react diff 原理
概念
调和函数(源码)是在fiber树构(对比更新)
过程中对旧fiber节点
与新reactElement
进行比较, 判定旧fiber节点
是否可以复用的一个比较函数.
调和函数仅是fiber树构造
过程中的一个环节, 所以在深入理解这个函数之前, 建议对fiber树构造
有一个宏观的理解(可以参考前文 fiber 树构造(初次创建), fiber 树构造(对比更新), 本节重点探讨其算法的实现细节.
它的主要作用:
- 给新增,移动,和删除节点设置
fiber.flags
(新增, 移动: Placement
, 删除: Deletion
)
- 如果是需要删除的
fiber
, 除了自身打上Deletion
之外, 还要将其添加到父节点的effects
链表中(正常副作用队列的处理是在completeWork
函数, 但是该节点(被删除)会脱离fiber
树, 不会再进入completeWork
阶段, 所以在beginWork
阶段提前加入副作用队列).
特性
算法复杂度低, 从上至下比较整个树形结构, 时间复杂度被缩短到 O(n)
基本原理
- 比较对象:
fiber
对象与ReactElement
对象相比较.
- 注意: 此处有一个误区, 并不是两棵 fiber 树相比较, 而是
旧fiber
对象与新ReactElement
对象向比较, 结果生成新的fiber子节点
.
- 可以理解为输入
ReactElement
, 经过reconcileChildren()
之后, 输出fiber
.
- 比较方案:
单节点比较
单节点的逻辑比较简明, 先直接看源码:
1// 只保留主干逻辑
2function reconcileSingleElement(
3 returnFiber: Fiber,
4 currentFirstChild: Fiber | null,
5 element: ReactElement,
6 lanes: Lanes
7): Fiber {
8 const key = element.key;
9 let child = currentFirstChild;
10
11 while (child !== null) {
12 // currentFirstChild !== null, 表明是对比更新阶段
13 if (child.key === key) {
14 // 1. key相同, 进一步判断 child.elementType === element.type
15 switch (child.tag) {
16 // 只看核心逻辑
17 default: {
18 if (child.elementType === element.type) {
19 // 1.1 已经匹配上了, 如果有兄弟节点, 需要给兄弟节点打上Deletion标记
20 deleteRemainingChildren(returnFiber, child.sibling);
21 // 1.2 构造fiber节点, 新的fiber对象会复用current.stateNode, 即可复用DOM对象
22 const existing = useFiber(child, element.props);
23 existing.ref = coerceRef(returnFiber, child, element);
24 existing.return = returnFiber;
25 return existing;
26 }
27 break;
28 }
29 }
30 // Didn't match. 给当前节点点打上Deletion标记
31 deleteRemainingChildren(returnFiber, child);
32 break;
33 } else {
34 // 2. key不相同, 匹配失败, 给当前节点打上Deletion标记
35 deleteChild(returnFiber, child);
36 }
37 child = child.sibling;
38 }
39
40 {
41 // ...省略部分代码, 只看核心逻辑
42 }
43
44 // 新建节点
45 const created = createFiberFromElement(element, returnFiber.mode, lanes);
46 created.ref = coerceRef(returnFiber, currentFirstChild, element);
47 created.return = returnFiber;
48 return created;
49}
- 如果是新增节点, 直接新建 fiber, 没有多余的逻辑
- 如果是对比更新
- 如果
key
和type
都相同(即: ReactElement.key
=== Fiber.key
且 Fiber.elementType === ReactElement.type
), 则复用
- 否则新建
注意: 复用过程是调用useFiber(child, element.props)
创建新的fiber
对象, 这个新fiber对象.stateNode = currentFirstChild.stateNode
, 即stateNode
属性得到了复用, 故 DOM 节点得到了复用.
可迭代节点比较(数组类型, [Symbol.iterator]=fn,[@@iterator]=fn)
可迭代节点比较, 在源码中被分为了 2 个部分:
1function reconcileChildFibers(
2 returnFiber: Fiber,
3 currentFirstChild: Fiber | null,
4 newChild: any,
5 lanes: Lanes
6): Fiber | null {
7 if (isArray(newChild)) {
8 return reconcileChildrenArray(returnFiber, currentFirstChild, newChild, lanes);
9 }
10 if (getIteratorFn(newChild)) {
11 return reconcileChildrenIterator(returnFiber, currentFirstChild, newChild, lanes);
12 }
13}
其中reconcileChildrenArray函数
(针对数组类型)和reconcileChildrenIterator
(针对可迭代类型)的核心逻辑几乎一致, 下文将分析reconcileChildrenArray()
函数. 如果是新增节点, 所有的比较逻辑都无法命中, 只有对比更新
过程, 才有实际作用, 所以下文重点分析对比更新
的情况.
1function reconcileChildrenArray(
2 returnFiber: Fiber,
3 currentFirstChild: Fiber | null,
4 newChildren: Array<*>,
5 lanes: Lanes
6): Fiber | null {
7 let resultingFirstChild: Fiber | null = null;
8 let previousNewFiber: Fiber | null = null;
9
10 let oldFiber = currentFirstChild;
11 let lastPlacedIndex = 0;
12 let newIdx = 0;
13 let nextOldFiber = null;
14 // 1. 第一次循环: 遍历最长公共序列(key相同), 公共序列的节点都视为可复用
15 for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
16 // 后文分析
17 }
18
19 if (newIdx === newChildren.length) {
20 // 如果newChildren序列被遍历完, 那么oldFiber序列中剩余节点都视为删除(打上Deletion标记)
21 deleteRemainingChildren(returnFiber, oldFiber);
22 return resultingFirstChild;
23 }
24
25 if (oldFiber === null) {
26 // 如果oldFiber序列被遍历完, 那么newChildren序列中剩余节点都视为新增(打上Placement标记)
27 for (; newIdx < newChildren.length; newIdx++) {
28 // 后文分析
29 }
30 return resultingFirstChild;
31 }
32
33 // ==================分割线==================
34 const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
35
36 // 2. 第二次循环: 遍历剩余非公共序列, 优先复用oldFiber序列中的节点
37 for (; newIdx < newChildren.length; newIdx++) {}
38
39 if (shouldTrackSideEffects) {
40 // newChildren已经遍历完, 那么oldFiber序列中剩余节点都视为删除(打上Deletion标记)
41 existingChildren.forEach((child) => deleteChild(returnFiber, child));
42 }
43
44 return resultingFirstChild;
45}
reconcileChildrenArray
函数源码看似很长, 梳理其主干之后, 其实非常清晰.
通过形参, 首先明确比较对象是currentFirstChild: Fiber | null
和newChildren: Array<*>
:
currentFirstChild
: 是一个fiber
节点, 通过fiber.sibling
可以将兄弟节点全部遍历出来. 所以可以将currentFirstChild
理解为链表头部, 它代表一个序列, 源码中被记为oldFiber
.
newChildren
: 是一个数组, 其中包含了若干个ReactElement
对象. 所以newChildren
也代表一个序列.
所以reconcileChildrenArray
实际就是 2 个序列之间的比较(链表oldFiber
和数组newChildren
), 最后返回合理的fiber
序列.
上述代码中, 以注释分割线为界限, 整个核心逻辑分为 2 步骤:
- 第一次循环: 遍历最长
公共
序列(key 相同), 公共序列的节点都视为可复用
- 如果
newChildren序列
被遍历完, 那么oldFiber序列
中剩余节点都视为删除(打上Deletion
标记)
- 如果
oldFiber序列
被遍历完, 那么newChildren序列
中剩余节点都视为新增(打上Placement
标记)
- 第二次循环: 遍历剩余
非公共
序列, 优先复用 oldFiber 序列中的节点
- 在对比更新阶段(非初次创建
fiber
, 此时shouldTrackSideEffects
被设置为 true). 第二次循环遍历完成之后, oldFiber序列中
没有匹配上的节点都视为删除(打上Deletion
标记)
假设有如下图所示 2 个初始化序列:
接下来第一次循环, 会遍历公共序列A,B
, 生成的 fiber 节点fiber(A), fiber(B)
可以复用.
最后第二次循环, 会遍历剩余序列E,C,X,Y
:
- 生成的 fiber 节点
fiber(E), fiber(C)
可以复用. 其中fiber(C)
节点发生了位移(打上Placement
标记).
fiber(X), fiber(Y)
是新增(打上Placement
标记).
- 同时
oldFiber
序列中的fiber(D)
节点确定被删除(打上Deletion
标记).
整个主干逻辑就介绍完了, 接下来贴上完整源码
第一次循环
1// 1. 第一次循环: 遍历最长公共序列(key相同), 公共序列的节点都视为可复用
2for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
3 if (oldFiber.index > newIdx) {
4 nextOldFiber = oldFiber
5 oldFiber = null
6 }
7 else {
8 nextOldFiber = oldFiber.sibling
9 }
10 // new槽位和old槽位进行比较, 如果key不同, 返回null
11 // key相同, 比较type是否一致. type一致则执行useFiber(update逻辑), type不一致则运行createXXX(insert逻辑)
12 const newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx], lanes)
13
14 if (newFiber === null) {
15 // 如果返回null, 表明key不同. 无法满足公共序列条件, 退出循环
16 if (oldFiber === null)
17 oldFiber = nextOldFiber
18
19 break
20 }
21 if (shouldTrackSideEffects) {
22 // 若是新增节点, 则给老节点打上Deletion标记
23 if (oldFiber && newFiber.alternate === null)
24 deleteChild(returnFiber, oldFiber)
25 }
26
27 // lastPlacedIndex 记录被移动的节点索引
28 // 如果当前节点可复用, 则要判断位置是否移动.
29 lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx)
30
31 // 更新resultingFirstChild结果序列
32 if (previousNewFiber === null)
33 resultingFirstChild = newFiber
34 else
35 previousNewFiber.sibling = newFiber
36
37 previousNewFiber = newFiber
38 oldFiber = nextOldFiber
39}
第二次循环
1// 1. 将第一次循环后, oldFiber剩余序列加入到一个map中. 目的是为了第二次循环能顺利的找到可复用节点
2const existingChildren = mapRemainingChildren(returnFiber, oldFiber)
3
4// 2. 第二次循环: 遍历剩余非公共序列, 优先复用oldFiber序列中的节点
5for (; newIdx < newChildren.length; newIdx++) {
6 const newFiber = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes)
7 if (newFiber !== null) {
8 if (shouldTrackSideEffects) {
9 if (newFiber.alternate !== null) {
10 // 如果newFiber是通过复用创建的, 则清理map中对应的老节点
11 existingChildren.delete(newFiber.key === null ? newIdx : newFiber.key)
12 }
13 }
14 lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx)
15 // 更新resultingFirstChild结果序列
16 if (previousNewFiber === null)
17 resultingFirstChild = newFiber
18 else
19 previousNewFiber.sibling = newFiber
20
21 previousNewFiber = newFiber
22 }
23}
24// 3. 善后工作, 第二次循环完成之后, existingChildren中剩余的fiber节点就是将要被删除的节点, 打上Deletion标记
25if (shouldTrackSideEffects)
26 existingChildren.forEach(child => deleteChild(returnFiber, child))
结果
无论是单节点还是可迭代节点的比较, 最终的目的都是生成下级子节点. 并在reconcileChildren
过程中, 给一些有副作用的节点(新增, 删除, 移动位置等)打上副作用标记, 等待 commit 阶段(参考 fiber 树渲染的处理.
总结
本节介绍了 React 源码中, fiber构造循环
阶段用于生成下级子节点的reconcileChildren
函数(函数中的算法被称为调和算法), 并演示了可迭代节点比较
的图解示例. 该算法十分巧妙, 其核心逻辑把newChildren序列
分为 2 步遍历, 先遍历公共序列, 再遍历非公共部分, 同时复用oldFiber
序列中的节点.