前言
渲染器的核心就是 Diff 算法。简单来说,当新旧 vnode 的子节点都是一组节点时,为了以最小的性能开销完成更新操作,需要比较两组子节点,用于比较的算法就叫作 Diff 算法。我们知道,操作 DOM 的性能开销通常比较大,而渲染器的核心 Diff 算法就是为了解决这个问题而诞生的。
减少 DOM 操作的性能开销
核心 Diff 只关心新旧虚拟节点都存在一组子节点的情况。针对两组子节点的更新,我们之前采用了一种简单直接的手段,即卸载全部旧子节点,再挂载全部新子节点。这么做的确可以完成更新,但由于没有复用任何 DOM 元素,所以会产生极大的性能开销。
// 旧 vnode
const oldNode = {
type: 'div',
children: [
{ type: 'p', children: '1' },
{ type: 'p', children: '2' },
{ type: 'p', children: '3' }
]
}
// 新 vnode
const newNode = {
type: 'div',
children: [
{ type: 'p', children: '4' },
{ type: 'p', children: '5' },
{ type: 'p', children: '6' }
]
}
按照之前的做法,当更新子节点时,我们需要执行 6 次 DOM 操作:
- 卸载所有旧子节点,需要 3 次 DOM 删除操作;
- 挂载所有新子节点,需要 3 次 DOM 添加操作。
但是,通过观察上面新旧 vnode 的子节点,可以发现:更新前后的所有子节点都是 p 标签,即标签元素不变;只有 p 标签的子节点(文本节点)会发生变化。
例如,oldVNode 的第一个子节点是一个 p 标签,且该 p 标签的子节点类型是文本节点,内容是 ‘1’。而 newVNode 的第一个子节点也是一个 p 标签,它的子节点的类型也是文本节点,内容是 ‘4’。可以发现,更新前后改变的只有 p 标签文本节点的内容。
所以,最理想的更新方式是,直接更新这个 p 标签的文本节点的内容。这样只需要一次 DOM 操作,即可完成一个 p 标签更新。新旧虚拟节点都有 3 个 p标签作为子节点,所以一共只需要 3 次 DOM 操作就可以完成全部节点的更新。相比原来需要执行 6 次 DOM 操作才能完成更新的方式,其性能提升了一倍。
按照这个思路,我们可以重新实现两组子节点的更新逻辑,如下面 patchChildren 函数的代码所示:
function patchChildren(n1, n2, container) {
if (typeof n2.children === 'string') {
// 省略部分代码
} else if (Array.isArray(n2.children)) {
// 重新实现两组子节点的更新方式
// 新旧 children
const oldChildren = n1.children
const newChildren = n2.children
// 遍历旧的 children
for (let i = 0; i < oldChildren.length; i++) {
// 调用 patch 函数逐个更新子节点
patch(oldChildren[i], newChildren[i])
}
} else {
// 省略部分代码
}
}
在这段代码中,oldChildren 和 newChildren 分别是旧的一组子节点和新的一组子节点。我们遍历前者,并将两者中对应位置的节点分别传递给 patch 函数进行更新。patch 函数在执行更新时,发现新旧子节点只有文本内容不同,因此只会更新其文本节点的内容。这样,我们就成功地将 6 次 DOM 操作减少为 3 次。其中菱形代表新子节点,矩形代表旧子节点,圆形代表真实 DOM 节点。

这种做法虽然能够减少 DOM 操作次数,但问题也很明显。在上面的代码中,我们通过遍历旧的一组子节点,并假设新的一组子节点的数量与之相同,只有在这种情况下,这段代码才能正确地工作。但是,新旧两组子节点的数量未必相同。当新的一组子节点的数量少于旧的一组子节点的数量时,意味着有些节点在更新后应该被卸载。

評論