【React/Redux/Router/Immutable】React最佳实践的正确食用姿势
世界Skill
现代前端框架基本都是对传统系统应用框架的搬运,React虽定位为一个View层的框架,实际上却包含了MVVM中的每一环,每一个组件都可以看做是拥有所有环节的结合体。其激进的设计不但体现在JSX这个融合了HTML+JS+CSS的语法糖,也体现在了对MVVM的杂糅,然而和直觉不同,这并没有使得三者混乱分离,或者说M V VM这三者的聚合并不会带来什么问题,反而有一些益处。真正的问题在于组件嵌套带来的组件通信和VDOM使用不当带来的性能问题,而Redux和Immutable就是来解决这个问题的。此外,React-router的出现使得前端路由成为可能,这几者结合起来大幅加强了一个SPA的开发效率和可维护性。
接下来将以我的博客为例,论述一下这个最佳实践。
React
React的出现首先打破的是HTML、JS、CSS分离的格局,许多遗老遗少大呼又回到了JSP时代,宣称这是在逆历史而行在开倒车,而新晋分子则都不以为然,认为这才是趋势。当然历史已经告诉我们二元论是站不住脚的,所以优劣必然并存,一切大都是博弈之下的选择。
Preview
传统前端开发主要是堆砌HTML结构,加之调优的样式并赋予小部分JS代码用于交互,逻辑大头基本都在后端。然而随着WEB业务的日趋复杂,前端逻辑的复杂度已不可同日而语,SPA的出现、以及后续一堆基于WEBKIT浏览器的加壳应用使得传统方式难以抗住需求,新的设计方法必须被提出。然而传统系统应用领域实际上早就对这些东西研究的十分深入,所以直接搬来即可。React搬来的是经典的MVC架构几次演化后迭代而成的MVVM,即“模型-视图-视图模型”模型,这种模型的一个经典实现就是MS的WPF,React的许多理念与其也十分相似,比如单向数据流,方法绑定等等,虽没有WPF这种经典完善,但也做的很好。其基本理念是:
- 组件化,每一个组件都拥有自己独立的namespace。
- 内部维护一个状态模型,称为
state
。 - 组件可以嵌套,并通过
props
变量完成和父级的通信,可以向此变量挂载方法来使得父级响应子级的请求。 - 内部状态可以影响视图层的渲染结果,视图层可以绑定事件控制状态,从而影响状态。
- 完备的生命周期,可以预测并控制整个组件的状态流向。
如此,便实现了一个健壮的、组件化的视图框架。通过这种设计,加之JSX的混写,我们可以对一个组件的所有状态进行非常细粒度的控制,也可以将整个组件封装好粗粒度调用,伸缩性非常好。不仅如此,内联样式的优先度可以使得整个页面渲染更快,通过一些lib也可以实现伪类样式,唯一的缺点大概就是和设计人员的分工和IDE的智能补全了(笑)。
要注意的是,对于数据的流向,React是强制单向数据流的,也就是说,数据只能由状态单向得传递到视图层,而不能直接由视图层传回来,视图层对状态的影响只能通过绑定事件来实现。这可以预防双向绑定带来的状态预测困难问题,当然,双向绑定也有其好处,但个人认为其好处是远小于负面影响的。
对React基本原理和应用具体如何编写,此文章不做多余探讨,阅读者应当有一些基本的功底。下面主要讨论标题所论述的“最佳实践”。
有一定React使用经验的的程序员,应当都或多或少遇到过工程庞大后的性能、以及当组件树嵌套过深时组件间的通信问题。前者主要由VDOM处理不当导致的反复render造成,而后者,则是React自身的设计特性造成的。
DOM相关优化
VDOM是React的核心概念之一,“虚拟DOM树”,相对于真实的DOM树,它由用户创建后只存在于预处理阶段,只有在满足一定条件的状况下才会被渲染为真实的DOM。比起操作真实的DOM,虚拟DOM多了一步differ的过程,所以其实是要比直接操作慢的,但考虑到其带来的工程性的提升,这点性能损失根本不足为道——本因如此,但现实的很多使用中却并非总是如此。除了differ之外,不适当的重绘都会使得性能的显著下降,这大都是因为使用不当造成的。在组件的生命周期中,有一个阶段是shouldComponentUpdate
,这个方法的参数是nextProps
和nextState
,返回的是一个bool值,React会根据这个返回值来允许或阻止组件重绘。这是一个影响性能的核心方法,其基本使用如下:
shoudComponentUpdate(nextProps, nextState) {
return nextPropppps !== this.props;
}
当然,这一句只是伪代码,在真实情况下这么写是没用的(JS的机制)。通过设置合适的条件,我们可以防止绝大部分的无效重绘,这也是React应有的使用方法。这要求我们在设计之初就对组件的功能有着清晰的了解,但好在如果设计的尚可,组件应该是有一个合适的粒度的,这一部分也不会太复杂。
除了自己判断之外,如果结合下面所言的Redux,还可以用其提供的辅助函数connect
来决定子级组件需要件听哪些父级状态,这个下面说。
在此之外,我们还需要根据React的建议对组件设计好key
,这是其进行VDOM differ的核心凭据,尤其在以数组的形式描写一个序列的子级组件之时,这个key是必须提供的:
<Parent>
[
<Child0 key=0/>,
<Child1 key=1/>,
......
]
</Parent>
组件间状态传递
组件间的状态传递是原生React的一个老大难问题,第一节已经介绍过,原生的方法只能使得状态在相邻的父级和子级之间传递,如果嵌套超过三层,便很容易出现下面的情况:
表现为代码即:
// child.js
handleTextChange(value) {
handleTextChangeParent(value);
}
// parent.js
handleTextChangeParent(value) {
handleTextChangeGrandparent(value);
}
如果组件的层级再增长,这无疑是灾难性的,但很遗憾在现代开发中这种状况很常见。解决这种问题的方法之一是自己定义全局单例和事件管理器来共享变量,就像这篇文章写得这样,我们可以在组件初始化的时候注册若干事件,并在合适的地方dispatch
来实现组件间的跨级方法调用,而状态,则可以用全局单例来共享:
// child1.js
import globalState from './globalState';
import eventManager from './globalState';
export default class Child1 extends React.Component{
constructor(props){
super(props);
this.state = {playing: false};
eventManager.register("ChangeMode", ::this.refresh);
}
refresh() {
this.setState(globalState);
}
}
// child2.js
export default class Child2 extends React.Component
refresh() {
eventManager.dispatch('ChangeMode');
}
}
// globalState.js
export default {playing: false}
这样实际上也有一些问题,我们可以发现,如果将globalState
的变换交给一个单独的模块,将其称为controller
,便又成了经典的MVC模式。这种模式最后C这一层一般会非常臃肿庞大,所以小应用也就罢了,大应用.......
但好在我们还有Redux,并且不难发现,Redux和以上这种策略其实有一些思想上的相似。
Redux
原理
在Redux之前,FB官方出过一个配套的库叫做Flux
,它从一定程度上解决了上述的问题,但无奈过于复杂,使得很多场景下难以使用。所幸不久后Redux应运而出,它脱胎于Flux,并借鉴了一些elm语言的思想,整体走函数式风格——单向数据流,无状态编程。Redux的基本思想是这样的:
- 维护一个顶层数据仓库,称为
store
。 - 全局只能存在一个store,所有的状态都存在于其中。
- 有一些被称为
reducer
的方法,例如reducer/theme.js,它是唯一可以准许被修改store中state的方法,每个reducer对应一个state,他接受上一次的state、一个action
的type
和可选的修改量,根据type来进行操作并返回下一级的state。 - 一些被称为action的东西,例如action/index.js,至于说
东西
,因为这个东西比较抽象,只能说它定义了一些可预测的行为,他可以是type
,也可以是一些带有副作用的方法,一般和dispatch
方法配合使用。 - dispatch方法是Redux的核心方法,它是触发action的唯一途径。
store
、action
、reducer
和dispatch
,加上一个getState
,便构成了Redux。我们用store作为顶层实例,action定义行为,dispatch触发行为,reducer改变状态,getState获取状态,如下:
这便是Redux的基本原理,十分简单。在默认状态下,所有使得数据的变动方法都是pure的,没有任何副作用,一切都是同步可预测的(所谓同步可预测,就是可以精确掌控在什么时刻的状态时什么样的),我们可以在初始化时赋予它任何状态,以此作为整个应用的开端,这也是Redux下的服务端渲染的基础。
和React绑定
虽然Redux是一个通用性的库,但目前还是和React结合使用最多,在安装了react-redux
这个绑定之后,我们便可以利用它提供的Provider
组件和connect
方法将store和React的根级组件进行连接:
const store = createStore(
reducers,
initState,
applyMiddleware(...middleware)
);
const Root = <Provider store={store}>
<APP />
</Provider>;
// APP
@connect(
state => ({...state})
)
这里面,reducers
的定义可见reducers.js,它利用combineReducers
这个语法糖合并许多二级的reducer到根级reducer,并自动根据其生成二级的state树,注意里面的initState
,这是一个可选的初始状态,在服务端渲染中至关重要,而applyMiddleware
这个方法则是用于应用中间件的,中间件在下一节会有详解。Provider
组件将APP
这个应用根级组件包起来,作为新的根级组件。connect
这个方法通过一个方法(这里可以理解为filter
)筛选要传给下一级的state,由于是根级组件,所有状态都要传,所以将其解包即可。
这里不难发现,如果每一次状态改变都要从根组件向下传递,那么默认状况下,所有子级组件都会响应改变事件,可能会引起大量不必要的重绘或逻辑操作,这除了用上一章所言的shouldComponentUpdate
来屏蔽之外,还可以注意connect
这个方法提供的筛选,它实际上会帮我们做一次类似于内部的shouldComponentUpdate
,合理利用它甚至可以帮我们完全解决子级组件重绘的问题。
如此一来,我们便可以将一个应用的文件目录组织为这样:src,components
是React组件,actions
里面定义行为,reducers
里定义改变状态的方法。
执行action
在理想状况下,数据的改变都是pure的,无副作用,整个过程中没有IO操作也没有异步调用,这样数据的流向非常清晰,不会混乱。我们只需要不断使用以下语句来改变状态即可:
dispatch({type: actionTypes.change.theme.current, theme: 'home'});
接下来的事情交给Redux自己去解决就好,它会根据这个action的type去适配reducer并修改相应的state,并从根组件经过一路筛选将状态传递到需要的组件中,有需求的子级组件监听到了变化便可以执行相应的操作。
这种理想的状况确实很美好,但是现实世界是复杂的,这种情况对于绝大多数SPA是不可能存在的——ajax是基本需求。所以我们便需要一些具有副作用的action来帮我们执行操作,这里就需要提到Redux的中间件机制了,他的中间件充分利用了高阶函数的特性,有兴趣的可以在官网自行查看原理,这里我们需要了解的仅仅是——中间件本质上就是类似于流水线的东西,一个action请求被发出后,Redux会将其轮流送入被注册的中间件,经过这些中间件之后再最终进入reducer之内改变状态。利用这个特点,我们便可以使用中间件来拦截action请求,实现副作用。
Redux官方提供了这样的一个中间件——redux-thunk,其原理十分简单,代码只有寥寥几行,不再赘述。通过它,我们便可以定义一种特殊的action,并在其中执行有副作用的操作:
function getListSource(url: string) {
return dispatch => {
dispatch({type: actionTypes.get.list.waiting});
return request.get(url)
.timeout(1000)
.then(res => {
const list = res.body.content || [];
dispatch({type: actionTypes.get.list.successful, url, list});
return Promise.resolve(res);
})
.catch(err => {
dispatch({type: actionTypes.get.list.failed, url});
return Promise.reject(err);
});
};
}
// 执行
dispatch(getListSource('/someting'));
如此,异步操作也可以在Redux中实现了,我们在action中发起了一次ajax请求,并根据返回结果执行不同的pure-acition,来改变最终的状态。这种操作在Blog项目中共有三个——action/source.js,读者可以通过这些了解更多。
中间件的用处很多,除了这个之外,有一个很常用的中间件是redux-logger,它用于调试模式下的状态路径追踪,将其应用后可以在浏览器控制台看到如下输出:
这会使得调试变得方便清晰起来,也是Redux使得数据可预测的一个直观体现。
reducer改变state
在接收到dispatch的请求之后,Redux会将此次的action分发到对应的reducer,它实现的分发方式本质上就是“轮询”,正如前面所言,combinReducers
只是一个语法糖,实际上在分发是还是会走一遍所有的reducer,reducer接收到参数后进入适当的分支,从而完成对状态的修改。
这里要特别注意,Redux的函数式特性决定了它的上一次状态是不可变的,你不能去通过修改上一次状态然后将其返回作为下一次的状态,而是必须建立一个新的状态来修改并返回它。这在一些情况下非常容易实现,但如果状态层级比较深、本身比较复杂,JS自己的对象内部又是地址引用的,所以新建并维护状态就可能变得格外复杂。虽然可以用lodash
这样的工具库来简化一些操作,但仍然没有根本上解决问题,而这时候,FB官方的Immutable
库便出现了,它和Redux的相容性非常好。
Immutable.js
Immutable.js是FB出的一个不可变数据结构库,它提供了一系列的数据结构和丰富的API,其数据结构有自己的内部实现,和JS原生对象的原理有所不同。作为使用者,我们需要关心的只是他提供的接口和特性。它有一个非常重要的特性就是不可变性,也就是说,Immutable对象自身是不可被修改的,一切对它的修改只会体现在修改它的方法的返回值上,它本身是不变的,这和Redux的理念不谋而合,加之其丰富得不亚于lodash的API,和Redux配套使用基本可以算是最佳组合。
在实际使用中,Immutable最常见的是下列API:
import Immutable from 'immutable';
// 将JS对象转换为Immutable对象
const im = Immutable.fromJS(obj)
// 将Immutable对象转换为JS对象
const obj = im.toJS();
// 将Immutable对象转换为JSON对象
const json = im.toJSON();
// 判断对象中是否有值
const bool = im.has(name);
// 判断两个对象是否相等
const bool = im1.equals(im2);
// 赋值/深度赋值,SetIn方法将依次查找到最后一层的key并执行赋值
// 要注意这两个方法并不会自动将obj转换为immutable对象存储!
const newIm = im.set(key, value);
const newIm = im.setIn([key1, key2, ......], value);
// 取值/深度取值
// 深度取值第二个参数是一个可选的默认值,这是一种optional的实现,如果找不到值,将会返回默认值
const value = im.get(key);
const value = im.getIn([key1, key2, ......], defaultValue);
// 归并,obj可以是Immutable对象也可以是JS对象
const newIm = im.merge(obj);
在绝大多数应用中,这几个API可以撑起半边天,尤其是merge
这个方法会被经常使用(我们最好保证可以用Immutable的地方都用它)。此外equals
方法也可以在shouldComponentUpdate
中配合使用,使得两次props的对比变得非常简单。
React+Redux+Immutable这个组合由此完成了,合理使用它们,可以使我们的开发变得条理明晰,维护方便(当前,前提是配好那一堆恶心的环境,这里不再赘述,详情可以看这个Blog项目的配置文件(还没有单元测试相关的东西,如果加上单元测试还需要趟更多的坑,比如这个项目))。
React-router
至此,一个简单的SPA的构造条件便都满足,但这又会带来一个问题——SEO相关的问题。在这种SPA的模式下,页面是没有自己的独立URL的,这样不利于伪静态页面的生成,不便于搜索引擎索引,也不便于用户访问,前端路由就是来解决这个问题的。其核心在于利用js代码截获浏览器history中的url,通过捕获跳转或回退事件来完成应用内部的跳转行为,并同步浏览器的地址栏。
有了前端路由,我们便可以实现对SPA的分页,基于此,我们可以指定各自的url给各个页面,这使得页面的静态化成为可能。React-router则是FB官方给React提供的前端路由库,使用它,无论是搜索引擎还是用户都可以直接通过静态的url来访问单个页面。同时值得一提的是,React-router也可以在服务端使用,这样便可以实现WEB和服务端渲染的同构实现。在Blog项目中,这一部分在这个文件中src/routes,其一个经典的实现如下:
const routes = (
<Route path="/" component={App}>
<IndexRoute components={{content: Home}} />
<Route path="archives(/:index)" components={{content: Archives}} />
<Route path="category/:name(/:index)" components={{content: Category}} />
<Route path="article/:name" components={{content: Article}} />
......
<Route path="*" components={{content: NotFound}} />
</Route>
);
Route
是React-router的核心组件,他的第一个属性path
是相对于根域名的路由地址,第二个component
或者components
是路由对应的组件。在这个路由中,根路径对应的组件是APP
,所以网站会将APP作为整个应用的入口,我们必须实现APP这个组件,并让它能够在不同路由下接受不同的components,来渲染出对应的DOM树。
在此Blog工程中,这个APP组件在src/app,它的render
方法中定义了许多基础的视图组件,并根据当前路由传来的params
和由对应路由中components
参数传来的content
来确认主视图中应当显示哪些组件,这个主视图实际上就是你当前在看的这个文章主体的位置。
比如在本页中,url为http://dtysky.moe/article/Skill-2016_10_09_a
,相对于根域名的地址是/article/Skill-2016_10_09_a
,它会走到article/:name
这个路由中,而这个路由中有个必选参数name
(形如(/:index)
这种参数就是非必须选的),当进入这个路由后,APP组件首先会接收到参数this.props.param={name: Skill-2016_10_09_a}
和this.props.content=Article
,接下来我们只需要使用cloneElement
方法将content
组件加上需要传入的参数放入DOM树中即可:
render() {
const {params, content} = this.props;
return (
<div>
......
{cloneElement(content, {params, ......})}
......
</div>
)
}
如此,当用户访问当前的这个url后,Article这个组件将会被装入DOM树,接受params
和其他的一些参数并渲染出响应的页面。
完成了这一切后,我们将Routes作为根级组件挂载到React-redux的Provider
下便可以完成和Redux的链接:
const Root = (
<Provider store={store}>
<Router
routes={routes}
history={browserHistory}
/>
</Provider>
);
使用React-router要特别注意组件自身生命周期的问题,清晰了解路由跳转时组件的行为非常重要,请务必通读官方的这篇说明Component Lifecycle。
More
至此,React最佳实践的第一部分就差不多了,之所以说第一部分,是因为上面这些并没有真正解决SEO、即首屏渲染的问题,这会涉及到React/Redux/React-router协作的服务端渲染,下一篇文章将会详细说明如何去操作。
知识可以学习,技术只能练习,这里只能说个脉络,更多请参考我的BlogReworkPro工程。