目前主流的前端框架Vue和React,都引入了虚拟DOM的机制,这不可避免的涉及到从虚拟DOM和真实DOM的对比过程,寻找差异后更新到真实DOM树,完成视图的渲染。 React的设计更加的复杂,它不仅有虚拟DOM,为了提升渲染性能,还引入了Fiber架构模式,这让它的Diff过程比较独特。
Diff的策略
Diff的过程是,比较两颗结点树的差异,然后patch,以最小化的成本来更新视图。完全比较两颗树的差异算法是O(n^3)的时间复杂度。假如页面上有1000个结点,消耗的性能非常惊人。
每个结点都去和另一棵树上的结点,逐一比较一次,再加上更新的操作。
js
1000 ** 3 = 1,000,000,000 // 10亿
假如页面上的结点更多的话,每次更新一个数据状态,都要如此庞大的性能消耗,页面肯定会比较卡顿。为了减少结点Diff过程中的性能消耗,React框架在设计的时候,制定了3条策略。
- 只进行同级的结点比较,跨层级的结点不处理
默认页面上的绝大部分操作都是在同一级内展开的(同一个父级容器内),跨级操作的概率会比较低。
- 根据结点的type类型比较,遇到类型不同直接标记为删除
第三个结点,由li标签变成的p标签,这种情况会直接销毁li-c,重新创建成p标签
- 会根据结点key标识,来尽量复用结点
这样,新旧列表中的结点类型一致且都能找相同key的结点,只需要移动结点的位置即可。
通过上述这三种Diff策略的实施,将结点树的对比时间复杂度由 O(n^3) 降低到了 O(n)
React中Diff的本质
在介绍 React的结点Diff过程前,我们先熟悉下React的fiber架构
在上面图中所示,每个结点有三个指针,return 指向父级结点,sibling指向相邻的兄弟结点,child指向第一子结点。所有的fiber结点构成了,一个棵链表树。
在每次 setState 的过程中,React都会进行调和(reconcile)的过程,就是拿JSX生成ReactElement 和 上次更新时对应的 Fiber结点比较,这就是Diff的过程,将比较的结果生成新的Fiber结点。
一个DOM节点在某一时刻最多会有4个节点和他相关。
- current Fiber。如果该DOM节点已在页面中,current Fiber代表该DOM节点对应的Fiber节点。
- workInProgress Fiber。如果该DOM节点将在本次更新中渲染到页面中,workInProgress Fiber代表该DOM节点对应的Fiber节点。
- DOM节点本身。
- JSX对象。即ClassComponent的render方法的返回结果,或 FunctionComponent的调用结果。JSX对象中包含描述DOM节点的信息。
Diff的过程,就是 current Fiber结点 和 JSX对象比较生成 workInProgress Fiber,而真实的DOM结点的更新则发生在commit阶段。
Diff的入口函数 reconcileChildFibers 出发,该函数会根据newChild(即JSX对象)类型调用不同的处理函数。
js
// 根据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 处理
// 单个 组件元素的处理
}
}
if (typeof newChild === 'string' || typeof newChild === 'number') {
// 调用 reconcileSingleTextNode 处理
// 单个文本结点的处理
}
if (isArray(newChild)) {
// 调用 reconcileChildrenArray 处理
// 多个子节点的处理
}
// 一些其他情况调用处理函数
// 以上都没有命中,删除节点
return deleteRemainingChildren(returnFiber, currentFirstChild);
}
单结点Diff
单个结点的处理流程
- 先判断是否存在上次的Fiber结点对应的DOM,不存在的话,直接新生成一个Fiber结点并返回
- 存在的话,对比下DOM结点是否可复用,能复用的话,直接返回上次Fiber的副本
- 假如不能复用,标记成
deletion
,并新生成一个Fiber结点返回。
js
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,并返回 ...省略
}
在代码的实现逻辑中
- 先比较结点的key是否相同,不同则不能复用,标记成 删除 状态
- 结点key相同的前提下,再比较type类型,类型一致,则可复用
- 结点key相同的前提下,假如type类型不一致的话,将该结点和其兄弟fiber结点都标记成 删除 状态
举个例子:
html
// before
section > div * 3
// after
section > h2
更新前是 三个div的子元素,更新后是一个h2标签,它们都同属一section容器。当h2的key已经匹配到第一个div时,此时由于div 和 h2是不同类型的结点, 既然h2 <--key匹配上--> div-1,那么后面的第二个和第三div已经没用了,且由于类型不同,会直接将这三个div全部标记删除状态。
当 h2 <--key没匹配上--> div-1,仅需要标记 div-1 删除状态即可,因为有可能 h2 <--key匹配上--> div-2 或者 h2 <--key匹配上--> div-3
当且仅当 key 和 type都相同时,才能复用结点,二者缺一不可。在该过程会优先使用key,key不同,则 type 就不会比较了,直接将结点标记 删除 状态
多结点Diff
当 reconcileChildFibers 方法中传递的 newChild参数类型为Array时,就开始多结点的Diff了。
html
<ul>
<li key="a">A</li>
<li key="b">B</li>
<li key="c">C</li>
<li key="d">D</li>
</ul>
可能的变化如下几种:
- 结点更新
(key一致的前提下)属性信息 或 内容信息 或 结点类型变化了
html
<ul>
<!-- 增加了样式类名称【list-item】 -->
<li key="a" class="list-item">A</li>
<!-- 改变了内容信息: -->
<li key="b">BBB</li>
<li key="c">C</li>
<!-- 标签类型变了 -->
<p key="d">D</p>
</ul>
- 结点新增或减少
html
<!-- 结点增加情况 -->
<ul>
<li key="a">A</li>
<li key="b">B</li>
<li key="c">C</li>
<li key="d">D</li>
<li key="e">E</li>
</ul>
<!-- 结点减少情况 -->
<ul>
<li key="a">A</li>
<li key="b">B</li>
<li key="c">C</li>
</ul>
- 结点位置的改变
html
<ul>
<li key="d">D</li>
<li key="a">A</li>
<li key="c">C</li>
<li key="b">B</li>
</ul>
多结点的Diff比较,就在上面三种情况中。
Diff的总体思路
由于 Fiber 结点是链表树的结构,而传递的 newChild 是JSXElement 类型,可以理解一个一维的数组结构。两者没法通过双端指针的比较方式,采用了两轮的比较策略。
- 第一轮,发现更新情况的结点,并标记处理
- 第二轮,处理非
更新
情况的结点
第一轮,分别从 oldFiber的起始位置 和 newChild的索引为0的位置,逐个比较。
发现发现可以复用的结点,i 和 start 分别向右挪动一个位置,直到 newChild 或 oldFiber 最后。
如果不能复用的话:
- key不同导致的无法复用,直接结束第一轮遍历
- key相同type不同导致的无法复用,会将 oldFiber 中的结点标记 删除状态,继续遍历
第一轮结束后,会有如下几种情况:
- oldFiber 和 newChild 都遍历完了,那么Diff过程结束
- oldFiber 没有遍历完,newChild 遍历完了,说明有 旧的结点会被删除
- oldFiber 遍历完,newChild 没有遍历完了,说明有 新的结点会被插入
- oldFiber 和 newChild 都没有遍历完,说明结点的位置有改动
第二轮遍历:
- newChildren与oldFiber同时遍历完,最理想的情况,Diff结束
- oldFiber 没有遍历完,newChild 遍历完了,需要遍历剩下的oldFiber,依次标记Deletion
- oldFiber 遍历完,newChild 没有遍历完了,遍历剩下的newChildren为生成的workInProgress fiber依次标记 Placement
- oldFiber 和 newChild 都没有遍历完,处理移动的结点
处理移动的结点
总体思路:
- 首先将 oldFiber 映射成 { key => FiberNode }的哈希Map,便于在遍历 newChild 时快速定位上
- 声明一个可以复用结点的最大索引位置 lastPlacedIndex,作为参照物,每次遍历 newChild 时 迭代更新
- 遍历 newChild 并找到其在 oldFiber 中的索引位置,记录为 currentIndex,拿 currentIndex 和 lastPlacedIndex。如果 currentIndex > lastPlacedIndex,则将 lastPlacedIndex = currentIndex,不断更新lastPlacedIndex的值,否则维持 lastPlacedIndex 不变
示例一
假如更新前是a b c d
的结点顺序,更新后是 a c d b
的顺序
第一轮结束后,lastPlacedIndex = 0
,开启第二轮针对 newChild剩余结点的遍历。此时
js
oldFiber: [a, b, c, d]
newChild: [a, → c, d, b]
- c在oldFiber中的索引是 2,即 currentIndex = 2
- 由于 currentIndex > lastPlacedIndex,意味着 c 结点不需要移动
- 重置参照物结点的索引:lastPlacedIndex = currentIndex = 2
进行 newChild中 下一个结点 d的处理
js
oldFiber: [a, b, c, d]
newChild: [a, c, → d, b]
- d在oldFiber中的索引是 3,即 currentIndex = 3
- 由于 currentIndex > lastPlacedIndex,意味着 d 结点不需要移动
- 重置参照物结点的索引:lastPlacedIndex = currentIndex = 3
进行 newChild中 下一个结点 b的处理
js
oldFiber: [a, b, c, d]
newChild: [a, c, d, → b]
- b 在oldFiber中的索引是 1,即 currentIndex = 1
- 上一次 lastPlacedIndex 是 3
- 由于 currentIndex < lastPlacedIndex,意味着 b 结点需要移动
- 参照物结点的索引,lastPlacedIndex 保持 3不变
最终 a c d 三个结点没有移动,只有 b 结点标记为移动状态
示例二
假如更新前是a b c d
的结点顺序,更新后是 d a b c
的顺序
第一轮结束后,没有结点可复用,开启第二轮针对 newChild剩余结点的遍历。首先处理 d 结点
js
oldFiber: [a, b, c, d]
newChild: [ → d, a, b, c]
- d在oldFiber中的索引是 3,即 currentIndex = 3
- 由于 currentIndex > lastPlacedIndex,意味着 d 结点不需要移动
- 重置参照物结点的索引:lastPlacedIndex = currentIndex = 3
进行 newChild中 下一个结点 a 的处理
js
oldFiber: [a, b, c, d]
newChild: [d, → a, b, c]
- a 在oldFiber中的索引是 0,即 currentIndex = 0
- 上一次 lastPlacedIndex 是 3
- 由于 currentIndex < lastPlacedIndex,意味着 a 结点需要移动
- 参照物结点的索引,lastPlacedIndex 保持 3不变
进行 newChild中 下一个结点 b 的处理
js
oldFiber: [a, b, c, d]
newChild: [d, a, → b, c]
- b 在oldFiber中的索引是 1,即 currentIndex = 1
- 上一次 lastPlacedIndex 是 3
- 由于 currentIndex < lastPlacedIndex,意味着 b 结点需要移动
- 参照物结点的索引,lastPlacedIndex 保持 3不变
进行 newChild中 下一个结点 c 的处理
js
oldFiber: [a, b, c, d]
newChild: [d, a, b, → c]
- c 在oldFiber中的索引是 2,即 currentIndex = 2
- 上一次 lastPlacedIndex 是 3
- 由于 currentIndex < lastPlacedIndex,意味着 c 结点需要移动
- 参照物结点的索引,lastPlacedIndex 保持 3不变
至此比较结束,d 结点不需要移动,而 a b c 三个结点都需要移动
总结
- [a, b, c, d] => [a, c, d, b] 需要移动 b 一个结点
- [a, b, c, d] => [d, a, b, c] 需要移动a、b、c三个结点
从两个示例中可以看出,应尽量减少后面的结点移动到前面,来提高性能。