为什么React和Immutable是好朋友

3,374 阅读5分钟

  目前工作中用到了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-tree

  上图展示的就是一个元素树,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官网中的图作为实例:

diff.png

  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会比较propsstate中的属性是否发生改变(浅比较)来判定是否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.wordsnextProps.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也是有风险和代价的而且还需要开发者转变原有的思维(毕竟天下没有白吃的午餐)。