原文出自:http://krasimirtsonev.com/blog/article/react-js-in-design-patterns
前言
我想找一个好的前端前端框架,找了很久。这个框架将能够帮助我写出具有可扩展性、可维护性 UI 的代码。通过对 React.js 优势的理解,我认为“我找到了它”。在我大量的使用过程中,我发现了一些模式性的东西。这些技术被一次又一次的用于编程开发之中。此时,我将它写下来、讨论和分享这些我发现的模式。
这些所有的代码都是可用的,能够在 https://github.com/krasimir/react-in-patterns 中下载。我可能不会更新我的博客,但是我将一直在 GitHub 中发布一些东西。我也将鼓励你在 GitHub 中讨论这些模式,通过 issue 或者直接 pull request 的方式。
一、React 自己的交流方式(Communication)
在使用 React 构建了几个月的情况下,你将能够体会到每一个 React Component 都是一个小系统,它能够自己运作。它有自己的 state、input、output.
Input
React Component 通过 props
作为 input(之后用输入代替)。下面我们来写一个例子:
// Title.jsx
class Title extends React.Component {
render() {
return <h1>{ this.props.text }</h1>;
}
};
Title.propTypes = {
text: React.PropTypes.string
};
Title.defaultProps = {
text: 'Hello world'
};
// App.jsx
class App extends React.Component {
render() {
return <Title text='Hello React' />;
}
};
其中的 Title
组件只有一个输入 - text
. 在父组件(App)提供了一个属性,通过 <Title>
组件。在 Title
组件中我们添加了两个设置 propTypes
和 defaultProps
,我们来单独看一下:
propTypes - 定义 props 的类型,这将帮助我们告诉 React 我们将传什么类型的 prop,能够对这个 prop 进行验证(或者说是测试)。
defaultProps - 定义 props 默认的值,设置一个默认值是一个好习惯。
还有一个 props.children
属性,能够让我们访问到当前组件的子组件。比如:
class Title extends React.Component {
render() {
return (
<h1>
{ this.props.text }
{ this.props.children }
</h1>
);
}
};
class App extends React.Component {
render() {
return (
<Title text='Hello React'>
<span>community</span>
</Title>
);
}
};
值得注意的是:如果我们没有在 Title 组件的 render 方法中添加 { this.props.children } 代码,其中的 span 标签(孩子组件)将不会被渲染。
对于一个组件的间接性输入(就是多层组件传递数据的时候),我们也可以调用 context
进行数据的访问。在整个 React tree 中的每一个组件中可能会有一个 context 对象。更多的说明将在依赖注入
章节讲解。
Output
React 的输出就是渲染过后的 HTML 代码。在视觉上我们将看到一个 React 组件的样子。当然,有些组件可能包含一些逻辑,能够帮助我们传递一些数据或者触发一个事件行为(这类组件可能不会有具体的 UI 形态)。为了实现逻辑类型的组件,我们将继续使用组件的 props:
class Title extends React.Component {
render() {
return (
<h1>
<a onClick={ this.props.logoClicked }>
<img src='path/to/logo.png' />
</a>
</h1>
);
}
};
class App extends React.Component {
render() {
return <Title logoClicked={ this.logoClicked } />;
}
logoClicked() {
console.log('logo clicked');
}
};
我们通过一个 callback 的方式在子组件中进行调用,logoClicked
方法能够接受一些数据,这样我们就能够从子组件向父组件传输一些数据了(这里就是 React 方式的子组件向父组件通信)。
我们之前有提到我们不能够访问 child 的 state。或者换句话说,我们不能够使用 this.props.children[0].state 的方式或者其他什么方式去访问。正确的姿势应该是通过 props callback 的方式获取子组件的一些信息。这是一件好事。这就迫使我们要去定义明确的 APIs,并鼓励使用单向数据流(在后面的单向数据流
中将介绍)。
二、组件构成(composition)
另外一个很棒的是 React 的可组合性。对于我来说,除了 React 之外还没有发现有任何框架能够如此简单的方式去创建组件以及合并组件。这段我将探索一些组件的构建方式,来让开发工作更加棒。
让我们先来看一个简单的例子:
假设我们有一个应用,包含 header 部分,header 内部有一个 navigation(导航)组件。
所以,我们将有三个 React 组件:App、Header 和 Navigation。
他们是层级嵌套的关系。
所以最后代码如下:
<App>
<Header>
<Navigation> ... </Navigation>
</Header>
</App>
我们为了组合这些小组件,并且引用他们,我们需要向下面这样定义他们:
// app.jsx
import Header from './Header.jsx';
export default class App extends React.Component {
render() {
return <Header />;
}
}
// Header.jsx
import Navigation from './Navigation.jsx';
export default class Header extends React.Component {
render() {
return <header><Navigation /></header>;
}
}
// Navigation.jsx
export default class Navigation extends React.Component {
render() {
return (<nav> ... </nav>);
}
}
然而这样,我们用这种方式去组织组件会有几个问题:
我们将 App 组件做为程序的入口,在这个组件里面去构建组件是一个不错的地方。对于 Header 组件,可能会包含其他组件,比如 logo、search 或者 slogan 之类的。它将是非常好处理,可以通过某种方式从外部传入,因此我们没有需要创建一个强依赖的组件。如果我们在另外的地方需要使用 Header 组件,但是这个时候又不需要内层的 Navigation 子组件。这个时候我们就不容易实现,因为 Header 和 Navigation 组件是两个强耦合的组件。
这样编写组件是不容易测试的,我们可能在 Header 组件中有一些业务逻辑,为了测试 Header 组件,我们就必须要创建一个 Header 的实例(其实就是引用组件来渲染)。然而,又因为 Header 组件依赖了其他组件,这就导致了我们也可能需要创建一些其他组件的实例,这就让测试不是那么容易。并且我们在测试过程中,如果 Navigation 组件测试失败,也将导致 Header 组件测试失败,这将导致一个错误的测试结果(因为不会知道是哪个组件测试没有通过)。(注:然后在测试中 shallow rendering 解决了这个问题,能够只渲染 Header 组件,不用实例化其他组件)。
使用 React's children API
在 React 中,我们能够通过 this.props.children
来很方便的处理这个问题。这个属性能够让父组件读取和访问子组件。这个 API 将使我们的 Header 组件更抽象和低耦合(原文是 dependency-free 不好翻译,但是是这个意思)。
// App.jsx
export default class App extends React.Component {
render() {
return (
<Header>
<Navigation />
</Header>
);
}
}
// Header.jsx
export default class Header extends React.Component {
render() {
return <header>{ this.props.children }</header>;
}
}
这将容易测试,因为我们可以让 Header 组件渲染成一个空的 div 标签。这就让组件脱离出来,然后只专注于应用的开发(其实就是抽象了一层父组件,然后让这个父组件和子组件进行了解耦,然后子组件可能才是应用的一些功能实现)。
将 child 做为一个属性
每一个 React 组件都接受 props。这非常好,这个 props 属性能包含一些数据。或者说是其他组件。
// App.jsx
class App extends React.Component {
render() {
var title = <h1>Hello there!</h1>;
return (
<Header title={ title }>
<Navigation />
</Header>
);
}
};
// Header.jsx
export default class Header extends React.Component {
render() {
return (
<header>
{ this.props.title }
<hr />
{ this.props.children }
</header>
);
}
};
这个技术在我们要合并两个组件,这个组件在 Header 内部的时候是非常有用的,以及在外部提供这个需要合并的组件。
三、高阶组件(Higher-order components)
高阶组件看起来很像装饰器模式。他是包裹一个组件和附加一些其他功能或者 props 給它。
这里通过一个函数来返回一个高阶组件:
var enhanceComponent = (Component) =>
class Enhance extends React.Component {
render() {
return (
<Component
{...this.state}
{...this.props}
/>
)
}
};
export default enhanceComponent;
我们经常提供一个工厂函数,接受我们的原始组件,当我们需要访问的时候,就返回这个 被升级或者被包裹 过的组件版本給它。比如:
var OriginalComponent = () => <p>Hello world.</p>;
class App extends React.Component {
render() {
return React.createElement(enhanceComponent(OriginalComponent));
}
};
首先,高阶组件其实也是渲染的原始组件(传入的组件)。一个好的习惯是直接传入 state 和 props 給它。这将有助于我们想代理数据和像是用原始组件一样去使用这个高阶组件。
高阶组件让我们能够控制输入。这些数据我们想通过 props 进行传递。现在像我们说的那样,我们有一个配置,OriginalComponent 组件需要这个配置的数据,代码如下:
var config = require('path/to/configuration');
var enhanceComponent = (Component) =>
class Enhance extends React.Component {
render() {
return (
<Component
{...this.state}
{...this.props}
title={ config.appTitle }
/>
)
}
};
这个配置是隐藏在高阶组件中。OriginalComponent 组件只能通过 props 来调用 title 数据。至于 title 数据从哪里来对于 OriginalComponent 来说并不重要(这就非常棒了!封闭性做的很好)。这是极大的优势,因为它帮助我们测试独立组件,以及提供一个好的机制去 mocking 数据。这里能够这样使用 title 属性( 也就是 stateless component[无状态组件] )。
var OriginalComponent = (props) => <p>{ props.title }</p>;
高阶组件是需要另外一个有用的模式-依赖注入(dependency injection)。
四、依赖注入(Dependency injection)
大部分模块/组件都会有依赖。能够合理的管理这些依赖能够直接影响到项目是否成功。有一个技术叫:依赖注入(dependency injection,之后我就简称 DI 吧)。也有部分人称它是一种模式。这种技术能够解决依赖的问题。
在 React 中 DI 很容易实现,让我们跟着应用来思考:
// Title.jsx
export default function Title(props) {
return <h1>{ props.title }</h1>;
}
// Header.jsx
import Title from './Title.jsx';
export default function Header() {
return (
<header>
<Title />
</header>
);
}
// App.jsx
import Header from './Header.jsx';
class App extends React.Component {
constructor(props) {
super(props);
this.state = { title: 'React in patterns' };
}
render() {
return <Header />;
}
};
有一个 "React in patterns" 的字符串,这个字符串以某种方式来传递给 Title 组件。
最直接的方式是通过: App => Header => Title 每一层通过 props 来传递。然而这样可能在这个三个组件的时候比较方便,但是如果有多个属性以及更深的组件嵌套的情况下将比较麻烦。大量组件将接收到它们并不需要的属性(因为是逐层传递)。
我们前面提到的高阶组件的方式能够用来注入数据。让我们用这个技术来注入一下 title 变量。
// enhance.jsx
var title = 'React in patterns';
var enhanceComponent = (Component) =>
class Enhance extends React.Component {
render() {
return (
<Component
{...this.state}
{...this.props}
title={ title }
/>
)
}
};
// Header.jsx
import enhance from './enhance.jsx';
import Title from './Title.jsx';
var EnhancedTitle = enhance(Title);
export default function Header() {
return (
<header>
<EnhancedTitle />
</header>
);
}
这个 title 是隐藏在中间层(高阶组件)中,我们通过 prop 来传递给 Title 组件。这很好的解决了,但是这只是解决了一半问题,现在我们没有层级的方式去传递 title,但是这些数据都在 echance.jsx 中间层组件。
React 有一个 context 的概念,这个 context 能够在每一个组件中都可以访问它。这个优点像 event bus 模型,只不过这里是一个数据。这个方式让我们能够在任何地方访问到数据。
// 我们定义数据的地方:context => title
var context = { title: 'React in patterns' };
class App extends React.Component {
getChildContext() {
return context;
}
...
};
App.childContextTypes = {
title: React.PropTypes.string
};
// 我们需要这个数据的地方
class Inject extends React.Component {
render() {
var title = this.context.title;
...
}
}
Inject.contextTypes = {
title: React.PropTypes.string
};
值得注意的是我们必须使用 childContextTypes 和 contextTypes 这两个属性,定义这个上下文对象的类型声明。如果没有声明,context 这个对象将为空(经我测试,如果没有这些类型定义直接报错了,所以一定要记得加上哦)。这可能有些不太合适的地方,因为我们可能会放大量的东西在这里。所以说 context 定义成一个纯对象不是很好的方式,但是我们能够让它成为一个接口的方式来使用它,这将允许我们去存储和获取数据,比如:
// dependencies.js
export default {
data: {},
get(key) {
return this.data[key];
},
register(key, value) {
this.data[key] = value;
}
}
然后,我们再看一下我们的例子,顶层的 App 组件可能就会像这样写:
import dependencies from './dependencies';
dependencies.register('title', 'React in patterns');
class App extends React.Component {
getChildContext() {
return dependencies;
}
render() {
return <Header />;
}
};
App.childContextTypes = {
data: React.PropTypes.object,
get: React.PropTypes.func,
register: React.PropTypes.func
};
然后,我们的 Title 组件就从这个 context 中获取数据:
// Title.jsx
export default class Title extends React.Component {
render() {
return <h1>{ this.context.get('title') }</h1>
}
}
Title.contextTypes = {
data: React.PropTypes.object,
get: React.PropTypes.func,
register: React.PropTypes.func
};
最好的方式是我们在每次使用 context 的时候不想定义 contextTypes。这就是能够使用高阶组件包裹一层。甚至更多的是,我们能够写一个单独的函数,去更好的描述和帮助我们声明这个额外的地方。之后通过 this.context.get('title') 的方式直接访问 context 数据。我们通过高阶组件获取我们需要的数据,然后通过 prop 的方式来传递给我们的原始组件,比如:
// Title.jsx
import wire from './wire';
function Title(props) {
return <h1>{ props.title }</h1>;
}
export default wire(Title, ['title'], function resolve(title) {
return { title };
});
这个 wire 函数有三个参数:
一个 React 组件
需要依赖的数据,这个数据以数组的方式定义
一个 mapper 的函数,它能接受上下文的原始数据,然后返回一个我们的 React 组件(比如 Title 组件)实际需要的数据对象(相当于一个 filter 管道的作用)。
这个例子我们只是通过这种方式传递来一个 title 字符串变量。然后在实际应用开发过程中,它可能是一个数据的存储集合,配置或者其他东西。因此,我们通过这种方式,我们能够通过哪些我们确实需要的数据,不用去污染组件,让它们接收一些并不需要的数据。
这里的 wire 函数定义如下:
export default function wire(Component, dependencies, mapper) {
class Inject extends React.Component {
render() {
var resolved = dependencies.map(this.context.get.bind(this.context));
var props = mapper(...resolved);
return React.createElement(Component, props);
}
}
Inject.contextTypes = {
data: React.PropTypes.object,
get: React.PropTypes.func,
register: React.PropTypes.func
};
return Inject;
};
Inject 是一个高阶组件,它能够访问 context 对象的 dependencies 所有的配置项数组。这个 mapper 函数能够接收 context 的数据,并转换它,然后给 props 最后传递到我们的组件。
最后来看一下关于依赖注入
在很多解决方案中,都使用了依赖注入的技术,这些都基于 React 组件的 context 属性。我认为这很好的知道发生了什么。在写这篇文凭的时候,大量流行构建 React 应用的方式会需要 Redux。著名 connect 函数和 Provider 组件,就是使用的 context(现在大家可以看一下源码了)。
我个人发现这个技术是真的有用。它是满足了我处理所有依赖数据的需要,使我的组件变得更加纯粹和更方便测试。
五、单向数据流(One-way direction data flow)
在 React 中单向数据流的模式运作的很好。它让组件不用修改数据,只是接收它们。它们只监听数据的改变和可能提供一些新的值,但是它们不会去改变数据存储器里面实际的数据。更新会放在另外地方的机制下,和组件只是提供渲染和新的值。
让我们来看一个简单的 Switcher 组件的例子,这个组件包含了一个 button。我们点击它将能够控制切换(flag 不好翻译,程序员都懂的~)
class Switcher extends React.Component {
constructor(props) {
super(props);
this.state = { flag: false };
this._onButtonClick = e => this.setState({ flag: !this.state.flag });
}
render() {
return (
<button onClick={ this._onButtonClick }>
{ this.state.flag ? 'lights on' : 'lights off' }
</button>
);
}
};
// ... and we render it
class App extends React.Component {
render() {
return <Switcher />;
}
};
这个时候再我们的组件里面有一个数据。或者换句话说:Switcher 只是一个一个我们需要通过 flag 变量来渲染的地方。让我们发送它到一个外面的 store 中:
var Store = {
_flag: false,
set: function (value) {
this._flag = value;
},
get: function () {
return this._flag;
}
};
class Switcher extends React.Component {
constructor(props) {
super(props);
this.state = { flag: false };
this._onButtonClick = e => {
this.setState({ flag: !this.state.flag }, () => {
this.props.onChange(this.state.flag);
});
}
}
render() {
return (
<button onClick={ this._onButtonClick }>
{ this.state.flag ? 'lights on' : 'lights off' }
</button>
);
}
};
class App extends React.Component {
render() {
return <Switcher onChange={ Store.set.bind(Store) } />;
}
};
我们的 Store 对象是单例 我们有 helper 去设置和获取 _flag 这个属性的值。通过 getter,然后组件能够通过外部数据进行更新。大楷我们的应用工作流看起来是这样的:
User's input
|
Switcher -------> Store
让我们假设我们要通过 Store 给后端服务去保存这个 flag 值。当用户返回的时候,我们必须设置合适的初始状态。如果用户离开后在后来,我们必须展示 "lights on" 而不是默认的 "lights off"。现在它变得困难,因为我们的数据是在两个地方。UI 和 Store 中都有自己的状态,我们必须在它们之间交流:Store --> Switcher 和 Switcher --> Store。
// ... in App component
<Switcher
value={ Store.get() }
onChange={ Store.set.bind(Store) } />
// ... in Switcher component
constructor(props) {
super(props);
this.state = { flag: this.props.value };
...
我们的模型改变就要通过:
User's input
|
Switcher <-------> Store
^ |
| |
| |
| v
Service communicating
with our backend
所有这些都导致了需要管理两个状态而不是一个。如果 Store 的改变是通过其他系统的行为,我们就必须传送这些改变给 Switcher 组件和我们就增加了自己 App 的复杂度。
单向数据流就解决了这个问题。它消除了这种多种状态的情况,只保留一个状态,这个状态一般是在 Store 里面。为了实现单向数据流这种方式,我们必须简单修改一下我们的 Store 对象。我们需要一个能够订阅改变的逻辑。
var Store = {
_handlers: [],
_flag: '',
onChange: function (handler) {
this._handlers.push(handler);
},
set: function (value) {
this._flag = value;
this._handlers.forEach(handler => handler())
},
get: function () {
return this._flag;
}
};
然后我们将有一个钩子在主要的 App 组件中,我们将在每次 Store 中的数据变化的时候重新渲染它。
class App extends React.Component {
constructor(props) {
super(props);
Store.onChange(this.forceUpdate.bind(this));
}
render() {
return (
<div>
<Switcher
value={ Store.get() }
onChange={ Store.set.bind(Store) } />
</div>
);
}
};
注:我们使用了 forceUpdate 的方式,但这种方式不推荐使用。一般情况能够使用高阶组件进行重新渲染。我们使用 forceUpdate 只是简单的演示。
因为这个改变,Switcher 变得比之前简单。我们不需要内部的 state:
class Switcher extends React.Component {
constructor(props) {
super(props);
this._onButtonClick = e => {
this.props.onChange(!this.props.value);
}
}
render() {
return (
<button onClick={ this._onButtonClick }>
{ this.props.value ? 'lights on' : 'lights off' }
</button>
);
}
};
这个好处在于:这个模式让我们的组件变成了展示 Store 数据的一个填鸭式组件。它是真的让 React 组件变成了纯粹的渲染层。我们写我们的应用是声明的方式,并且只在一个地方处理一些复杂的数据。
这个应用的工作流就变成了:
Service communicating
with our backend
^
|
v
Store <-----
| |
v |
Switcher ---->
^
|
|
User input
我们看到这个数据流都是一个方向流动的,并且在我们的系统中,不需要同步两个部分(或者更多部分)。单向数据流不止能基于 React 应用,这些就是它让应用变得更简单的原因,这个模式可能还需要更多的实践,但是它是确实值得探索的。
六、结语
当然,这不是在 React 中所有的设计模式/技术。还可能有更多的模式,你能够 checkout github.com/krasimir/react-in-patterns 进行更新。我将努力分享我新的发现。