0. 前言
Virtual DOM 是 React 对真实 DOM 的一种抽象,其本质是一个 js 对象。
React render 函数每次执行都会生成一个新 Virtual DOM Tree,diff 算法就是比较前后两次 render 函数生成的 Virtual DOM Tree 的差异性,相比于通过传统 diff 方法比较随机两棵树差异的 O(n^3) 时间复杂度,React diff 算法可以将时间复杂度降至 O(n) (其中 n 为树中的节点数)。
通过 diff 算法,React 可以找出新老 Virtual DOM Tree 的最小差异集,下一步就是要把这些变化通过最少步骤更新到真实的 DOM 节点上,React 把构造新 Virtual DOM -> 执行 diff 算法 -> 更新老 Virtual DOM -> 更新真实 DOM 这一流程称为 reconciliation 流程。
Reconciliation is the process through which React updates the DOM.
以下是 render 函数执行后的组件生命周期图:

1. React Element
在介绍 Virtual DOM 之前,先了解下 React Element,React Element 是 React 应用的最小单元。
Elements are the smallest building blocks of React apps.
React Element 分为两种:DOM Element 和 Component Element。
DOM Element:浏览器原生支持的DOM元素(比如div,p等)的抽象描述Component Element:通过React Component构建出来的元素(构建方式为函数或者ES6 class类)Components let you split the UI into independent, reusable pieces, and think about each piece in isolation.
2. Virtual DOM
以 React15 为例:
|
|
vDomObj 各属性含义如下:
| name | type | description |
|---|---|---|
| $$typeof | Symbol | 标志该对象是否是 React Element,设计初衷是为了避免 xss |
| key | string / null | Virtual DOM 的唯一标识 |
| ref | string / function / null | 真实 DOM 节点或 React 元素的索引 |
| type | string / function | Virtual DOM 元素类型(包括 DOM Element 和 Component Element) |
| props | object | Virtual DOM 元素属性,包含 children |
| _owner | object | 指明该 Virtual Dom 的父元素 |
| _store | object | 未知 |
3. diff 算法
React 官网中描述的 diff 算法基于以下两个假设:
- Two elements of different types will produce different trees.
- The developer can hint at which child elements may be stable across different renders with a key prop.
即:
- 不同类型的元素产生会产生不同的节点树。
- 不同子元素可以通过
key属性来保持稳定。
除此之外,还有一个隐藏的假设:
Web UI中的DOM节点的跨层级操作特别少,可以忽略不计。
基于假设三:分层比较
假设三是整个 diff 算法的基础,它确立了 React 对树的分层比较策略,即 diff 算法在比较新老 Virtual DOM Tree 的时候,只会比较同层级的 Virtual DOM,如下图,diff 算法只会比较相同颜色框中的节点。正是这种分层比较策略可以让 diff 在根据广度遍历一次 Virtual DOM Tree 后就完成整颗树的比较。

然而,在实际使用 React 过程中,无法完全避免节点的跨层级操作,比如下图中要将 A 节点从 B 节点下方跨层级移动到 R 节点下方,对于这种情况,diff 算法首先发现 R 节点的子节点多了一个 A 节点,因此会创建一个新的 A 节点,然后遍历 B 节点的子节点时,发现少了一个 A 节点,因此会删除原先 B 节点下方的 A 节点,整个过程即 find new R.A -> create R.A -> B.A not exists -> delete B.A。diff 算法不会把 B.A 完整的移动到 R.A 上,而是通过上述先增后删的操作来实现这种‘移动’效果。

基于假设一:减少比较次数
当对比两棵 Virtual DOM Tree 时,diff 算法会优先比较两棵树的根节点,如果它们的类型(type)不同则认为这两个根节点及其下属的子树是完全不同的,diff 算法不会再继续递归比较两棵子树,如下图所示,当 diff 算法检测到 R1.type !== R2.type,则其会把 R1 跟 R2 视为两个不同的节点,因此会删除 R1 节点及 R1.A 和 R1.B,然后再创建 R2、R2.A 和 R2.B。通过这种策略,diff 算法可以减少遍历比较次数,从而一定程度上提升算法的性能。

