狗教我React——原理篇之Diff算法

3,575 阅读7分钟

前置文章:

  1. 狗教我 React——原理篇之 React 整体架构解读 - 掘金 (juejin.cn)
  2. 狗教我 React——原理篇之整体渲染流程 - 掘金 (juejin.cn)
  3. 狗教我React——原理篇之Fiber和Fiber双缓冲 - 掘金 (juejin.cn)

image.png

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 需要更新的算法。

image.png

Diff 算法的特点

Diff 算法具有以下特点:

  1. 分层,同级比较:React 将整个 DOM 树分为多个层级,然后逐层比较,只比较同一层级的节点,从而减少比较的复杂度。同级比较时按照从左到右的顺序进行比较。

  2. 元素类型对比: 两个不同类型的元素会生成不同的树,如果元素类型发生了变化,React 会销毁旧树并创建新树。

  3. 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 对象的类型进行不同的处理:

  1. 当 current Fiber Node 和 JSX 对象的类型相同时,React 会递归地调用 reconcileChildFibers 函数来比较子节点,并生成对应的 workInProgress Fiber Node。如果子节点类型不同,React 会销毁旧树并创建新树。
  2. 当 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。

第一轮遍历

第一轮遍历的三种情况:

  1. 如果新旧子节点的 key 和 type 都相同,说明可以复用。
  2. 如果新旧子节点的 key 相同,但是 type 不相同,这个时候就会根据 ReactElement 来生成一个全新的 fiber,旧的 fiber 被放入到 deletions 数组里面,回头统一删除。但是此时遍历并不会终止。
  3. 如果新旧子节点的 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 的学习,我们可以知道结构是这样的:

image.png

为了方便我们看同级的比较,ul部分我们暂时省略。 经过前面对 fiber 双缓冲的学习,我们知道目前可以看到的这些是 current fiber,而我们要通过对比创建workInProgress Fiber。下面就是对比的过程:

image.png

遍历到第一个 li 时,发现 key 相同,type 相同,可以复用。

关于 alternate,是用来关联 wip Fiber Node 和 currentFiber Node 的,可以参考前面Fiber 的学习。

image.png 遍历到第二个 li 时,也可以复用。

image.png 遍历到第三个 li 时,发现 key 不同,结束遍历。

第二轮遍历

第一轮遍历结束后,如果有节点没有遍历到,那么就会进入第二轮遍历。

还是以刚才的例子为例,第一轮遍历结束后,还剩下两个li。第二轮遍历中,首先会将剩余的旧的 FiberNode 放入到一个 map 里:

image.png

接下来会继续去遍历剩下的 JSX 对象数组 ,遍历的同时,从 map 里面去找有没有能够复用的。如果找到了就移动过来。如果在 map 里面没有找到,那就会新增这个 FiberNode:

image.png

image.png

如果整个 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 不同,结束遍历。

image.png

第二轮遍历:

剩余的旧的 FiberNode 放入到一个 map 里:

image.png

继续遍历,从 map 里面去找有 key 为 e, type 为 li 的,找到了,移过来复用:

image.png

map 中没有 li.f,新增:

image.png

map 中有 li.c,复用:

image.png

JSX 数组遍历完成,map 中还剩下 li.d:

image.png

这个 FiberNode 无法复用,添加到 deletions 数组中,之后删除。

Diff 算法的优化

为了提高 Diff 算法的性能,React 在实现时做了一些优化:

  1. 避免不必要的比较:React 在比较同级节点时,会按照从左到右的顺序进行比较,从而避免出现跨层级的节点移动问题。

  2. 使用 key 属性:React 使用 key 属性来标识节点的唯一性,从而在比较时能够快速定位到需要更新的节点。

  3. 批量更新:React 在更新 DOM 时,会将多个更新操作合并为一个,从而减少 DOM 操作的次数。


补充:从节点重排看React 与 Vue3 的 Diff 算法