React原理之Diff算法
前置文章:
- React原理之 React 整体架构解读
- React原理之整体渲染流程
- React原理之Fiber详解
- React原理之Fiber双缓冲
Diff 算法是 React 中最核心的部分,它决定了 React 在更新时如何高效地复用和更新 FiberNode。
前面我们提到:
在构建 workInProgress Fiber Tree 时会尝试复用 current Fiber Tree 中对应的 FiberNode 的数据,这个决定是否复用的过程就是 Diff 算法。
除了 workInProgress Fiber Tree
和 current Fiber Tree
的构建关系,我们还需要了解一个概念:JSX
,即类组件的 render 方法的返回结果,或函数组件的调用结果。JSX 对象中包含描述 DOM 节点
的信息。
Diff 算法的本质就是对比 current Fiber Tree 和 JSX 对象,生成 workInProgress Fiber Tree。
当组件的状态或者属性发生变化时,React 需要决定如何更新 DOM 来反映这些变化。Diff 算法就是用来决定哪些部分的 DOM 需要更新的算法。
Diff 算法的特点
Diff 算法具有以下特点:
-
分层,同级比较:React 将整个 DOM 树分为多个层级,然后逐层比较,只比较同一层级的节点,从而减少比较的复杂度。同级比较时按照从左到右的顺序进行比较。
-
元素类型对比: 两个不同类型的元素会生成不同的树,如果元素类型发生了变化,React 会销毁旧树并创建新树。
-
key 属性:React 使用 key 属性来标识节点的唯一性,从而在比较时能够快速定位到需要更新的节点。
关于 key
key 是 React 中用于标识节点的唯一性的一种机制。在 Diff 算法中,React 使用 key
属性来快速定位到需要更新的节点,从而提高 Diff 算法的性能。
我们经常强调在列表渲染中要使用 key 来提高性能,那么 key 到底是怎么帮助我们识别的呢?看一个简单的例子:
<div>
<p key="a">a</p>
<span key="b">b</span>
</div>
<div>
<span key="b">b</span>
<p key="a">a</p>
</div>
在上面的例子中,React 在比较两个 JSX 对象时,会按照从左到右的顺序进行比较。那么两个 JSX 在比较第一个子节点时,发现 p
和 span
的元素类型不同,因此会销毁旧树并创建新树。
但是由于他们有 key,React 会认为他们只是位置发生了变化,而不是元素类型发生了变化,因此会复用旧树中的节点,只是改变他们的位置。
Diff 算法的实现
Diff 算法在 React 中是通过 reconcileChildFibers
函数实现的,该函数会根据 current Fiber Node
和 JSX 对象
生成 workInProgress Fiber Node
。
在 reconcileChildFibers
函数中,React 会根据 current Fiber Node 和 JSX 对象的类型进行不同的处理:
- 当 current Fiber Node 和 JSX 对象的类型相同时,React 会递归地调用
reconcileChildFibers
函数来比较子节点,并生成对应的 workInProgress Fiber Node。如果子节点类型不同,React 会销毁旧树并创建新树。 - 当 current Fiber Node 和 JSX 对象的类型不同时,React 会销毁旧树并创建新树。
在比较子节点时,React 会使用 key 属性来标识节点的唯一性,从而快速定位到需要更新的节点。
看一下源码片段:
function reconcileChildFibers(returnFiber: Fiber, currentFirstChild: Fiber | null, newChild: any): Fiber | null {
// ...
// 处理对象类型的新子元素
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
// 处理单一的 React 元素
return placeSingleChild(reconcileSingleElement(returnFiber, currentFirstChild, newChild, lanes));
case REACT_PORTAL_TYPE:
// 处理 React portal
return placeSingleChild(reconcileSinglePortal(returnFiber, currentFirstChild, newChild, lanes));
case REACT_LAZY_TYPE:
// 处理懒加载的组件
const payload = newChild._payload;
const init = newChild._init;
return reconcileChildFibers(returnFiber, currentFirstChild, init(payload), lanes);
}
// 如果新子元素是一个数组,协调数组中的每个子元素。
if (isArray(newChild)) {
return reconcileChildrenArray(returnFiber, currentFirstChild, newChild, lanes);
}
// 如果新子元素是一个可迭代对象,协调迭代器中的每个子元素。
if (getIteratorFn(newChild)) {
return reconcileChildrenIterator(returnFiber, currentFirstChild, newChild, lanes);
}
// 如果新子元素是一个可迭代对象,协调迭代器中的每个子元素。
throwOnInvalidObjectType(returnFiber, newChild);
}
// 如果新子元素是一个非空字符串或数字,协调单个文本节点。
if ((typeof newChild === 'string' && newChild !== '') || typeof newChild === 'number') {
return placeSingleChild(reconcileSingleTextNode(returnFiber, currentFirstChild, '' + newChild, lanes));
}
//...
// 如果新子元素是 null 或 undefined,删除当前的所有子节点。
return deleteRemainingChildren(returnFiber, currentFirstChild);
}
// ...
Diff 的流程
React 的 diff 算法分为两轮遍历:
第一轮遍历,处理可复用的节点。
第二轮遍历,遍历第一轮剩下的 fiber。
第一轮遍历
第一轮遍历的三种情况:
- 如果新旧子节点的 key 和 type 都相同,说明可以复用。
- 如果新旧子节点的 key 相同,但是 type 不相同,这个时候就会根据
ReactElement
来生成一个全新的 fiber,旧的 fiber 被放入到deletions
数组里面,回头统一删除。但是此时遍历并不会终止。 - 如果新旧子节点的 key 和 type 都不相同,结束遍历。
示例:
更新前
<ul>
<li key="a">a</li>
<li key="b">b</li>
<li key="c">c</li>
<li key="d">d</li>
</ul>
更新后
<ul>
<li key="a">a</li>
<li key="b">b</li>
<li key="c2">c2</li>
<li key="d">d</li>
</ul>
以上结构,经过前面 Fiber 的学习,我们可以知道结构是这样的:
为了方便我们看同级的比较,ul
部分我们暂时省略。
经过前面对 fiber 双缓冲的学习,我们知道目前可以看到的这些是 current fiber
,而我们要通过对比创建workInProgress Fiber
。下面就是对比的过程:
遍历到第一个 li
时,发现 key
相同,type
相同,可以复用。
关于 alternate,是用来关联 wip Fiber Node 和 currentFiber Node 的,可以参考前面 fiber 的学习。
遍历到第二个 li
时,也可以复用。
遍历到第三个 li
时,发现 key
不同,结束遍历。
第二轮遍历
第一轮遍历结束后,如果有节点没有遍历到,那么就会进入第二轮遍历。
还是以刚才的例子为例,第一轮遍历结束后,还剩下两个li
。第二轮遍历中,首先会将剩余的旧的 FiberNode 放入到一个 map
里:
接下来会继续去遍历剩下的 JSX 对象数组
,遍历的同时,从 map
里面去找有没有能够复用的。如果找到了就移动过来。如果在 map
里面没有找到,那就会新增这个 FiberNode:
如果整个 JSX 对象数组
遍历完成后,map 里面还有剩余的 FiberNode,说明这些 FiberNode 是无法进行复用,就将这些 Fiber 节点添加到 deletions 数组
里面,之后统一删除。
第二个示例
前面例子比较简单,可以对照以上流程再看一个示例。
更新前:
<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="e">e</li>
<li key="f">f</li>
<li key="c">c</li>
</ul>
第一轮遍历和前面示例一样,第一个 li:a
和第二个 li: b
的 key 和 type 都相同,可以复用。遍历到第三个 li
时,发现 key 不同,结束遍历。
第二轮遍历:
剩余的旧的 FiberNode 放入到一个 map 里:
继续遍历,从 map 里面去找有 key 为 e, type 为 li 的,找到了,移过来复用:
map 中没有 li.f
,新增:
map 中有 li.c
,复用:
JSX 数组遍历完成,map 中还剩下 li.d
:
这个 FiberNode 无法复用,添加到 deletions
数组中,之后删除。
Diff 算法的优化
为了提高 Diff 算法的性能,React 在实现时做了一些优化:
- 避免不必要的比较:React 在比较同级节点时,会按照从左到右的顺序进行比较,从而避免出现跨层级的节点移动问题。
- 使用 key 属性:React 使用 key 属性来标识节点的唯一性,从而在比较时能够快速定位到需要更新的节点。
- 批量更新:React 在更新 DOM 时,会将多个更新操作合并为一个,从而减少 DOM 操作的次数。