Introduction to Assumptions Made in React Reconciliation

Friday, September 16, 2016

每当 state 发生变化,ReactJS 就会重新渲染新的 Virtual DOM,接着执行 Diff 算法与旧的 Virtual DOM 比较,计算出差异,最后执行 DOM 操作。ReactJS 基于以下两个假设优化了标准实现的 Diff 算法:

  • 相同的组件产生类似的 DOM 结构,不同的组件产生不同的 DOM 结构
  • 每个元素都有可能由唯一的 key 所标识

这两个假设作为高性能 Diff 算法与渲染机制的基础,不仅能够指导我们编写高性能的 render 函数,也有助于我们进一步优化渲染,从而提升界面的构建性能。

相同的组件产生类似的 DOM 结构,不同的组件产生不同的 DOM 结构

基于第一个假设,主要影响了不同结点的之间比较。这种比较又分成两种情况:结点类型不同和结点属性不同。

对于不同类型的结点,ReactJS 先删除旧结点而后插入新结点,对于不同组件,操作规则同样适用,因为不同组件产生不同 DOM 结构。当对比相同类型结点时,例如遇到新、旧 Virtual DOM 同一位置的结点相同时,ReactJS 会进一步比较不同的 DOM 结点属性,对于不同的属性结点会执行相应的新增、替换、删除等操作(style 属性之所以为对象而非字符串是为了计算差异而后执行所需的操作)。

不过 ReactJS 的 Diff 算法实现非常简单,只进行同层次的比较,也就是同一父结点下的所有子节点的比较。例如把 A 结点的子节点 C 移动到 B 结点,然后生成以下两棵 Virtual DOM 树,ReactJS 分析出差异之后 A --> null, B --> C,从 A 结点把 C 结点删除,然后创建新的 C 结点并把 C 结点添加到 B 结点,而非简单地把 C 结点移动到 B 结点。

    +---+            +---+          destroy C
    | R |            | R |          create C
    +-+-+            +-+-+          append C to B
      |                |
  +---+---+        +---+---+
  |       |        |       |        [lifecycle]
+-+-+   +-+-+    +-+-+   +-+-+      
| A |   | B |    | A |   | B |      C will unmount (componentWillUnmout)
+-+-+   +---+    +---+   +-+-+      C is created   (constructor)
  |                        |        A is updated   (componentDidUpdate)
+-+-+                    +-+-+      C did mount    (componentDidMount)
| C |                    | C |      B is updated   (componentDidUpdate)
+---+                    +---+      R is updated   (componentDidUpdate)

所以 ReactJS 在进行逐层比较的时候,处理不同层次的结点时只会简单地删除和添加,而对同一层次的结点则会有条件地考虑位置变化。第二种假设影响的便是对同一层次的结点的处理。

每个元素都有可能由唯一的 key 所标识

ReactJS 在处理列表结点时,默认情况下会认为每个元素都有唯一的 key 进行标识,如果没有提供就会提示这样的警告信息:Each child in an array should have a unique "key" prop,不过这个 key 属性是可选的,界面基本也能正常渲染,只是意味着潜在的性能问题。

具体来说,向一组结点 (A, B, C, D, E, F) 的第二个位置插入一个结点 N,变成 (A, N, B, C, D, E, F)

如果没有提供唯一的 key 属性,那么 ReactJS 会认为 F 元素是一个新的元素,所以会首先创建 F 元素,然后对第二个结点开始的所有结点 (B, C, D, E, F) 执行更新操作:B --> NC --> BD --> CE --> DF --> E,最后把 F 插入到列表中。虽然结果与预期一致,但是其中涉及了不必要的 DOM 操作,从而造成了一定的性能问题。

如果提供 key 属性,那么 ReactJS 会通过内建 Hash Table 高效地计算出最小化 DOM 操作,从而提高渲染效率,所以上述插入新结点的例子所需的 DOM 操作只需把结点 N 插入到结点 A 和结点 B 之间。

+---+  +---+  +---+  +---+  +---+  +---+            F is created (constructor)
| A |  | B |  | C |  | D |  | E |  | F |            A is updated (componentDidUpdate)
+---+  +-+-+  +-+-+  +-+-+  +-+-+  +-+-+            B is updated (componentDidUpdate)
         |      |      |      |      |              C is updated (componentDidUpdate)
         |      |      |      |      |              D is updated (componentDidUpdate)
       +-v-+  +-v-+  +-v-+  +-v-+  +-v-+  +---+     E is updated (componentDidUpdate)
       | N |  | B |  | C |  | D |  | E |  | F |     F is updated (componentDidUpdate)
       +---+  +---+  +---+  +---+  +---+  +---+     F is mounted (componentDidMount)

----------------- KEY PROVIDED ----------------

+---+         +---+  +---+  +---+  +---+  +---+     N is created (contructor)
| A |         | B |  | C |  | D |  | E |  | F |     N is mounted (componentDidMount)
+---+         +---+  +---+  +---+  +---+  +---+     A is updated (componentDidUpdate)
                                                    B is updated (componentDidUpdate)
                                                    C is updated (componentDidUpdate)
       +---+                                        D is updated (componentDidUpdate)
       | N |                                        E is updated (componentDidUpdate)
       +---+                                        F is updated (componentDidUpdate)

因此为列表的每个结点提供唯一的 key 有助于 ReactJS 执行高效的操作,另外这个 key 无需全局唯一,只需保证当前列表内唯一即可。

总结

由于 Diff 算法依赖于这两个假设,所以目前官方给出了三点需要注意的地方:

  • Diff 算法不会去对比不同组件之间最终产生的 DOM 结构,如果两个组件之间拥有相似的 DOM 结构,则应该把它们写成同一个组件
  • key 属性只在当前层次的子树中相邻结点之间有效,如果把这颗子树移动到其他结点,那么 key 属性将不起作用,ReactJS 依然会渲染整棵子树
  • key 属性应当稳定、可预测而且唯一,不稳定的 key 属性会产生不必要的创建操作,造成性能退化甚至丢失组件的内部 state

另外,由于假设了相同的组件产生类似的 DOM 结构,所以我们应该保持组件的 DOM 结构的稳定性,例如在隐藏/县显示某些结点时,最好通过 CSS 进行隐藏/显示而非移除/添加 DOM 结点。

// recommended
<Component style={{ display: this.state.isShown ? 'block' : 'none' }} />
// bad
{ this.state.isShown ? <Component /> : null }

参考资料