Description
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的瓶颈以及React如何应对
由于Diff
操作本身也会带来性能损耗,React文档中提到,即使在最前沿的算法中,将前后两棵树完全比对的算法的复杂程度为 O(n 3 ),其中n
是树中元素的数量。
如果在React
中使用了该算法,那么展示1000个元素所需要执行的计算量将在十亿的量级范围。这个开销实在是太过高昂。
为了降低算法复杂度,React
的diff
会预设三个限制:
- 只对同级元素进行
Diff
。如果一个DOM节点
在前后两次更新中跨越了层级,那么React
不会尝试复用他。 - 两个不同类型的元素会产生出不同的树。如果元素由
div
变为p
,React会销毁div
及其子孙节点,并新建p
及其子孙节点。 - 开发者可以通过
key prop
来暗示哪些子元素在不同的渲染下能保持稳定。考虑如下例子:
// 更新前
<div>
<p key="ka">ka</p>
<h3 key="song">song</h3>
</div>
// 更新后
<div>
<h3 key="song">song</h3>
<p key="ka">ka</p>
</div>
如果没有key
,React
会认为div
的第一个子节点由p
变为h3
,第二个子节点由h3
变为p
。这符合限制2的设定,会销毁并新建。
但是当我们用key
指明了节点前后对应关系后,React
知道key === "ka"
的p
在更新后还存在,所以DOM节点
可以复用,只是需要交换下顺序。
Diff是如何实现的
我们从Diff
的入口函数reconcileChildFibers
出发,该函数会根据newChild
(即JSX对象
)类型调用不同的处理函数。
我们可以从同级的节点数量将Diff分为两类:
- 当
newChild
类型为object
、number
、string
,代表同级只有一个节点 - 当
newChild
类型为Array
,同级有多个节点
单节点Diff
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,并返回 ...省略
}
- 先判断是否存在对应的DOM节点
- 再根据key,以及type判断是否可复用
- key不同,删除该fiber节点
- key相同,type不同,删除该fiber节点以及其兄弟fiber节点
- key和type都相同,可复用。
可能会奇怪为啥,key相同,type不同需要全部删除。这是因为,咱们此次更新的是单节点。当key相同(唯一性),就说明对上了上一次的节点,后续的兄弟节点不可能对上,但是由于type不同,这个节点不能复用,就需要删除。当key不同,只是当前节点没对上,后续的兄弟节点还有可能对上。
练习题
让我们来做几道习题巩固下吧:
请判断如下JSX对象
对应的DOM
元素是否可以复用:
// 习题1 更新前
<div>ka song</div>
// 更新后
<p>ka song</p>
// 习题2 更新前
<div key="xxx">ka song</div>
// 更新后
<div key="ooo">ka song</div>
// 习题3 更新前
<div key="xxx">ka song</div>
// 更新后
<p key="ooo">ka song</p>
// 习题4 更新前
<div key="xxx">ka song</div>
// 更新后
<div key="xxx">xiao bei</div>
。
。
。
。
公布答案:
习题1: 未设置key prop
默认 key = null;
,所以更新前后key相同,都为null
,但是更新前type
为div
,更新后为p
,type
改变则不能复用。
习题2: 更新前后key
改变,不需要再判断type
,不能复用。
习题3: 更新前后key
改变,不需要再判断type
,不能复用。
习题4: 更新前后key
与type
都未改变,可以复用。children
变化,DOM
的子元素需要更新。
多节点Diff
多节点Diff分为:第一轮遍历,第二轮遍历两种。第一遍是找出待更新的DOM,第二遍是找出待新增,删除,移动的DOM。(更新的场景更多,所以react官方先判断更新)
第一轮遍历:处理更新
的节点。
第二轮遍历:处理剩下的不属于更新
的节点。
第一轮遍历
第一轮遍历步骤如下:
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
),跳出遍历,第一轮遍历结束。
第二轮遍历
对于第一轮遍历的结果,我们分别讨论:
1 . newChildren
与oldFiber
同时遍历完
那就是最理想的情况:只需在第一轮遍历进行组件更新
。此时Diff
结束。
2. newChildren
没遍历完,oldFiber
遍历完
已有的DOM节点
都复用了,这时还有新加入的节点,意味着本次更新有新节点插入,我们只需要遍历剩下的newChildren
为生成的workInProgress fiber
依次标记Placement
。
3. newChildren
遍历完,oldFiber
没遍历完
意味着本次更新比之前的节点数量少,有节点被删除了。所以需要遍历剩下的oldFiber
,依次标记Deletion
。
4. newChildren
与oldFiber
都没遍历完
这意味着有节点在这次更新中改变了位置。需要移动位置。这是Diff算法
最精髓也是最难懂的部分。我们接下来会重点讲解。
处理移动的节点
由于有节点改变了位置,所以不能再用位置索引i
对比前后的节点,那么如何才能将同一个节点在两次更新中对应上呢?
我们需要使用key
。
为了快速的找到key
对应的oldFiber
,我们将所有还未处理的oldFiber
存入以key
为key,oldFiber
为value的Map
中。
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
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节点被标记为移动