本文从属于笔者的React入门与最佳实践系列,推荐阅读GUI应用程序架构的十年变迁:MVC,MVP,MVVM,Unidirectional,Clean
Communication
React组件一个很大的特性在于其拥有自己完整的生命周期,因此我们可以将React组件视作可自运行的小型系统,它拥有自己的内部状态、输入与输出。
Input
对于React组件而言,其输入的来源就是Props,我们会用如下方式向某个React组件传入数据:
// 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' />; } };
text
是Text
组件自己的输入域,父组件App
在使用子组件Title
时候应该提供text
属性值。除了标准的属性名之外,我们还会用到如下两个设置:
propTypes:用于定义Props的类型,这有助于追踪运行时误设置的Prop值。
defaultProps:定义Props的默认值,这个在开发时很有帮助
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
标签是不会被渲染出来的。除了Props之外,另一个隐性的组件的输入即是context
,整个React组件树会拥有一个context
对象,它可以被树中挂载的每个组件所访问到,关于此部分更多的内容请参考依赖注入这一章节。
Output
组件最明显的输出就是渲染后的HTML文本,即是React组件渲染结果的可视化展示。当然,部分包含了逻辑的组件也可能发送或者触发某些Action或者Event。
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'); } };
在App
组件中我们向Title
组件传入了可以从Title
调用的回调函数,在logoClicked
函数中我们可以设置或者修改需要传回父组件的数据。需要注意的是,React并没有提供可以访问子组件状态的API,换言之,我们不能使用this.props.children[0].state
或者类似的方法。正确的从子组件中获取数据的方法应该是在Props中传入回调函数,而这种隔离也有助于我们定义更加清晰的API并且促进了所谓单向数据流。
Composition
React最大的特性之一即是其强大的组件的可组合性,实际上除了React之外,笔者并不知道还有哪个框架能够提供如此简单易用的方式来创建与组合各式各样的组件。本章我们会一起讨论些常用的组合技巧,我们以一个简单的例子来进行讲解。假设在我们的应用中有一个页首栏目,并且其中放置了导航栏。我们创建了三个独立的React组件:App
,Header
以及Navigation
。将这三个组件依次嵌套组合,可以得到以下的代码:
<App> <Header> <Navigation> ... </Navigation> </Header> </App>
而在JSX中组合这些组件的方式就是在需要的时候引用它们:
// 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
当做各个组件间的连接线,也是整个应用的入口,因此在App
中进行各个独立组件的组合是个不错的方法。不过Header
元素中可能包含像图标、搜索栏或者Slogan这样的元素。而如果我们需要另一个不包含Navigation
功能的Header
组件时,像上面这种直接将Navigation
组件硬编码进入Header
的方式就会难于修改。这种硬编码的方式会难以测试,如果我们在
Header
中加入一些自定义的业务逻辑代码,那么在测试的时候当我们要创建Header
实例时,因为其依赖于其他组件而导致了这种依赖层次过深(这里不包含Shallow Rendering这种仅渲染父组件而不渲染嵌套的子组件方式)。
使用React的children
API
React为我们提供了this.props.children
来允许父组件访问其子组件,这种方式有助于保证我们的Header
独立并且不需要与其他组件解耦合。
// 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>; } };
这种方式也有助于测试,我们可以选择输入空白的div
元素,从而将要测试的目标元素隔离开来而专注于我们需要测试的部分。
将子组件以属性方式传入
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> ); } };
这种方式在我们需要对传入的待组合组件进行一些修正时非常适用。
Higher-order components
Higher-Order Components模式看上去非常类似于装饰器模式,它会用于包裹某个组件然后为其添加一些新的功能。这里展示一个简单的用于构造Higher-Order Component的函数:
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)); } };
一般来说,高阶组件的首要工作就是渲染原始的组件,我们经常也会将Props与State传递进去,将这两个属性传递进去会有助于我们建立一个数据代理。HOC模式允许我们控制组件的输入,即将需要传入的数据以Props传递进去。譬如我们需要为原始组件添加一些配置:
var config = require('path/to/configuration'); var enhanceComponent = (Component) => class Enhance extends React.Component { render() { return ( <Component {...this.state} {...this.props} title={ config.appTitle } /> ) } };
这里对于configuration
的细节实现会被隐藏到高阶组件中,原始组件只需要了解从Props中获取到title
变量然后渲染到界面上。原始组件并不会关心变量存于何地,从何而来,这种模式最大的优势在于我们能够以独立的模式对该组件进行测试,并且可以非常方便地对该组件进行Mocking。在HOC模式下我们的原始组件会变成这样子:
var OriginalComponent = (props) => <p>{ props.title }</p>;
Dependency injection
我们写的大部分组件与模块都会包含一些依赖,合适的依赖管理有助于创建良好可维护的项目结构。而所谓的依赖注入技术正是解决这个问题的常用技巧,无论是在Java还是其他应用程序中,依赖注入都受到了广泛的使用。而React中对于依赖注入的需要也是显而易见的,让我们假设有如下的应用树结构:
// 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 />; } };
title
这个变量的值是在App
组件中被定义好的,我们需要将其传入到Title
组件中。最直接的方法就是将其从App
组件传入到Header
组件,然后再由Header
组件传入到Title
组件中。这种方法在这里描述的简单的仅有三个组件的应用中还是非常清晰可维护的,不过随着项目功能与复杂度的增加,这种层次化的传值方式会导致很多的组件要去考虑它们并不需要的属性。在上文所讲的HOC模式中我们已经使用了数据注入的方式,这里我们使用同样的技术来注入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 } /> ) } }; export default enhanceComponent; // Header.jsx import enhance from './enhance.jsx'; import Title from './Title.jsx'; var EnhancedTitle = enhance(Title); export default function Header() { return ( <header> <EnhancedTitle /> </header> ); }
在上文这种HOC模式中,title
变量被包含在了一个隐藏的中间层中,我们将其作为Props值传入到原始的Title
变量中并且得到一个新的组件。这种方式思想是不错,不过还是只解决了部分问题。现在我们可以不去显式地将title
变量传递到Title
组件中即可以达到同样的enhance.jsx
效果。
React为我们提供了context
的概念,context
是贯穿于整个React组件树允许每个组件访问的对象。有点像所谓的Event Bus,一个简单的例子如下所示:
// a place where we'll define the context var context = { title: 'React in patterns' }; class App extends React.Component { getChildContext() { return context; } ... }; App.childContextTypes = { title: React.PropTypes.string }; // a place where we need data class Inject extends React.Component { render() { var title = this.context.title; ... } } Inject.contextTypes = { title: React.PropTypes.string };
注意,我们要使用context对象必须要通过childContextTypes
与contextTypes
指明其构成。如果在context
对象中未指明这些那么context
会被设置为空,这可能会添加些额外的代码。因此我们最好不要将context
当做一个简单的object对象而为其设置一些封装方法:
// 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
组件中,我们需要进行如下设置:
// Title.jsx export default class Title extends React.Component { render() { return <h1>{ this.context.get('title') }</h1> } } Title.contextTypes = { data: React.PropTypes.object,register: React.PropTypes.func };
当然我们不希望在每次要使用contextTypes
的时候都需要显式地声明一下,我们可以将这些声明细节包含在一个高阶组件中。
// 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组件对象,第二个参数是一系列需要注入的依赖值,注意,这些依赖值务必已经调用过register
函数。最后一个参数则是所谓的映射函数,它接收存储在context
中的某个原始值然后返回React Props中需要的值。因为在这个例子里context
中存储的值与Title
组件中需要的值都是title
变量,因此我们直接返回即可。不过在真实的应用中可能是一个数据集合、配置等等。
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,register: React.PropTypes.func }; return Inject; };
这里的Inject就是某个可以访问context
的高阶组件,而mapper
就是用于接收context
中的数据并将其转化为组件所需要的Props的函数。实际上现在大部分的依赖注入的解决方案都是基于context
,我觉得了解这种方式的底层原理还是很有意义的。譬如现在流行的Redux
,其核心的connect
函数与Provider
组件都是基于context
。
One direction data flow
单向数据流是React中主要的数据驱动模式,其核心概念在于组件并不会修改它们接收到的数据,它们只是负责接收新的数据而后重新渲染到界面上或者发出某些Action以触发某些专门的业务代码来修改数据存储中的数据。我们先设置一个包含一个按钮的Switcher
组件,当我们点击该按钮时会触发某个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
对象是一个简单的单例对象,可以帮助我们设置与获取_flag
属性值。而通过将getter
函数传递到组件内,可以允许我们在Store
外部修改这些变量,此时我们的应用工作流大概是这样的:
User's input | Switcher -------> Store
假设我们已经将flag
值保存到某个后端服务中,我们需要为该组件设置一个合适的初始状态。此时就会存在一个问题在于同一份数据保存在了两个地方,对于UI与Store
分别保存了各自独立的关于flag
的数据状态,我们等于在Store
与Switcher
之间建立了双向的数据流: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
中,这样也在无形之间增加了应用的复杂度。而单向数据流则是解决了这个问题,它强制在全局只保留一个状态存储,通常是存放在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
并不是一个推荐的用法,我们通常会使用HOC模式来进行重渲染,这里使用forceUpdate
只是用于演示说明。在基于上述的改造,我们就不需要在组件中继续保留内部状态:
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
中数据的呈现,此时才是真正无状态的View。我们可以以完全声明式的方式来编写组件,而将应用中复杂的业务逻辑放置到单独的地方。此时我们应用程序的流图变成了:
Service communicating with our backend ^ | v Store <----- | | v | Switcher ----> ^ | | User input
在这种单向数据流中我们不再需要同步系统中的多个部分,这种单向数据流的概念并不仅仅适用于基于React的应用。
Flux
关于Flux的简单了解可以参考笔者的GUI应用程序架构的十年变迁:MVC,Clean
Flux是用于构建用户交互界面的架构模式,最早由Facebook在f8大会上提出,自此之后,很多的公司开始尝试这种概念并且貌似这是个很不错的构建前端应用的模式。Flux经常和React一起搭配使用,笔者本身在日常的工作中也是使用React+Flux的搭配,给自己带来了很大的遍历。
Flux中最主要的角色为Dispatcher,它是整个系统中所有的Events的中转站。Dispatcher负责接收我们称之为Actions的消息通知并且将其转发给所有的Stores。每个Store实例本身来决定是否对该Action感兴趣并且是否相应地改变其内部的状态。当我们将Flux与熟知的MVC相比较,你就会发现Store在某些意义上很类似于Model,二者都是用于存放状态与状态中的改变。而在系统中,除了View层的用户交互可能触发Actions之外,其他的类似于Service层也可能触发Actions,譬如在某个HTTP请求完成之后,请求模块也会发出相应类型的Action来触发Store中对于状态的变更。
而在Flux中有个最大的陷阱就是对于数据流的破坏,我们可以在Views中访问Store中的数据,但是我们不应该在Views中修改任何Store的内部状态,所有对于状态的修改都应该通过Actions进行。作者在这里介绍了其维护的某个Flux变种的项目fluxiny。
Dispatcher
大部分情况下我们在系统中只需要单个的Dispatcher,它是类似于粘合剂的角色将系统的其他部分有机结合在一起。Dispatcher一般而言有两个输入:Actions与Stores。其中Actions需要被直接转发给Stores,因此我们并不需要记录Actions的对象,而Stores的引用则需要保存在Dispatcher中。基于这个考虑,我们可以编写一个简单的Dispatcher:
var Dispatcher = function () { return { _stores: [],register: function (store) { this._stores.push({ store: store }); },dispatch: function (action) { if (this._stores.length > 0) { this._stores.forEach(function (entry) { entry.store.update(action); }); } } } };
在上述实现中我们会发现,每个传入的Store
对象都应该拥有一个update
方法,因此我们在进行Store的注册时也要来检测该方法是否存在:
register: function (store) { if (!store || !store.update) { throw new Error('You should provide a store that has an `update` method.'); } else { this._stores.push({ store: store }); } }
在完成了对于Store的注册之后,下一步我们就是需要将View与Store关联起来,从而在Store发生改变的时候能够触发View的重渲染:
很多flux的实现中都会使用如下的辅助函数:
Framework.attachToStore(view,store);
不过作者并不是很喜欢这种方式,这样这样会要求View中需要调用某个具体的API,换言之,在View中就需要了解到Store的实现细节,而使得View与Store又陷入了紧耦合的境地。当开发者打算切换到其他的Flux框架时就不得不修改每个View中的相对应的API,那又会增加项目的复杂度。另一种可选的方式就是使用React mixins
:
var View = React.createClass({ mixins: [Framework.attachToStore(store)] ... });
使用mixin
是个不错的修改现有的React 组件而不影响其原有代码的方式,不过这种方式的缺陷在于它不能够以一种Predictable的方式去修改组件,用户的可控性较低。还有一种方式就是使用React context
,这种方式允许我们将值跨层次地传递给React组件树中的组件而不需要了解它们处于组件树中的哪个层级。这种方式和mixins可能有相同的问题,开发者并不知道该数据从何而来。
作者最终选用的方式即是上面提及到的Higher-Order Components模式,它建立了一个包裹函数来对现有组件进行重新打包处理:
function attachToStore(Component,store,consumer) { const Wrapper = React.createClass({ getInitialState() { return consumer(this.props,store); },componentDidMount() { store.onChangeEvent(this._handleStoreChange); },componentWillUnmount() { store.offChangeEvent(this._handleStoreChange); },_handleStoreChange() { if (this.isMounted()) { this.setState(consumer(this.props,store)); } },render() { return <Component {...this.props} {...this.state} />; } }); return Wrapper; };
其中Component
代指我们需要附着到Store
中的View,而consumer
则是应该被传递给View的Store中的部分的状态,简单的用法为:
class MyView extends React.Component { ... } ProfilePage = connectToStores(MyView,(props,store) => ({ data: store.get('key') }));
这种模式的优势在于其有效地分割了各个模块间的职责,在该模式中Store并不需要主动地推送消息给View,而主需要简单地修改数据然后广播说我的状态已经更新了,然后由HOC去主动地抓取数据。那么在作者具体的实现中,就是选用了HOC模式:
register: function (store) { if (!store || !store.update) { throw new Error('You should provide a store that has an `update` method.'); } else { var consumers = []; var change = function () { consumers.forEach(function (l) { l(store); }); }; var subscribe = function (consumer) { consumers.push(consumer); }; this._stores.push({ store: store,change: change }); return subscribe; } return false; },dispatch: function (action) { if (this._stores.length > 0) { this._stores.forEach(function (entry) { entry.store.update(action,entry.change); }); } }
另一个常见的用户场景就是我们需要为界面提供一些默认的状态,换言之当每个consumer
注册的时候需要提供一些初始化的默认数据:
var subscribe = function (consumer,noInit) { consumers.push(consumer); !noInit ? consumer(store) : null; };
综上所述,最终的Dispatcher函数如下所示:
var Dispatcher = function () { return { _stores: [],register: function (store) { if (!store || !store.update) { throw new Error('You should provide a store that has an `update` method.'); } else { var consumers = []; var change = function () { consumers.forEach(function (l) { l(store); }); }; var subscribe = function (consumer,noInit) { consumers.push(consumer); !noInit ? consumer(store) : null; }; this._stores.push({ store: store,change: change }); return subscribe; } return false; },dispatch: function (action) { if (this._stores.length > 0) { this._stores.forEach(function (entry) { entry.store.update(action,entry.change); }); } } } };
Actions
Actions就是在系统中各个模块之间传递的消息载体,作者觉得应该使用标准的Flux Action模式:
{ type: 'USER_LOGIN_REQUEST',payload: { username: '...',password: '...' } }
其中的type
属性表明该Action所代表的操作而payload
中包含了相关的数据。另外,在某些情况下Action中没有带有Payload,因此可以使用Partial Application方式来创建标准的Action请求:
var createAction = function (type) { if (!type) { throw new Error('Please,provide action\'s type.'); } else { return function (payload) { return dispatcher.dispatch({ type: type,payload: payload }); } } }
Final Code
上文我们已经了解了核心的Dispatcher与Action的构造过程,那么在这里我们将这二者组合起来:
var createSubscriber = function (store) { return dispatcher.register(store); }
并且为了不直接暴露dispatcher对象,我们可以允许用户使用createAction
与createSubscriber
这两个函数:
var Dispatcher = function () { return { _stores: [],entry.change); }); } } } }; module.exports = { create: function () { var dispatcher = Dispatcher(); return { createAction: function (type) { if (!type) { throw new Error('Please,provide action\'s type.'); } else { return function (payload) { return dispatcher.dispatch({ type: type,payload: payload }); } } },createSubscriber: function (store) { return dispatcher.register(store); } } } };