基于假设二:稳定的 DOM
Virtual DOM 的 key 属性用来标识该节点是否唯一,diff 算法可以根据 key 值来判断新老两个 Virtual DOM 在类型相同的情况下是否是同一个实例,如果新老 Virtual DOM 的 key 值不同,则 diff 算法会销毁老的实例,然后再创建一个新的实例。下面的代码在切换 Child 的 key 值后,会把原先的 a 实例销毁掉,再创建一个 b 实例。
|
|
key 的作用不仅仅体现在保证元素的稳定上,它还可以帮助 diff 算法快速找到 Virtual DOM 列表中的某个具体节点。
在实际应用中,经常会出现某个节点含有多个子节点的场景,对于 diff 算法来说,有效快速地比较出改变前后子节点列表的差异和更新步骤可以显著提升 React 应用的性能。
换言之,diff 算法要在 O(n) 的时间复杂度内找出将改变前子节点列表变换到改变后子节点列表的最少步骤,这个问题其实是求解最小编辑距离问题的变种,但是求解最小编辑距离的传统算法可以把时间复杂度控制在 O(n^2) 内,对于 React 来说依旧太费时,所以 React diff 算法采取了其他策略来保证时间复杂度为 O(n)。
让我们把问题抽象简化:给定改变前的子节点列表 prevList 和改变后的子节点列表 nextList,求将 prevList 变换成 nextList 所需的最少操作步骤,操作步骤可以为
INSERT_MARKUP:若nextList中某节点type不存在prevList内,则需要对该新节点执行插入操作。MOVE_EXISTING:若nextList与prevList中存在相同type和key的节点,则直接复用prevList中节点,并对其做移动操作。REMOVE_NODE:若nextList中不存在与prevList中某节点相同type和key的节点,则从prevList中删除该节点。
我们需要一个队列 updateQueue 来按照优先顺序依次存放计算过程中得到的操作,updateQueue 是一个数组,它的每个元素是一个描述某次操作的 js 对象,其属性定义如下:
| name | description |
|---|---|
| type | 操作类型,包括 INSERT_MARKUP、MOVE_EXISTING、REMOVE_NODE |
| fromIndex | 节点在 prevList 的初始位置信息 |
| toIndex | 经过移动或插入后,节点在 nextList 的位置信息 |
| markupNode | 要插入的子节点,仅对 INSERT_MARKUP 有效 |
相应的,我们为每种操作分别定义一个函数:
|
|
接下来,为了从 prevList 和 nextList 中快速查找某个节点,得把节点列表转换成一个存放 key - vnode 映射关系的对象。
|
|
通过 nodeMap 分别获取到改变前后两个列表中 key 或 index 相同的两个节点,然后再根据这两个节点是否相等来判断下一步更新操作(插入、删除、移动),为了不遗漏两个列表中的节点,需要分别对两个列表中的节点进行遍历。
1.首先遍历 nextList 中的节点并将其与 prevList 中的节点进行对比。

2.再遍历 prevList 中的节点并将其与 nextList 中的节点进行对比。

