说明:本文结论均基于 React 17.0.2 得出,若有出入请参考对应版本源码
引言
上篇文章介绍了 React 协调过程中 beginWork
阶段的前半部分,这篇文章我们来介绍后半部分。
beginWork
同样的,我们还是精简一下代码,只关注感兴趣的部分:
1 | function beginWork( |
首先会通过 workInProgress.tag
来判断当前处理的 FiberNode
是哪种类型,并针对不同的类型调用不同的 update 方法。这些方法虽然名字差别很大,但是最后都逃不过这两句代码:
1 | reconcileChildren(current, workInProgress, nextChildren, renderLanes); |
这里的 workInProgress
为当前正在处理的 FiberNode
,current
即为上一次更新的与 workInProgress
配对的 FiberNode
(有可能不存在),nextChildren
是这次更新的 workInProgress
下新的子节点( ReactElement
数组),renderLanes
则是跟更新优先级相关。
接下来看看 reconcileChildren
做了什么:
1 | export function reconcileChildren( |
reconcileChildren
中根据 current
是否存在来决定是应该 mountChildFibers
还是 reconcileChildFibers
,这两个方法其实是类似的,只是 reconcileChildFibers
时 shouldTrackSideEffects
为 true,会多做一些事情:
1 | function ChildReconciler(shouldTrackSideEffects) { |
我们这里暂时只看 reconcileChildFibers
:
1 | function reconcileChildFibers( |
reconcileChildFibers
中根据 newChild
的类型来进行不同的处理,我们这里去掉了暂时不关心的部分,仅分析 newChild
为对象且是 REACT_ELEMENT_TYPE
或者 newChild
为数组的情形。事实上 reconcileChildFibers
就涉及到了 React 中面试常考的 Diff 算法,接下来我们深入研究一下。
Diff 算法
Diff 的是什么?
首先,需要知道的第一个问题是:Diff 算法到底 Diff 的是哪两个东西?是旧的 FiberNode
和 新的 FiberNode
?答案是 No。
从上面的代码可知,传入 reconcileChildFibers
的是 current.child
和 nextChildren
,所以 Diff 算法 其实是比较旧的 FiberNode
和新的 ReactElement
来生成新的 FiberNode
的一个过程:
Diff 分类
根据 Diff 时 ReactElement
的类型,我们可以把 Diff 算法分为:
- 单节点 Diff(reconcileSingleElement):
ReactElement
是对象。 - 多节点 Diff(reconcileChildrenArray):
ReactElement
是数组。
单节点 Diff
1 | function reconcileSingleElement( |
去掉一些调试代码以及类型为 Fragment
的代码,分析剩下的代码,发现可以分为四种场景:
- 原来的
FiberNode
不存在。只需要创建新的FiberNode
并标记为Placement
即可。 - 新的
ReactElement
和 旧的FiberNode
的type
和key
都相同。可以复用旧的FiberNode
,并将旧的FiberNode
的所有兄弟节点标记为。 - 新的
ReactElement
和 旧的FiberNode
的key
相同,type
不同。需要将旧的FiberNode
及其兄弟节点都标记为删除,然后创建新的FiberNode
并标记为Placement
。 - 新的
ReactElement
和 旧的FiberNode
的key
不同。将当前FiberNode
标记为删除,继续按照 2,3,4 的策略对比其兄弟节点和ReactElement
直到遍历完成。
多节点 Diff
1 | function reconcileChildrenArray( |
因为实际应用场景中列表更新时大部分都是追加或者节点本身的更新,节点位置发生变化的情况相对少见,所以 React 在多节点 Diff 过程中分成了两轮:
第一轮从前往后一一比对,这里分为三种情况:
- 如果新旧节点
key
和type
相同则说明可以复用旧节点,继续往下遍历。 key
相同type
不同,将旧节点标记为删除,新节点标记为Placement
,继续往下遍历。key
不同,退出循环。
第一轮结束后,开始第二轮遍历,这里又分为三种情况:
- 新节点即
ReactElement
数组遍历完成了,此时只需要把旧的剩下的节点标记为删除,然后返回。 - 旧节点链表遍历完成了,此时只需要处理
ReactElement
数组中剩下的元素,即通过他们创建新的FiberNode
并标记为Placement
,然后返回。 都没有遍历完成,说明第一轮遍历中提前终止了。接下来详细讨论该步骤:
3.1. 将未遍历完的旧节点以保存为 Map,用
key
作为索引。
3.2. 遍历新节点,通过新节点去 Map 中寻找是否有可以复用的节点。如果没有找到则通过ReactElement
创建新的节点,找到了则复用旧节点来创建新的节点。最后得到的都是newFiber
。
3.3. 调用placeChild
来处理newFiber
。这里分三种情形:a. 它是通过ReactElement
创建来的,将其标记为Placement
;b. 它是通过复用旧节点而得到的且旧节点的位置小于lastPlacedIndex
,将其标记为Placement
;c. 它是通过复用旧节点而得到的且旧节点的位置oldIndex
大于lastPlacedIndex
,无需标记,更新lastPlacedIndex
为oldIndex
。
上面的步骤肯定看了很懵逼,尤其是涉及到 lastPlacedIndex
的更新这一块。不妨看下面两个例子:
例一:1
2旧列表:abcde
新列表:abdec
该例子最后得到的结果是要把 c 移到最后面。
例二:
1 | 旧列表:abcde |
该例子最后得到的结果是要把 c 和 d 移到最后面。
同样都只是移动了一个元素,得到的结果却不大一样,例二的代价明显要大,原因就在于 React 的 Diff 算法。
总结
本文介绍了 beginWork
函数的后半部分并引出了 Diff 算法,讨论了 Diff 算法的作用、分类以及详细步骤,并结合案例进行了对比。