目前工作中用到了React,搭配一起使用了Immutable.js。之前没有静下来思考一下为什么React社区这么推崇搭配一起使用Immutable。正好想写篇文章分析一下这个问题。之前我翻译了React官方文档中Advanced Guides中关于一致化处理(Reconciliation)和性能优化(Optimizing Performance)就涉及到这方面的内容。
React性能优化
一提到React,大家第一时间就想到的虚拟DOM(Virtual DOM)和伴随其带来的高性能。但是React提供的是声明式的API(declarative API),好的一方面是让我们编写程序更加方便,但另一方面,却使得我们不太了解内部细节。
一致化处理(Reconciliation)
React采用的是虚拟DOM,每次属性(props)和状态(state)发生变化的时候,render
函数返回不同的元素树,React会检测当前返回的元素树和上次渲染的元素树之前的差异,然后找出何如高效的更新UI。
上图展示的就是一个元素树,React比较两次元素树差异的时候,首先从根节点开始。如果元素类型不相同时,该节点以下(包括当前节点)的元素树都会被销毁,树的根节点以下的任何组件都会被卸载,因此状态(state)也会丢失。如果元素节点类型是相同的,那就需要区分是DOM元素还是组件。如果是DOM元素,会保持节点相同,仅更新改变的属性。而如果是组件的话,会保持组件实例不变,仅更新组件实例的属性,因此组件实例的状态(state)就会被保留下来。比较完当前节点,然后会递归遍历比较子元素。
一致化处理(Reconciliation)包括的就是React元素的比较以及对应的React元素不同时对DOM的更新,即可理解为React 内部将虚拟 DOM 同步更新到真实 DOM 的过程,包括新旧虚拟 DOM 的比较及计算最小 DOM 操作。我们可以看到促使React性能提升的一个重要点就是避免一致化处理。
shouldComponentUpdate
React使用shouldComponentUpdate
来判别组件是否会因为当前属性(props)和状态(state)变化而导致组件输出变化。默认的shouldComponentUpdate
会在props和state发生变化时返回true
,表示组件会重新渲染,从而调用render
函数。当然了在首次渲染的时候和使用forceUpdate
的时候,是不会经过shouldComponentUpdate
判断。shouldComponentUpdate
作为性能优化的一个非常有用且简单的方法,非常实用。
举例
我们以React官网中的图作为实例:
SCU代表shouldComponentUpdate,红色SCU表示shouldComponentUpdate返回true
,绿色的SCU表示shouldComponentUpdate返回false
。vDOMEq代表渲染的React元素是否相等。红色vDOMEq表示React元素不相等,绿色的vDOMEq表示React元素相等。元素节点为红色表示需要对该节点进行一致化处理,节点颜色为绿色表示不需要对其进行一致化处理。
表示对于两棵元素树,React会同步比较。在比较C1节点,因为SCU返回的false,需要对其进行diff,vDOMEq返回的是false,故需要一致化处理,存在DOM元素的更新。迭代递归到C2,因为SCU返回的是true,以C2为根节点的整个子树,都不需要diff判断。但是C3的SCU返回true,需要进行diff比较。C3的子节点C6因为SCU返回true需要进行diff比较,并且因为vDOMEq返回的false,因此C6不可避免进行DOM的更新。对于C8来讲,通过比较渲染元素而不需要进行一致化处理,而C7因为shouldComponentUpdate返回false从而不需要进行diff。
因此我们可以发现,如果能够合理地编写shouldComponentUpdate
函数,从而能避免不必要的一致化处理,使得性能可以极大提高。一般shouldComponentUpdate
会比较props
和state
中的属性是否发生改变(浅比较)来判定是否shouldComponentUpdate
是否需要返回true
从而触发一致化处理。我们可以通过继承React.PureComponent
或者通过引入PureRenderMixin模块来达到目的。但是这也存在一个问题:
class ListOfWords extends React.PureComponent { render() { return <div>{this.props.words.join(',')}</div>; } } class WordAdder extends React.Component { constructor(props) { super(props); this.state = { words: ['marklar'] }; this.handleClick = this.handleClick.bind(this); } handleClick() { // This section is bad style and causes a bug const words = this.state.words; words.push('marklar'); this.setState({words: words}); } render() { return ( <div> <button onClick={this.handleClick} /> <ListOfWords words={this.state.words} /> </div> ); } }
组件展现一个以逗号分隔的单词列表,在父组件WordAdder
,当你点击一个按钮时会给列表添加一个单词,但实际上,上面的代码是存在问题的,ListOfWords
继承的React.PureComponent
,当你每次点击按钮会给WordAdder
组件中的this.state.words
添加新的单词。因此ListOfWords
中的shouldComponentUpdate
在判断this.props.words
和nextProps.words
实际是相等的,因此返回了false
。所以,ListOfWords
是不会被重新渲染的,因为React.PureComponent
中的shouldComponentUpdate
进行的是浅比较(shallow comparison),但是如果真的进行深比较,那么比较的性能损耗又太大,不禁让我们得出一个结论:
共享的可变状态是万恶之源
这时候Immutable.js横空出世
Immutable Data
Immutable Data是指一旦创建,就不能被更改的数据。对Immutable对象的修改都会返回新的Immutable对象。并且目前的Immutable库,都实现了结构共享,即如果对象树中一个节点发生变化,只修改这个节点和受它影响的父节点,其它节点则进行共享,避免了deepCopy把所有节点都复制一遍带来的性能损耗。比较两个Immutable对象是否相同,只需要使用===
就可以轻松判别。因此如果React传入的数据是Immutable Data,那么React就能高效地比较前后属性的变化,从而决定shouldComponentUpdate
的返回值。解决了上面存在的问题。 但是引入Immutable Data也不是没有代价的,毕竟Immutable Data需要引入新的API,并且需要引入新的库,在原有的项目中引入Immutable Data也是有风险和代价的而且还需要开发者转变原有的思维(毕竟天下没有白吃的午餐)。