Diff算法
一、设计动机
在某一时间节点调用 React 的 render()
方法,会创建一棵由 React 元素组成的树。在下一次 state 或 props 更新时,相同的 render()
方法会返回一棵不同的树。React 需要基于这两棵树之间的差别来判断如何高效的更新 UI,以保证当前 UI 与最新的树保持同步。
此算法有一些通用的解决方案,即生成将一棵树转换成另一棵树的最小操作次数。然而,即使使用最优的算法,该算法的复杂程度仍为 O(n 3 ),其中 n 是树中元素的数量。
如果在 React 中使用该算法,那么展示 1000 个元素则需要 10 亿次的比较。这个开销实在是太过高昂。于是 React 在以下两个假设的基础之上提出了一套 O(n) 的启发式算法:
- 两个不同类型的元素会产生出不同的树;
- 开发者可以通过设置
key
属性,来告知渲染哪些子元素在不同的渲染下可以保存不变;
在实践中,我们发现以上假设在几乎所有实用的场景下都成立。
为了降低算法复杂度,React
的diff
会预设三个限制:
-
只对同级元素进行
Diff
。如果一个DOM节点
在前后两次更新中跨越了层级,那么React
不会尝试复用他。 -
两个不同类型的元素会产生出不同的树。如果元素由
div
变为p
,React会销毁div
及其子孙节点,并新建p
及其子孙节点。 -
开发者可以通过
key prop
来暗示哪些子元素在不同的渲染下能保持稳定。// 更新前 <div> <p key="a">A</p> <h3 key="b">B</h3> </div> // 更新后 <div> <h3 key="b">B</h3> <p key="a">A</p> </div>
如果没有
key
,React
会认为div
的第一个子节点由p
变为h3
,第二个子节点由h3
变为p
。这符合限制2的设定,会销毁并新建。但是当我们用
key
指明了节点前后对应关系后,React
知道key === "a"
的p
在更新后还存在,所以DOM节点
可以复用,只是需要交换下顺序。这就是
React
为了应对算法性能瓶颈做出的三条限制。
二、Diff算法
为了防止概念混淆,这里再强调下
一个
DOM节点
在某一时刻最多会有4个节点和他相关。
current Fiber
。如果该DOM节点
已在页面中,current Fiber
代表该DOM节点
对应的Fiber节点
。workInProgress Fiber
。如果该DOM节点
将在本次更新中渲染到页面中,workInProgress Fiber
代表该DOM节点
对应的Fiber节点
。DOM节点
本身。JSX对象
。即ClassComponent
的render
方法的返回结果,或FunctionComponent
的调用结果。JSX对象
中包含描述DOM节点
的信息。
Diff算法
的本质是对比1和4,生成2。
我们从Diff
的入口函数reconcileChildFibers
出发,该函数会根据newChild
(即JSX对象
)类型调用不同的处理函数。
// 根据newChild类型选择不同diff函数处理
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any,
): Fiber | null {
const isObject = typeof newChild === 'object' && newChild !== null;
if (isObject) {
// object类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
// 调用 reconcileSingleElement 处理
// // ...省略其他case
}
}
if (typeof newChild === 'string' || typeof newChild === 'number') {
// 调用 reconcileSingleTextNode 处理
// ...省略
}
if (isArray(newChild)) {
// 调用 reconcileChildrenArray 处理
// ...省略
}
// 一些其他情况调用处理函数
// ...省略
// 以上都没有命中,删除节点
return deleteRemainingChildren(returnFiber, currentFirstChild);
}
我们可以从同级的节点数量将Diff分为两类:
- 当
newChild
类型为object
、number
、string
,代表同级只有一个节点 - 当
newChild
类型为Array
,同级有多个节点
1.单节点diff
对于单个节点,我们以类型object
为例,会进入reconcileSingleElement
const isObject = typeof newChild === 'object' && newChild !== null;
if (isObject) {
// 对象类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
// 调用 reconcileSingleElement 处理
// ...其他case
}
}
这个函数会做如下的事情
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement
): Fiber {
const key = element.key;
let child = currentFirstChild;
// 首先判断是否存在对应DOM节点
while (child !== null) {
// 上一次更新存在DOM节点,接下来判断是否可复用
// 首先比较key是否相同
if (child.key === key) {
// key相同,接下来比较type是否相同
switch (child.tag) {
// ...省略case
default: {
if (child.elementType === element.type) {
// type相同则表示可以复用
// 返回复用的fiber
return existing;
}
// type不同则跳出switch
break;
}
}
// 代码执行到这里代表:key相同但是type不同
// 将该fiber及其兄弟fiber标记为删除
deleteRemainingChildren(returnFiber, child);
break;
} else {
// key不同,将该fiber标记为删除
deleteChild(returnFiber, child);
}
child = child.sibling;
}
// 创建新Fiber,并返回 ...省略
}
从代码可以看出,React通过先判断key
是否相同,如果key
相同则判断type
是否相同,只有都相同时一个DOM节点
才能复用。
这里有个细节需要关注下:
- 当
child !== null
且key相同
且type不同
时执行deleteRemainingChildren
将child
及其兄弟fiber
都标记删除。 - 当
child !== null
且key不同
时仅将child
标记删除。
考虑如下例子:
当前页面有3个li
,我们要全部删除,再插入一个p
。
// 当前页面显示的
ul > li * 3
// 这次需要更新的
ul > p
由于本次更新时只有一个p
,属于单一节点的Diff
,会走上面介绍的代码逻辑。
在reconcileSingleElement
中遍历之前的3个fiber
(对应的DOM
为3个li
),寻找本次更新的p
是否可以复用之前的3个fiber
中某个的DOM
。
当key相同
且type不同
时,代表我们已经找到本次更新的p
对应的上次的fiber
,但是p
与li
type
不同,不能复用。既然唯一的可能性已经不能复用,则剩下的fiber
都没有机会了,所以都需要标记删除。
当key不同
时只代表遍历到的该fiber
不能被p
复用,后面还有兄弟fiber
还没有遍历到。所以仅仅标记该fiber
删除。
由此我们可以想到为什么react在同一层次下一定要保持key值的唯一性
2.多节点diff
在日常开发中,相较于新增
和删除
,更新
组件发生的频率更高。所以Diff
会优先判断当前节点是否属于更新
。
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
),跳出遍历,第一轮遍历结束。
从这里我们可以看出保持同一层次下key值的稳定性是有多重要
第二轮遍历
第一轮遍历完我们有以下四种情况
-
newChildren
与oldFiber
同时遍历完那就是最理想的情况:只需在第一轮遍历进行组件
更新
。此时Diff
结束 -
newChildren
没遍历完,oldFiber
遍历完已有的
DOM节点
都复用了,这时还有新加入的节点,意味着本次更新有新节点插入,我们只需要遍历剩下的newChildren
为生成的workInProgress fiber
依次标记Placement
。 -
newChildren
遍历完,oldFiber
没遍历完意味着本次更新比之前的节点数量少,有节点被删除了。所以需要遍历剩下的
oldFiber
,依次标记Deletion
。 -
newChildren
与oldFiber
都没遍历完这意味着有节点在这次更新中改变了位置。
这是
Diff算法
最精髓也是最难懂的部分。接下来会重点讲解。处理移动的节点
由于有节点改变了位置,所以不能再用位置索引
i
对比前后的节点,那么如何才能将同一个节点在两次更新中对应上呢?我们需要使用
key
。为了快速的找到
key
对应的oldFiber
,我们将所有还未处理的oldFiber
存入以key
为key,oldFiber
为value的Map
中。const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
接下来遍历剩余的
newChildren
,通过newChildren[i].key
就能在existingChildren
中找到key
相同的oldFiber
。标记节点是否移动
既然我们的目标是寻找移动的节点,那么我们需要明确:节点是否移动是以什么为参照物?
我们的参照物是:最后一个可复用的节点在
oldFiber
中的位置索引(用变量lastPlacedIndex
表示)。由于本次更新中节点是按
newChildren
的顺序排列。在遍历newChildren
过程中,每个遍历到的可复用节点
一定是当前遍历到的所有可复用节点
中最靠右的那个,即一定在lastPlacedIndex
对应的可复用的节点
在本次更新中位置的后面。那么我们只需要比较
遍历到的可复用节点
在上次更新时是否也在lastPlacedIndex
对应的oldFiber
后面,就能知道两次更新中这两个节点的相对位置改变没有。我们用变量
oldIndex
表示遍历到的可复用节点
在oldFiber
中的位置索引。如果oldIndex < lastPlacedIndex
,代表本次更新该节点需要向右移动。lastPlacedIndex
初始为0
,每遍历一个可复用的节点,如果oldIndex >= lastPlacedIndex
,则lastPlacedIndex = oldIndex
。单纯文字表达比较晦涩,这里我们提供两个Demo,你可以对照着理解。
Demo1
在Demo中我们简化下书写,每个字母代表一个节点,字母的值代表节点的
key
// 之前 abcd // 之后 acdb ===第一轮遍历开始=== a(之后)vs a(之前) key不变,可复用 此时 a 对应的oldFiber(之前的a)在之前的数组(abcd)中索引为0 所以 lastPlacedIndex = 0; 继续第一轮遍历... c(之后)vs b(之前) key改变,不能复用,跳出第一轮遍历 此时 lastPlacedIndex === 0; ===第一轮遍历结束=== ===第二轮遍历开始=== newChildren === cdb,没用完,不需要执行删除旧节点 oldFiber === bcd,没用完,不需要执行插入新节点 将剩余oldFiber(bcd)保存为map // 当前oldFiber:bcd // 当前newChildren:cdb 继续遍历剩余newChildren key === c 在 oldFiber中存在 const oldIndex = c(之前).index; 此时 oldIndex === 2; // 之前节点为 abcd,所以c.index === 2 比较 oldIndex 与 lastPlacedIndex; 如果 oldIndex >= lastPlacedIndex 代表该可复用节点不需要移动 并将 lastPlacedIndex = oldIndex; 如果 oldIndex < lastplacedIndex 该可复用节点之前插入的位置索引小于这次更新需要插入的位置索引,代表该节点需要向右移动 在例子中,oldIndex 2 > lastPlacedIndex 0, 则 lastPlacedIndex = 2; c节点位置不变 继续遍历剩余newChildren // 当前oldFiber:bd // 当前newChildren:db key === d 在 oldFiber中存在 const oldIndex = d(之前).index; oldIndex 3 > lastPlacedIndex 2 // 之前节点为 abcd,所以d.index === 3 则 lastPlacedIndex = 3; d节点位置不变 继续遍历剩余newChildren // 当前oldFiber:b // 当前newChildren:b key === b 在 oldFiber中存在 const oldIndex = b(之前).index; oldIndex 1 < lastPlacedIndex 3 // 之前节点为 abcd,所以b.index === 1 则 b节点需要向右移动 ===第二轮遍历结束=== 最终acd 3个节点都没有移动,b节点被标记为移动
Demo2
// 之前 abcd // 之后 dabc ===第一轮遍历开始=== d(之后)vs a(之前) key改变,不能复用,跳出遍历 ===第一轮遍历结束=== ===第二轮遍历开始=== newChildren === dabc,没用完,不需要执行删除旧节点 oldFiber === abcd,没用完,不需要执行插入新节点 将剩余oldFiber(abcd)保存为map 继续遍历剩余newChildren // 当前oldFiber:abcd // 当前newChildren dabc key === d 在 oldFiber中存在 const oldIndex = d(之前).index; 此时 oldIndex === 3; // 之前节点为 abcd,所以d.index === 3 比较 oldIndex 与 lastPlacedIndex; oldIndex 3 > lastPlacedIndex 0 则 lastPlacedIndex = 3; d节点位置不变 继续遍历剩余newChildren // 当前oldFiber:abc // 当前newChildren abc key === a 在 oldFiber中存在 const oldIndex = a(之前).index; // 之前节点为 abcd,所以a.index === 0 此时 oldIndex === 0; 比较 oldIndex 与 lastPlacedIndex; oldIndex 0 < lastPlacedIndex 3 则 a节点需要向右移动 继续遍历剩余newChildren // 当前oldFiber:bc // 当前newChildren bc key === b 在 oldFiber中存在 const oldIndex = b(之前).index; // 之前节点为 abcd,所以b.index === 1 此时 oldIndex === 1; 比较 oldIndex 与 lastPlacedIndex; oldIndex 1 < lastPlacedIndex 3 则 b节点需要向右移动 继续遍历剩余newChildren // 当前oldFiber:c // 当前newChildren c key === c 在 oldFiber中存在 const oldIndex = c(之前).index; // 之前节点为 abcd,所以c.index === 2 此时 oldIndex === 2; 比较 oldIndex 与 lastPlacedIndex; oldIndex 2 < lastPlacedIndex 3 则 c节点需要向右移动 ===第二轮遍历结束===
可以看到,我们以为从
abcd
变为dabc
,只需要将d
移动到前面。但实际上React保持
d
不变,将abc
分别移动到了d
的后面。从这点可以看出,考虑性能,我们要尽量减少将节点从后面移动到前面的操作