通过以上两步操作,就可以将 prevList 变换到 nextList,至此还剩下三个关键的问题需要解决,即
- 计算新增节点在
nextList中的插入位置 - 如何删除不存在的老节点
- 如何移动可复用的老节点
在解决的这三个问题之前,首先得了解几个概念
| name | description |
|---|---|
| lastIndex | prevList 中被遍历过最右节点位置(即遍历过节点的最大下标),初始值为 0 |
| nextIndex | nextList 中当前被遍历到的节点下标,初始值为 0 |
| mountIndex | 某个节点在 nextList 中的最终位置,初始值为该节点在 prevList 中的位置 |
对于插入操作,只需要把新增节点 nextNode 的 mountIndex 设置为 nextIndex,再根据 nextNode.mountIndex 把 nextNode 插入到 nextList 的对应位置即可。
|
|
对于删除操作,直接根据老节点在 prevList 中的原始位置删除即可。
|
|
移动操作稍微复杂些,涉及到 lastIndex 和节点 mountIndex 的大小比较,如果 lastIndex > prevNode.mountIndex,说明当前遍历到的 prevNode 在 prevList 中就比上一个 prevNode 节点靠前,因此需要将当前遍历到的 prevNode 移动到 nextIndex 位置,反之则不用移动。
|
|
完整的计算更新步骤的代码如下:
|
|
以上代码体现了 React diff 在处理 list diff 时候的基本逻辑,在 React 源码中其实现远比这个复杂,这种处理策略可以保证 diff 算法在处理 list diff 时候可以用线性时间算出更新步骤,可以结合以下例子加深理解:
|
|
假设节点列表变化前后节点排列顺序如上所示,根据 list diff 策略处理如下:
0.初始化时,updateQueue 为空数组,lastIndex、nextIndex 以及各节点的 mountIndex 如下:
| lastIndex | nextIndex | A | B | C | D | |
|---|---|---|---|---|---|---|
| 初始化 | 0 | 0 | 0 | 1 | 2 | null |
1.遍历 nextList.B,发现 prevList.B 也存在,比较 prevList.B.mountIndex 与 lastIndex,发现前者更大,故不需要移动 prevList.B,此时 prevList 中访问的节点最右位置变为 prevList.B.mountIndex,而 B 的最终位置则为 nextList 中当前访问的节点位置,即 nextIndex(prevList.B.mountIndex = nextIndex),然后切换到下一个 nextList 节点。
| lastIndex | nextIndex | A | B | C | D | |
|---|---|---|---|---|---|---|
| 初始化 | 0 | 0 | 0 | 1 | 2 | null |
遍历 nextList.B |
1 | 1 | 0 | 0 | 2 | null |
2.遍历 nextList.A,发现 prevList.A 也存在,比较 prevList.A.mountIndex 与 lastIndex,发现后者更大,故需要移动 prevList.A,将 prevList.A 移动到 nextIndex 的位置,而保持 lastIndex 不变。updateQueue 变为:
|
|
| lastIndex | nextIndex | A | B | C | D | |
|---|---|---|---|---|---|---|
| 初始化 | 0 | 0 | 0 | 1 | 2 | null |
遍历 nextList.B |
1 | 1 | 0 | 0 | 2 | null |
遍历 nextList.A |
1 | 2 | 1 | 0 | 2 | null |
3.遍历 nextList.D,发现 prevList.D 不存在,故直接将其插入到 nextIndex 的位置,updateQueue 变为:
|
|
| lastIndex | nextIndex | A | B | C | D | |
|---|---|---|---|---|---|---|
| 初始化 | 0 | 0 | 0 | 1 | 2 | null |
遍历 nextList.B |
1 | 1 | 0 | 0 | 2 | null |
遍历 nextList.A |
1 | 2 | 1 | 0 | 2 | null |
遍历 nextList.D |
1 | 3 | 1 | 0 | 2 | 2 |
4.遍历 prevList.A,发现 nextList.A 存在,跳过。
| lastIndex | nextIndex | A | B | C | D | |
|---|---|---|---|---|---|---|
| 初始化 | 0 | 0 | 0 | 1 | 2 | null |
遍历 nextList.B |
1 | 1 | 0 | 0 | 2 | null |
遍历 nextList.A |
1 | 2 | 1 | 0 | 2 | null |
遍历 nextList.D |
1 | 3 | 1 | 0 | 2 | 2 |
遍历 prevList.A |
1 | 3 | 1 | 0 | 2 | 2 |
5.遍历 prevList.B,发现 nextList.B 存在,跳过。
| lastIndex | nextIndex | A | B | C | D | |
|---|---|---|---|---|---|---|
| 初始化 | 0 | 0 | 0 | 1 | 2 | null |
遍历 nextList.B |
1 | 1 | 0 | 0 | 2 | null |
遍历 nextList.A |
1 | 2 | 1 | 0 | 2 | null |
遍历 nextList.D |
1 | 3 | 1 | 0 | 2 | 2 |
遍历 prevList.A |
1 | 3 | 1 | 0 | 2 | 2 |
遍历 prevList.B |
1 | 3 | 1 | 0 | 2 | 2 |
6.遍历 prevList.C,发现 nextList.C 不存在,故需要删除该节点,updateQueue 变为:
|
|
| lastIndex | nextIndex | A | B | C | D | |
|---|---|---|---|---|---|---|
| 初始化 | 0 | 0 | 0 | 1 | 2 | null |
遍历 nextList.B |
1 | 1 | 0 | 0 | 2 | null |
遍历 nextList.A |
1 | 2 | 1 | 0 | 2 | null |
遍历 nextList.D |
1 | 3 | 1 | 0 | 2 | 2 |
遍历 prevList.A |
1 | 3 | 1 | 0 | 2 | 2 |
遍历 prevList.B |
1 | 3 | 1 | 0 | 2 | 2 |
遍历 prevList.C |
1 | 3 | 1 | 0 | null | 2 |
至此,就可以算出 list diff 后节点列表的更新步骤。
4. 为什么不将变化直接更新到真实 DOM 节点上?
通过 reconciliation 将节点变化部分更新到真实节点的意义在于:
- 通过
diff算法对比render前后的Virtual DOM可以获取需要改变的Virtual DOM最小集合以及将这些变化部分更新到真实节点的最少步骤。 - 在更新真实节点的过程中,
React会根据diff获取到的更新步骤,在一次事件循环中执行完所有步骤并保证在此次事件循环中只执行一次真实节点的重绘和重排操作。
以上两点可以确保 React 在更新真实 DOM 过程中,避免无用重复的重排和重绘操作,从而降低更新操作带来的性能影响。
参考文献: