一文讲通 React 的 diff 过程
看完这篇文章,我们可以弄明白下面这几个问题:
- 传统
diff
算法的瓶颈是什么? React
的diff
算法是怎么实现的?React
的diff
算法发生在哪个阶段?React
的元素的key
是做什么的?React
是怎么通过key
实现高效diff
的?
Fiber 节点的构建
下面的伪代码展示了 fiber
构建的过程:
1 | function workLoop(deadline) { |
- 不熟悉
requestIdleCallback
可以点这里查看,这个方法很简单:它需要传入一个callback
,浏览器会在空闲时去调用这个callback
,然后给这个callback
传入一个IdleDeadline
,IdleDeadline
会预估一个剩余闲置时间,我们可以通过还剩多少闲置时间去判断,是否足够去执行下一个单元任务。 performUnitOfWork
方法将传入的节点
创建为Fiber
,然后返回下一个待构建的节点
并赋值给nextUnitOfWork
,同时还会将刚创建的fiber
与已创建的fiber
连接起来构成Fiber
树。
performUnitOfWork
的工作可以分为两部分:“递
”和“归
”。
render 阶段
render 阶段的开始,首先从 rootFiber
开始向下深度优先
遍历,也就是不断 while
循环执行 performUnitOfWork
,会经历递
和归
两个阶段。
“递”阶段
- 向下遍历,每个遍历到的
Fiber
节点会调用beginWork
方法。 - 该方法会根据传入的
Fiber
节点创建子Fiber
节点,并将这两个Fiber
节点连接
起来。 - 当遍历到没有
child
的节点时就会进入“归”阶段。
“归”阶段
- 在“归”阶段会调用
completeWork
处理Fiber
节点。 - 当某个
Fiber
节点执行完completeWork
,如果其存在兄弟Fiber
节点,会进入其兄弟Fiber
的“递”阶段。 - 如果不存在
兄弟Fiber
,会进入父Fiber
的“归”阶段。
递和归举例
1 | function App() { |
对应的 Fiber 树结构:
render 阶段会依次执行:
1 | 1. rootFiber beginWork |
注意:之所以没有“KaSong”
Fiber 的 beginWork/completeWork
,是因为作为一种性能优化手段,针对只有单一文本子节点的 Fiber
,React
会特殊处理。
render 完成
“递”和“归”阶段会交错执行直到“归”到 rootFiber
。至此,render
阶段的工作就结束了。
Diff
diff 算法
发生在两个阶段,分别是 beginWork
和 completeWork
阶段。
Diff 的瓶颈以及 React 如何应对
由于 Diff
操作本身也会带来性能损耗,React
文档中提到,即使在最前沿的算法中,将前后两棵树完全比对的算法的复杂程度为 O(n 3 ),其中 n 是树中元素的数量。
如果在 React 中使用了该算法,那么展示 1000 个元素所需要执行的计算量将在十亿的量级范围。这个开销实在是太过高昂。
为了降低算法复杂度,React 的 diff 会预设三个限制:
- 只进行
同层比较
。 - 新、旧节点的
type
不同,直接删除
旧节点,创建
新节点。如:元素由div
变为p
,React会销毁
div及其子孙节点,并新建
p` 及其子孙节点。 - 通过
key
来复用
节点。
所以 react
中 diff
算法主要遵循上面的三个层级的策略:
tree
层级conponent
层级element
层级
tree 层级
DOM
节点跨层级的操作不做优化,只会对相同层级的节点进行比较
只有删除、创建操作,没有移动操作,如下图:
react
发现新树中,R
节点下没有了 A
,那么直接删除 A
,在 D
节点下创建 A
以及下属节点,上述操作中,只有删除和创建操作
component 层级
如果是同一个类的组件,则会继续往下 diff
运算,如果不是一个类的组件,那么直接删除这个组件下的所有子节点,创建新的
当 component D 换成了 component G 后,即使两者的结构非常类似,也会将 D 删除再重新创建 G
element 层级
对于比较同一层级的节点们,每个节点在对应的层级用唯一的 key
作为标识
提供了 3 种节点操作,分别为 INSERT_MARKUP
(插入)、MOVE_EXISTING
(移动) 和 REMOVE_NODE
(删除)
如下场景:
通过 key
可以准确地发现新旧集合中的节点都是相同的节点,因此无需进行节点删除和创建,只需要将旧集合中节点的位置进行移动,更新为新集合中节点的位置
Diff 是如何实现的
我们从 Diff
的入口函数 reconcileChildFibers
出发,该函数会根据 newChild
(即 JSX 对象)类型调用不同的处理函数。
1 | // 根据 newChild 类型选择不同 diff 函数处理 |
我们可以从同级的节点数量将 Diff
分为两类:
- 当
newChild
类型为object
、number
、string
,代表同级只有一个节点 - 当
newChild
类型为Array
,同级有多个节点
在接下来两节我们会分别讨论这两类节点的 Diff
单节点 diff
对于单个节点,我们以类型 object
为例,会进入 reconcileSingleElement
1 | const isObject = typeof newChild === 'object' && newChild !== null; |
React 通过先判断 key 是否相同,如果 key 相同则判断 type 是否相同,只有都相同时一个 DOM 节点才能复用。
这里有个细节需要关注下:
当
key 相同
且type 不同
时,执行deleteRemainingChildren
将child
及其兄弟 fiber
都标记删除。当
key 不同
时仅将child
标记删除 (后面还有兄弟 fiber
还没有遍历到。所以仅仅标记该fiber
删除。)
考虑如下例子:当前页面有 3 个 li,我们要全部删除,再插入一个 p。
1 | // 当前页面显示的 |
- 由于检测
新节点
只有一个p
,属于单节点
比较。 - 在
reconcileSingleElement
中遍历之前的 3 个fiber(对应的
DOM
为 3 个li
),寻找本次更新的p
是否可以复用之前的 3 个fiber
中某个的DOM
。 - 当
key
相同且type
不同时,代表我们已经找到本次更新的 p 对应的上次的fiber
,但是 p 与 litype
不同,不能复用。既然唯一的可能性已经不能复用,则剩下的fiber
都没有机会了,所以都需要标记删除。 - 当
key
不同时只代表遍历到的该fiber
不能被p
复用,后面还有兄弟fiber
还没有遍历到。所以仅仅标记该fiber
删除
多节点 diff
这种情况下,reconcileChildFibers
的 newChild
参数类型为 Array
,在 reconcileChildFibers
函数内部对应如下情况:
1 | if (isArray(newChild)) { |
然后归纳要处理的情况:
- 节点
更新
- 节点
新增
或减少
- 节点
位置变化
同级多个节点的 Diff
,一定属于以上三种情况中的一种或多种。
diff 思路
该如何设计算法呢?如果让我设计一个 Diff 算法,我首先想到的方案是:
- 判断当前节点的更新属于哪种情况
- 如果是新增,执行新增逻辑
- 如果是删除,执行删除逻辑
- 如果是更新,执行更新逻辑
按这个方案,其实有个隐含的前提——不同操作的优先级是相同的
但是
React
团队发现,在日常开发中,相较于新增
和删除
,更新组件发生的频率
更高。所以 Diff 会优先判断当前节点是否属于更新
。
为什么不能用双指针遍历
在我们做数组相关的算法题时,经常使用双指针从数组头和尾同时遍历以提高效率,但是这里却不行。
虽然 newChildren
为数组
形式,但是老节点是 fiber
链表,同级的 Fiber
节点是由 sibling
指针链接形成的单链表,即不支持双指针遍历。即 newChildren[0]与fiber比较,newChildren[1]与fiber.sibling比较
。
所以无法使用双指针优化,基于以上原因,Diff 算法的整体逻辑会经历两轮遍历:
- 第一轮遍历:处理更新的节点。
- 第二轮遍历:处理剩下的不属于更新的节点。
第一轮遍历
第一轮遍历步骤如下:
let i = 0
,遍历newChildren
,将newChildren[i]
与oldFiber
比较,判断 DOM 节点是否可复用。- 如果可复用,
i++
,继续比较newChildren[i]
与oldFiber.sibling
,可以复用则继续遍历。 - 如果不可复用,分两种情况:
key 不同
导致不可复用,立即跳出整个遍历,第一轮遍历结束。key 相同
而type 不同
导致不可复用,会将oldFiber
标记为DELETION
,并继续遍历
- 如果
newChildren
遍历完(即i === newChildren.length - 1
)或者oldFiber
遍历完(即oldFiber.sibling === null
),跳出遍历,第一轮遍历结束。
当遍历结束后,会有两种结果:
步骤 3 跳出的遍历
此时 newChildren 没有遍历完,oldFiber 也没有遍历完。
举个例子,考虑如下代码:
1 | // 之前 |
第一个节点可复用,遍历到 key === 2
的节点发现 key
改变,不可复用,跳出遍历,等待第二轮遍历处理。
此时 oldFiber
剩下 key === 1
、key === 2
未遍历,newChildren
剩下 key === 2
、key === 1
未遍历。
步骤 4 跳出的遍历
可能 newChildren
遍历完,或 oldFiber
遍历完,或他们同时遍历完。
举个例子,考虑如下代码:
1 | // 之前 |
带着第一轮遍历的结果,我们开始第二轮遍历。
第二轮遍历
对于第一轮遍历的结果,我们分别讨论:
newChildren
与oldFiber
同时遍历完
那就是最理想的情况:只需在第一轮遍历进行组件更新。此时 Diff
结束。
newChildren
没遍历完,oldFiber
遍历完
已有的 DOM
节点都复用了,这时还有新加入的节点,意味着本次更新有新节点插入,我们只需要遍历剩下的 newChildren
为生成的 fiber
依次标记 Placement。
newChildren
遍历完,oldFiber
没遍历完
意味着本次更新比之前的节点数量少,有节点被删除了。所以需要遍历剩下的 oldFiber
,依次标记 Deletion
。
newChildren
与oldFiber
都没遍历完
这意味着有节点在这次更新中改变了位置。
这是 Diff 算法最精髓也是最难懂的部分。我们接下来会重点讲解。
处理移动的节点
移动的思想:如果当前节点在新集合中的位置比老集合中的位置靠前的话,是不会影响后续节点操作的,这时候不用动
操作过程中只比较 oldIndex
和 maxIndex
,规则如下:
- 当
oldIndex>maxIndex
时,将oldIndex
的值赋值给maxIndex
- 当
oldIndex=maxIndex
时,不操作 - 当
oldIndex<maxIndex
时,将当前节点移动到index
的位置
即:遍历新节点,每个新节点有 2 个 index,一个是在旧节点的位置,另一个是遇到的最大旧节点的位置,然后根据上面判断决定旧节点是否右移。
还是举上面的例子
diff 过程如下:
- 节点 B:此时 maxIndex=0,oldIndex=1;满足 maxIndex< oldIndex,因此 B 节点不动,此时 maxIndex= Math.max(oldIndex, maxIndex),就是 1
- 节点 A:此时 maxIndex=1,oldIndex=0;不满足 maxIndex< oldIndex,因此 A 节点进行移动操作,此时 maxIndex= Math.max(oldIndex, maxIndex),还是 1
- 节点 D:此时 maxIndex=1, oldIndex=3;满足 maxIndex< oldIndex,因此 D 节点不动,此时 maxIndex= Math.max(oldIndex, maxIndex),就是 3
- 节点 C:此时 maxIndex=3,oldIndex=2;不满足 maxIndex< oldIndex,因此 C 节点进行移动操作,当前已经比较完了
当 ABCD 节点比较完成后,diff 过程还没完,还会整体遍历老集合中节点,看有没有没用到的节点,有的话,就删除
总结
- 传统 diff 算法通过
循环递归
对节点进行依次对比,复杂度过高,为了降低算法复杂度,diff 遵循了 3 个层级的优化策略:- 只进行同层比较。
- 新、旧节点的 type 不同,直接删除旧节点,创建新节点。
- 通过 key 来复用节点。
- 从
Diff
的入口函数reconcileChildFibers
出发,判断子元素的类型,若不是数组进入单节点diff
,否则进入多节点diff
单节点diff
:先判断key
是否相同,- 如果
key
相同,再看type
type
相同,复用;type
不同,全部删掉(包括它和它的兄弟元素,因为既然 key 一样,唯一的可能性都不能复用,则剩下的 fiber 都没有机会了);
- 如果
key
不同,只删除该child
,再找到它的兄弟节点(child.silbing
)的key
,直到找到key
相同的节点,再同上操作
- 如果
多节点diff
:归纳只有 3 种情况,更新节点、增减节点、位置变化,由于是单向的链表(newChildren[0]
与fiber
比较,newChildren[1]
与fiber.sibling
比较),所以不能像 vue 一样用双指针遍历,所以这里逻辑要经过两轮遍历:- 第一轮遍历:比较 key,
- 可复用,继续遍历 (新节点
i++
,老节点child.silbing
), - 不可复用,就停止第一轮遍历,进入第二轮遍历,有 2 种不可复用的情况:
- key 不同,直接停止
- key 相同,type 不同,标记删除,停止
- 可复用,继续遍历 (新节点
- 第二轮遍历:
- 老节点遍历完了,新节点还有,则将剩下的新节点
插入
- 新节点遍历完了,老节点还有,则将剩下的老节点
删除
- 新老节点都还有,则
移动顺序
,这是 diff 算法最精髓也是最难懂的部分,规则:遍历新节点,每个新节点有 2 个 index,一个 index 表示它在旧节点的位置,另一个 index 表示遍历中遇到的最大旧节点的位置,用oldIndex
和maxIndex
表示- 当
oldIndex>maxIndex
时,将oldIndex
的值赋值给maxIndex
- 当
oldIndex=maxIndex
时,不操作 - 当
oldIndex<maxIndex
时,将当前节点移动到index
的位置
- 当
- 老节点遍历完了,新节点还有,则将剩下的新节点
- 第一轮遍历:比较 key,
参考: