渲染器的核心 Diff 算法

渲染器的核心 Diff 算法

最近在深入学习 Virtual DOM 的相关知识,参考了许多资料,也拜读了许多大神的文章,所以在这里大致的整理成了比较适合自己理解的方式,方便时不时回来翻翻,复习一下,篇幅较长,主要会分为三个部分来分别进行介绍,具体章节如下,目录名就差不多代表了章节的相关内容

在上一章当中,我们主要讨论了渲染器是如何更新各种类型的 VNode 的,本章是第三部分,也是最后一部分,主要介绍渲染器当中的核心,也就是传说中的 diff 算法,主要参考的是 HcySunYang/vue-design,本章相关内容如下

  • 减小 DOM 操作的性能开销
  • 尽可能的复用 DOM 元素
    • key 的作用
    • 找到需要移动的节点
    • 移动节点
    • 添加新元素
    • 移除不存在的元素
  • 双端比较算法
    • 非理想情况
    • 添加新元素
    • 移除不存在的元素
  • inferno 当中的 Diff 算法
    • 相同的前置和后置元素
    • 判断是否需要进行 DOM 移动
    • DOM 移动的方式

减小 DOM 操作的性能开销

在上一章当中,我们在遇到了旧的 children 和新的 children 均为多个子节点的情况,如下图

我们当时提到,只有当新旧子节点的类型都是多个子节点时,核心 diff 算法才派得上用场,并且当时我们采用了一种仅能实现目标但并不完美的算法,即遍历旧的子节点,将其全部移除,再遍历新的子节点,将其全部添加,虽然能够达到目的,但并非最佳处理方式,在正式展开之前,我们先来思考一下上面的算法存在的问题,可以简化为下图

简单来说就是遍历旧的 VNode,通过旧 VNode 对真实 DOM 的引用取得真实 DOM,即可将已渲染的 DOM 移除,接着遍历新的 VNode 并将其全部添加到页面中,我们可以先假定它们都是 <li> 标签,想象一下如果它们只是单纯的交换位置,或者只是简单的调整了一下包含的文本,我们是不是可以复用已有 DOM 元素呢?

实际上是可以的,我们在介绍 pathcElement 函数时了解到,当新旧 VNode 所描述的是相同标签时,那么这两个 VNode 之间的差异就仅存在于 VNodeDatachildren 上,所以我们完全可以通过遍历新旧 VNode,并一一比对它们,这样对于任何一个 DOM 元素来说,由于它们都是相同的标签,所以更新的过程是不会移除和新建任何 DOM 元素的,而是复用已有 DOM 元素,需要更新的只有 VNodeDatachildren,优化后的算法如下图所示

但是会发现有问题,如果新旧 children 的长度相同的话是可行的,但是如果不同呢,所以我们就可以大致的分为三种情况来单独的进行处理,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
function patchChildren(prevChildFlags, nextChildFlags, prevChildren, nextChildren, container) {
switch (prevChildFlags) {

// ...

// 旧的 children 中有多个子节点
default:
switch (nextChildFlags) {

// ...

default:
// 新的 children 中有多个子节点
// 获取公共长度,取新旧 children 长度较小的那一个
const prevLen = prevChildren.length
const nextLen = nextChildren.length
const commonLength = prevLen > nextLen ? nextLen : prevLen
for (let i = 0; i < commonLength; i++) {
patch(prevChildren[i], nextChildren[i], container)
}

// 如果 nextLen > prevLen,将多出来的元素添加
if (nextLen > prevLen) {
for (let i = commonLength; i < nextLen; i++) {
mount(nextChildren[i], container)
}
// 如果 prevLen > nextLen,将多出来的元素移除
} else if (prevLen > nextLen) {
for (let i = commonLength; i < prevLen; i++) {
container.removeChild(prevChildren[i].el)
}
}
break
}
break
}
}

实际上,这个算法就是在没有 key 时所采用的算法,该算法是存在优化空间的,下面我们将分析如何进一步优化

尽可能的复用 DOM 元素

在上一小节中,我们通过减少 DOM 操作的次数使得更新的性能得到了提升,但它仍然存在可优化的空间,我们假设新旧 children 如下

1
2
3
4
5
6
7
8
9
10
11
12
13
// 旧的 children
[
h('li', null, 1),
h('li', null, 2),
h('li', null, 3)
]

// 新的 children
[
h('li', null, 2),
h('li', null, 3),
h('li', null, 4)
]

如果还是使用我们之前的算法,patch 函数知道它们是相同的标签,所以只会更新 VNodeData 和子节点,由于这两个标签都没有 VNodeData,所以只需要更新它们的子节点,它会依次进行比对,然后调用 patchText 方法来更新文本子节,比如在上面的示例当中,它会执行三次,实际上我们通过观察可以很明显的发现,最佳的操作应该是『通过移动元素的位置来达到更新的目的』,那么应该如何移动元素来完成更新呢?

key 的作用

所以,我们需要在新旧 children 的节点中保存映射关系,以便我们能够在旧 children 的节点中找到可复用的节点,这时候我们就需要给 children 中的节点添加唯一标识,也就是我们常说的 key,有了 key 我们就能够明确的知道新旧 children 中节点的映射关系,比如下面这个例子

知道了映射关系,我们就很容易判断新 children 中的节点是否可被复用,我们只需要遍历新 children 中的每一个节点,并去旧 children 中寻找是否存在具有相同 key 值的节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 遍历新的 children
for (let i = 0; i < nextChildren.length; i++) {
const nextVNode = nextChildren[i]
let j = 0
// 遍历旧的 children
for (j; j < prevChildren.length; j++) {
const prevVNode = prevChildren[j]
// 如果找到了具有相同 key 值的两个节点,则调用 patch 函数更新之
if (nextVNode.key === prevVNode.key) {
patch(prevVNode, nextVNode, container)
break // 这里需要 break
}
}
}

找到需要移动的节点

我们已经找到了可复用的节点,并进行了合适的更新操作,下一步需要做的,就是判断一个节点是否需要移动以及如何移动

我们可以先来看看不需要移动的情况,如上图,我们假定以上面的算法来执行,流程是下面这样的

  1. 取出新 children 的第一个节点,并尝试在旧 children 中寻找 li-a,找到了,索引为 0
  2. 取出新 children 的第二个节点,并尝试在旧 children 中寻找 li-b,找到了,索引为 1
  3. 取出新 children 的第三个节点,并尝试在旧 children 中寻找 li-c,找到了,索引为 2

可以发现,我们先后遇到的索引顺序依次为 0 ==> 1 ==> 2,这是一个递增的顺序,这就说明

  • 如果在寻找的过程中遇到的索引呈现递增趋势,则说明新旧 children 中节点顺序相同,不需要移动操作
  • 相反的,如果在寻找的过程中遇到的索引值不呈现递增趋势,则说明需要移动操作

下面再来看一个例子

还是按照我们上面的流程,来看一看执行结果

  1. 取出新 children 的第一个节点,并尝试在旧 children 中寻找 li-c,找到了,索引为 2
  2. 取出新 children 的第二个节点,并尝试在旧 children 中寻找 li-a,找到了,索引为 0
  3. 取出新 children 的第三个节点,并尝试在旧 children 中寻找 li-b,找到了,索引为 1

这次可以发现,递增的趋势被打破了,索引顺序依次为 2 ==> 0 ==> 1

在第二次查找过程中我们遇到了 0 < 2 的情况,这说明在旧 childrenli-a 的位置要比 li-c 靠前,但在新的 childrenli-a 的位置要比 li-c 靠后,所以得知 li-a 是需要被移动的

在第三次查找过程中,1 也是小于 2 的,这说明在旧 children 中节点 li-b 的位置也要比 li-c 的位置靠前,但在新的 childrenli-b 的位置要比 li-c 靠后,所以 li-b 也需要被移动

在这个过程当中可以发现,因为有 2 这个数字的存在才使得我们能够知道哪些节点需要移动,我们可以把它称为,寻找过程中在旧 children 中所遇到的最大索引值

如果在后续寻找的过程中发现存在索引值比最大索引值小的节点,意味着该节点需要被移动

实际上,这就是 React 所使用的算法,我们可以使用一个叫做 lastIndex 的变量存储寻找过程中遇到的最大索引值,并且它的初始值为 0

移动节点

现在我们已经有办法找到需要移动的节点了,接下应该如何移动这些节点?先还是按照之前的流程来运行一下我们之前的示例

  • children 中的第一个节点是 li-c,它在旧 children 中的索引为 2,由于 li-c 是新 children 中的第一个节点,所以它始终都是不需要移动的,只需要调用 patch 函数更新即可

这里有一个需要注意的地方,即新 children 中的 li-c 节点在经过 patch 函数之后,也将存在对真实 DOM 元素的引用(因为我们在 patchElement 函数当中已经让 nextVNode.el 也引用了该元素)

  • children 中的第二个节点 li-a,它在旧 children 中的索引是 0,由于 0 < 2 所以 li-a 是需要移动的节点,所以我们需要做的就是把 li-a 节点对应的『真实』DOM 移动到 li-c 节点所对应『真实』DOM 的后面
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 用来存储寻找过程中遇到的最大索引值
let lastIndex = 0
// 遍历新的 children
for (let i = 0; i < nextChildren.length; i++) {
const nextVNode = nextChildren[i]
let j = 0
// 遍历旧的 children
for (j; j < prevChildren.length; j++) {
const prevVNode = prevChildren[j]
// 如果找到了具有相同 key 值的两个节点,则调用 `patch` 函数更新之
if (nextVNode.key === prevVNode.key) {
patch(prevVNode, nextVNode, container)
if (j < lastIndex) {
/**
* 假设我们当前正在更新的节点是 li-a,那么如上代码中的变量 i 就是节点 li-a 在新 children 中的位置索引
* 所以 nextChildren[i - 1] 就是 li-a 节点的前一个节点,也就是 li-c 节点,由于 li-c 节点存在对真实 DOM 的引用
* 所以我们可以通过其 el 属性拿到真实 DOM
*
* 我们的目标是把 li-a 节点对应的真实 DOM 移动到 li-c 节点所对应真实 DOM 的后面
* 所以我们的思路应该是想办法拿到 li-c 节点对应真实 DOM 的下一个兄弟节点
* 并把 li-a 节点所对应真实 DOM 插到该节点的前面
*
* 所以 refNode 引用是 li-c 节点对应真实 DOM 的下一个兄弟节点
* 拿到了正确的 refNode 之后,我们就可以调用容器元素的 insertBefore 方法来完成 DOM 的移动了
* 移动的对象就是 li-a 节点所对应的真实 DOM,由于当前正在处理的就是 li-a 节点
* 所以 prevVNode 就是旧 children 中的 li-a 节点,它是存在对真实 DOM 的引用的,即 prevVNode.el
*/
// 需要移动
// refNode 是为了下面调用 insertBefore 函数准备的
const refNode = nextChildren[i - 1].el.nextSibling
// 调用 insertBefore 函数移动 DOM
container.insertBefore(prevVNode.el, refNode)
} else {
// 更新 lastIndex
lastIndex = j
}
break // 这里需要 break
}
}
}

我们可以用下图来描述这个过程

添加新元素

我们之前考虑的只是一种情况,如果在新 children 中包含了一个全新的节点,这意味着在旧 children 中是找不到该节点的,针对这种情况我们需要单独处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
let lastIndex = 0
for (let i = 0; i < nextChildren.length; i++) {
const nextVNode = nextChildren[i]
/**
* 我们在原来的基础上添加了变量 find,它将作为一个标志,代表新 children 中的节点是否存在于旧 children 中,初始值为 false
* 一旦在旧 children 中寻找到了相应的节点,我们就将变量 find 的值设置为 true
* 所以如果内层循环结束后,变量 find 的值仍然为 false,则说明在旧的 children 中找不到可复用的节点
* 这时我们就需要使用 mount 函数将当前遍历到的节点挂载到容器元素
*/
let j = 0, find = false
for (j; j < prevChildren.length; j++) {
const prevVNode = prevChildren[j]
if (nextVNode.key === prevVNode.key) {
find = true
patch(prevVNode, nextVNode, container)
if (j < lastIndex) {
// 需要移动
const refNode = nextChildren[i - 1].el.nextSibling
container.insertBefore(prevVNode.el, refNode)
break
} else {
// 更新 lastIndex
lastIndex = j
}
}
}
/**
* 我们应该按照节点在新的 children 中的位置将其添加到正确的地方
* 所以我们先找到当前遍历到的节点的前一个节点,即 nextChildren[i - 1]
* 接着找到该节点所对应真实 DOM 的下一个子节点作为 refNode,即 nextChildren[i - 1].el.nextSibling
* 但是由于当前遍历到的节点有可能是新 children 的第一个节点,这时 i - 1 < 0,这将导致 nextChildren[i - 1] 不存在
* 所以当 i - 1 < 0 时,我们只需要把新的节点插入到最前面即可(第一个节点),所以我们使用 prevChildren[0].el 作为 refNode
*
* 最后调用 mount 函数挂载新节点时,我们为其传递了第四个参数 refNode(这就是第四个参数的作用)
* 即当 refNode 存在时,我们应该使用 insertBefore 方法代替 appendChild 方法
*/
if (!find) {
// 挂载新节点
// 找到 refNode
const refNode =
i - 1 < 0
? prevChildren[0].el
: nextChildren[i - 1].el.nextSibling
mount(nextVNode, container, false, refNode)
}
}

移除不存在的元素

当然,除了要将全新的节点添加到容器元素之外,我们还应该把已经不存在了的节点移除

我们需要在外层循环结束之后,再优先遍历一次旧的 children,并尝试拿着旧 children 中的节点去新 children 中寻找相同的节点,如果找不到则说明该节点已经不存在于新 children 中了,这时我们应该将该节点对应的真实 DOM 移除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let lastIndex = 0
for (let i = 0; i < nextChildren.length; i++) {
// ... 省略(见上方)
}
// 移除已经不存在的节点
// 遍历旧的节点
for (let i = 0; i < prevChildren.length; i++) {
const prevVNode = prevChildren[i]
// 拿着旧 VNode 去新 children 中寻找相同的节点
const has = nextChildren.find(
nextVNode => nextVNode.key === prevVNode.key
)
if (!has) {
// 如果没有找到相同的节点,则移除
container.removeChild(prevVNode.el)
}
}

至此,第一种算法我们算是介绍完毕了,这个也就是 React 所采用的 diff 算法,不过还有可以优化的空间,我们后面在进行介绍

双端比较算法

之前提到 Reactdiff 算法是存在优化空间的,想要要找到优化的关键点,我们首先要知道它存在什么问题,来看下图

我们可以通过观察可以发现,其实最优的解决方案应该是把 li-c 节点对应的真实 DOM 移动到最前面即可,只需要一次移动即可完成更新,然而 React 所采用的 diff 算法在更新如上案例的时候,会进行两次移动

所以在这种情况下,我们就可以采用一种新的思路,即『双端比较』,所谓双端比较,就是同时从新旧 children 的两端开始进行比较的一种方式,所以我们需要四个索引值,分别指向新旧 children 的两端,如下图所示

我们使用四个变量 oldStartIdxoldEndIdxnewStartIdx 以及 newEndIdx 分别存储旧 children 和新 children 的两个端点的位置索引,除了位置索引之外,我们还需要拿到四个位置索引所指向的 VNode,用代码表示如下

1
2
3
4
5
6
7
8
9
let oldStartIdx = 0
let oldEndIdx = prevChildren.length - 1
let newStartIdx = 0
let newEndIdx = nextChildren.length - 1

let oldStartVNode = prevChildren[oldStartIdx]
let oldEndVNode = prevChildren[oldEndIdx]
let newStartVNode = nextChildren[newStartIdx]
let newEndVNode = nextChildren[newEndIdx]

在一次比较过程中,最多需要进行四次比较

  1. 使用旧 children 的头一个 VNode 与新 children 的头一个 VNode 比对,即 oldStartVNodenewStartVNode 比较对
  2. 使用旧 children 的最后一个 VNode 与新 children 的最后一个 VNode 比对,即 oldEndVNodenewEndVNode 比对
  3. 使用旧 children 的头一个 VNode 与新 children 的最后一个 VNode 比对,即 oldStartVNodenewEndVNode 比对
  4. 使用旧 children 的最后一个 VNode 与新 children 的头一个 VNode 比对,即 oldEndVNodenewStartVNode 比对

可以用下图来描述在一次比对过程中的四个步骤

下面是对该过程的实现,其实简单来说就是上述四个步骤分别对应着不同的结果,我们只需要针对结果进行不同的处理即可

1
2
3
4
5
6
7
8
9
if (oldStartVNode.key === newStartVNode.key) {
// 步骤一:oldStartVNode 和 newStartVNode 比对
} else if (oldEndVNode.key === newEndVNode.key) {
// 步骤二:oldEndVNode 和 newEndVNode 比对
} else if (oldStartVNode.key === newEndVNode.key) {
// 步骤三:oldStartVNode 和 newEndVNode 比对
} else if (oldEndVNode.key === newStartVNode.key) {
// 步骤四:oldEndVNode 和 newStartVNode 比对
}

每次比对完成之后,如果在某一步骤中找到了可复用的节点,我们就需要将相应的位置索引后移或者前移一位,以上图为例

  1. 拿旧 children 中的 li-a 和新 children 中的 li-d 进行比对,由于二者 key 值不同,所以不可复用,什么都不做
  2. 拿旧 children 中的 li-d 和新 children 中的 li-c 进行比对,同样不可复用,什么都不做
  3. 拿旧 children 中的 li-a 和新 children 中的 li-c 进行比对,什么都不做
  4. 拿旧 children 中的 li-d 和新 children 中的 li-d 进行比对,由于这两个节点拥有相同的 key 值,所以我们在这次比对的过程中找到了可复用的节点

oldEndVNodenewStartVNode 拥有相同的 key 值,这说明 li-d 节点所对应的真实 DOM 原本是最后一个子节点,并且更新之后它应该变成第一个子节点,所以我们需要把 li-d 所对应的真实 DOM 移动到最前方即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if (oldStartVNode.key === newStartVNode.key) {
// 步骤一:oldStartVNode 和 newStartVNode 比对
} else if (oldEndVNode.key === newEndVNode.key) {
// 步骤二:oldEndVNode 和 newEndVNode 比对
} else if (oldStartVNode.key === newEndVNode.key) {
// 步骤三:oldStartVNode 和 newEndVNode 比对
} else if (oldEndVNode.key === newStartVNode.key) {
// 步骤四:oldEndVNode 和 newStartVNode 比对

// 先调用 patch 函数完成更新
patch(oldEndVNode, newStartVNode, container)
// 更新完成后,将容器中最后一个子节点移动到最前面,使其成为第一个子节点
container.insertBefore(oldEndVNode.el, oldStartVNode.el)
// 更新索引,指向下一个位置
oldEndVNode = prevChildren[--oldEndIdx]
newStartVNode = nextChildren[++newStartIdx]
}

这一步更新完成之后,新的索引关系可以用下图来表示

这样,一次比对就完成了,并且位置索引已经更新,那么什么时候比对才能结束呢?如下

1
2
3
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// ...
}

我们将每一轮比对所做的工作封装到一个 while 循环内,循环结束的条件是要么 oldStartIdx 大于 oldEndIdx,要么 newStartIdx 大于 newEndIdx,如此往复下去,流程如下图所示

最后得到的结果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVNode.key === newStartVNode.key) {
/**
* 当 oldStart === newStart 的时候
* 由于该节点无论是在新 children 中还是旧 children 中,都是当前索引范围内的第一个节点,所以位置不需要变化
* 即不需要移动操作,只需要调用 patch 函数更新即可,同时也要将相应的位置所以下移一位
*/
patch(oldStartVNode, newStartVNode, container)
oldStartVNode = prevChildren[++oldStartIdx]
newStartVNode = nextChildren[++newStartIdx]
} else if (oldEndVNode.key === newEndVNode.key) {
/**
* 当 oldEnd === newEnd 的时候
* 说明二者在新旧 children 中都是最末尾的一个节点
* 所以是不需要进行移动操作的,只需要调用 patch 函数更新即可,同时将相应的索引前移一位
*/
patch(oldEndVNode, newEndVNode, container)
oldEndVNode = prevChildren[--oldEndIdx]
newEndVNode = newEndVNode[--newEndIdx]
} else if (oldStartVNode.key === newEndVNode.key) {
/**
* 当 oldStart === newEnd 的时候
* 说明该节点对应的真实 DOM 原本是第一个子节点,但现在变成了当前索引范围内的最后一个节点
* 所以移动操作也是比较明显的,我们将 oldStartVNode 对应的真实 DOM 移动到 oldEndVNode 所对应真实 DOM 的后面即可
*/
patch(oldStartVNode, newEndVNode, container)
container.insertBefore(
oldStartVNode.el,
oldEndVNode.el.nextSibling
)
oldStartVNode = prevChildren[++oldStartIdx]
newEndVNode = nextChildren[--newEndIdx]
} else if (oldEndVNode.key === newStartVNode.key) {
/**
* 当 newStart === oldEnd 的时候
* 说明该节点所对应的真实 DOM 原本是最后一个子节点,并且更新之后它应该变成第一个子节点
* 所以我们需要把该节点所对应的真实 DOM 移动到最前方即可,所以我们的操作如下
*
* 1. 先调用 patch 函数完成更新
* 2. 更新完成后,将容器中最后一个子节点移动到最前面,使其成为第一个子节点
* 3. 更新索引,指向下一个位置
*/
patch(oldEndVNode, newStartVNode, container)
container.insertBefore(oldEndVNode.el, oldStartVNode.el)
oldEndVNode = prevChildren[--oldEndIdx]
newStartVNode = nextChildren[++newStartIdx]
}
}

如上,在经过循环对比之后,我们可以发现真实的 DOM 的顺序已经与新 children 中节点的顺序保持一致了,也就是说我们圆满的完成了目标,另外观察上述流程可以发现,此时 oldStartIdxnewStartIdx 分别比 oldEndIdxnewEndIdx 要大,所以这将是最后一轮的比对,循环将终止,以上就是双端比较的核心原理

非理想情况

在之前的讲解中,我们所采用的是较理想的例子,换句话说,在每一轮的比对过程中,总会满足四个步骤中的一步,但实际上大多数情况下并不会这么理想,如下图所示,在上面的每一步对比当中,都无法找到可复用的节点,在这种情况下,我们只能拿新 children 中的第一个节点尝试去旧 children 中寻找,试图找到拥有相同 key 值的节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVNode.key === newStartVNode.key) {
// ...
} else if (oldEndVNode.key === newEndVNode.key) {
// ...
} else if (oldStartVNode.key === newEndVNode.key) {
// ...
} else if (oldEndVNode.key === newStartVNode.key) {
// ...
} else {
// 遍历旧 children,试图寻找与 newStartVNode 拥有相同 key 值的元素
const idxInOld = prevChildren.findIndex(
node => node.key === newStartVNode.key
)
}
}

这里的关键点并不在于我们找到了位置索引,而是要明白,当我们在旧的 children 中找到了与新 children 中第一个节点拥有相同 key 值的节点,这就意味着旧 children 中的这个节点所对应的真实 DOM 在新 children 的顺序中,已经变成了第一个节点,所以我们需要把该节点所对应的真实 DOM 移动到最前头

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVNode.key === newStartVNode.key) {
// ...
} else if (oldEndVNode.key === newEndVNode.key) {
// ...
} else if (oldStartVNode.key === newEndVNode.key) {
// ...
} else if (oldEndVNode.key === newStartVNode.key) {
// ...
} else {
// 遍历旧 children,试图寻找与 newStartVNode 拥有相同 key 值的元素
const idxInOld = prevChildren.findIndex(
node => node.key === newStartVNode.key
)
if (idxInOld >= 0) {
// vnodeToMove 就是在旧 children 中找到的节点,该节点所对应的真实 DOM 应该被移动到最前面
const vnodeToMove = prevChildren[idxInOld]
// 调用 patch 函数完成更新
patch(vnodeToMove, newStartVNode, container)
// 把 vnodeToMove.el 移动到最前面,即 oldStartVNode.el 的前面
container.insertBefore(vnodeToMove.el, oldStartVNode.el)
// 由于旧 children 中该位置的节点所对应的真实 DOM 已经被移动,所以将其设置为 undefined(这是很关键的一步)
prevChildren[idxInOld] = undefined
}
// 将 newStartIdx 下移一位,准备进行下一轮的比较
newStartVNode = nextChildren[++newStartIdx]
}
}

我们用一张图来描述这个过程结束之后的状态

由于原本旧 children 中的 li-b 节点,此时已经变成了 undefined,所以在后续的比对过程中 oldStartIdxoldEndIdx 二者当中总会有一个位置索引优先达到这个位置,也就是说此时 oldStartVNodeoldEndVNode 两者之一可能是 undefined,这说明该位置的元素在之前的比对中被移动到别的位置了,所以不再需要处理该位置的节点,这时我们需要跳过这一位置

1
2
3
4
5
6
7
8
9
10
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (!oldStartVNode) {
oldStartVNode = prevChildren[++oldStartIdx]
} else if (!oldEndVNode) {
oldEndVNode = prevChildren[--oldEndIdx]
} else if (oldStartVNode.key === newStartVNode.key) {
// ...
}
// ...
}

oldStartVNodeoldEndVNode 不存在时,说明该节点已经被移动了,我们只需要跳过该位置即可,以上就是我们所说的双端比较的非理想情况的处理方式

添加新元素

在上一小节中,我们尝试拿着新 children 中的第一个节点去旧 children 中寻找与之拥有相同 key 值的可复用节点,然后并非总是能够找得到,当新的 children 中拥有全新的节点时,就会出现找不到的情况

由于 li-d 节点的位置索引是 newStartIdx,这说明 li-d 节点是当前这一轮比较中的当前索引范围内的第一个节点,所以只要把它挂载到位于 oldStartIdx 位置的节点所对应的真实 DOM 前面就可以了,即 oldStartVNode.el,只需要增加一行代码即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (!oldStartVNode) {
oldStartVNode = prevChildren[++oldStartIdx]
}

// ...

else {
if (idxInOld >= 0) {
// ...
} else {
// 使用 mount 函数挂载新节点
mount(newStartVNode, container, false, oldStartVNode.el)
}
newStartVNode = nextChildren[++newStartIdx]
}
}

我们再来看下面这种情况

在上图最后一种情况当中,此时 oldEndIdx 的值将变成 -1,它要小于 oldStartIdx 的值,这时循环的条件不在满足,意味着更新完成,然而通过上图可以很容易的发现 li-d 节点被遗漏了,所以我们需要在循环终止之后,对 oldEndIdxoldStartIdx 的值再次进行检查

如果在循环结束之后 oldEndIdx 的值小于 oldStartIdx 的值则说明新的 children 中『存在还没有被处理的全新节点』,这时我们应该调用 mount 函数将其挂载到容器元素中

1
2
3
4
5
6
7
8
9
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// ...
}
if (oldEndIdx < oldStartIdx) {
// 添加新节点
for (let i = newStartIdx; i <= newEndIdx; i++) {
mount(nextChildren[i], container, false, oldStartVNode.el)
}
}

这样我们就完整的实现了完整的添加新节点的功能

移除不存在的元素

对于双端比较,最后一个需要考虑的情况就是当有元素被移除时的情况

通过上图可以发现,在循环结束之后,并不满足条件 oldEndIdx < oldStartIdx 而是满足条件 newEndIdx < newStartIdx,基于此,我们可以认为循环结束后,一旦满足条件 newEndIdx < newStartId 则说明有元素需要被移除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 省略...
}
if (oldEndIdx < oldStartIdx) {
// 添加新节点
for (let i = newStartIdx; i <= newEndIdx; i++) {
mount(nextChildren[i], container, false, oldStartVNode.el)
}
} else if (newEndIdx < newStartIdx) {
// 移除操作
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
container.removeChild(prevChildren[i].el)
}
}

以上就是相对完整的双端比较算法的实现,这是 Vue2 所采用的算法,借鉴于开源项目 snabbdom

inferno 当中的 Diff 算法

Vue3 中采用了另外一种 diff 算法,它借鉴于 iviinferno,在 DOM 操作的各个方面,iviinferno 都要稍优于 vue2 的双端比较,例如在创建 VNode 时就确定其类型,以及在 mount/patch 的过程中采用位运算来判断一个 VNode 的类型,在这个基础之上再配合核心的 diff 算法,才使得性能上产生一定的优势,本节我们就来看看这个算法的实现原理

相同的前置和后置元素

实际上本节介绍的 diff 算法最早应用于两个不同文本之间的差异比较,在真正进行核心的 diff 算法之前,会有一个预处理的过程,例如可以先对两个文本进行类似相等的比较,比如我们有两段文本,如下

1
2
text1: I use vue for app development
text2: I use react for app development

我们通过肉眼可以很容易的发现,这两段文本头部和尾部分别有一段相同的文本,所以真正需要进行 diff 的部分就变成了

1
2
text1: vue
text2: react

这么做的好处是:在某些情况下,我们能够轻松的判断出单独的文本插入和删除,比如

1
2
3
4
5
6
7
8
text1: I like react
text2: I like react too


// ==> 实际上

text1:
text2: too

又或者是下面这种

1
2
3
4
5
6
7
8
text1: I like react too
text2: I like react


// ==> 实际上

text1: too
text2:

很显然,该预处理过程在上例的情况下能够避免 diff 算法的执行,从而提高 diff 效率,我们来尝试着将其应用到我们 VNodediff 中,总共分为四个阶段,流程如下

如上图第一部分所示,新旧 children 拥有相同的前缀节点和后缀节点,对于前缀节点,我们可以建立一个索引,指向新旧 children 中的第一个节点,并逐步向后遍历,直到遇到两个拥有不同 key 值的节点为止

1
2
3
4
5
6
7
8
9
10
11
12
13
// 更新相同的前缀节点
// j 为指向新旧 children 中第一个节点的索引
let j = 0
let prevVNode = prevChildren[j]
let nextVNode = nextChildren[j]
// while 循环向后遍历,直到遇到拥有不同 key 值的节点为止
while (prevVNode.key === nextVNode.key) {
// 调用 patch 函数更新
patch(prevVNode, nextVNode, container)
j++
prevVNode = prevChildren[j]
nextVNode = nextChildren[j]
}

操作完成的状态如上图第二部分所示,这里有一个需要注意的地方就是,当 while 循环终止时,索引 j 的值为 1,接着我们需要处理的是相同的后缀节点,由于新旧 children 中节点的数量可能不同,所以我们需要两个索引分别指向新旧 children 的最后一个节点,并逐步向前遍历,直到遇到两个拥有不同 key 值的节点为止

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 更新相同的后缀节点

// 指向旧 children 最后一个节点的索引
let prevEnd = prevChildren.length - 1
// 指向新 children 最后一个节点的索引
let nextEnd = nextChildren.length - 1

prevVNode = prevChildren[prevEnd]
nextVNode = nextChildren[nextEnd]

// while 循环向前遍历,直到遇到拥有不同 key 值的节点为止
while (prevVNode.key === nextVNode.key) {
// 调用 patch 函数更新
patch(prevVNode, nextVNode, container)
prevEnd--
nextEnd--
prevVNode = prevChildren[prevEnd]
nextVNode = nextChildren[nextEnd]
}

该步操作完成以后为上图第三部分所示,同样需要注意的是,在这一步更新完成之后 prevEnd 的值为 0nextEnd 的值为 1,现在,我们就着重的来看看这三个字段的值

1
2
3
j: 1
prevEnd: 0
nextEnd: 1

我们发现在这种情况下 j > prevEnd 并且 j <= nextEnd,这说明当新旧 children 中相同的前缀和后缀被更新之后,旧 children 中的节点已经被更新完毕了,而新 children 中仍然有剩余节点

实际上新 children 中位于 jnextEnd 之间的所有节点都应该是新插入的节点(上图最后一部分),观察得知,新的节点都出现在 li-b 节点的前面,而我们又可以使用 nextEnd + 1 来表示 li-b 节点的位置,所以我们可以使用一个循环遍历索引 j -> nextEnd 之间的节点,并逐个将其插入到 li-b 节点之前即可

1
2
3
4
5
6
7
8
9
10
11
// 满足条件,则说明从 j -> nextEnd 之间的节点应作为新节点插入
if (j > prevEnd && j <= nextEnd) {
// 所有新节点应该插入到位于 nextPos 位置的节点的前面
const nextPos = nextEnd + 1
const refNode =
nextPos < nextChildren.length ? nextChildren[nextPos].el : null
// 采用 while 循环,调用 mount 函数挂载节点
while (j <= nextEnd) {
mount(nextChildren[j++], container, false, refNode)
}
}

同样的,我们在来看下面这个反过来的示例,分为两个阶段

在我们去掉意义上相同的后缀之后,也同样可以得到三个值

1
2
3
j: 1
prevEnd: 1
nextEnd: 0

我们发现在这种情况下 j > nextEnd 并且 j <= prevEnd,这就说明在旧 children 中有位于索引 jprevEnd 之间的节点,都应该被移除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (j > prevEnd && j <= nextEnd) {
// j -> nextEnd 之间的节点应该被添加
const nextPos = nextEnd + 1
const refNode =
nextPos < nextChildren.length ? nextChildren[nextPos].el : null
while (j <= nextEnd) {
mount(nextChildren[j++], container, false, refNode)
}
} else if (j > nextEnd) {
// j -> prevEnd 之间的节点应该被移除
while (j <= prevEnd) {
container.removeChild(prevChildren[j++].el)
}
}

我们可以来观察一下以上的代码结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// while 循环向后遍历,直到遇到拥有不同 key 值的节点为止
while (prevVNode.key === nextVNode.key) {
// 调用 patch 函数更新
// ...
j++
// ...
}

// while 循环向前遍历,直到遇到拥有不同 key 值的节点为止
while (prevVNode.key === nextVNode.key) {
// 调用 patch 函数更新
// ...
prevEnd--
nextEnd--
// ...
}

// 满足条件,则说明从 j -> nextEnd 之间的节点应作为新节点插入
if (j > prevEnd && j <= nextEnd) {
// j -> nextEnd 之间的节点应该被添加
// ...
} else if (j > nextEnd) {
// j -> prevEnd 之间的节点应该被移除
// ...
}

我们发现在两个 while 循环中,索引 j 和 索引 prevEndnextEnd 是以从两端向中间靠拢的趋势在变化的,而在两个 while 循环结束之后,我们会根据这三个索引的大小关系来决定应该做什么样的操作

但是我们可以发现,假设在第一个 while 循环结束之后,索引 j 的值已经大于 prevEndnextEnd,那么就已经没有必要再去执行第二个 while 循环了,因为一旦索引 j 大于 prevEnd 则说明旧 children 与新 children 中的所有节点都已经参与了 patch,这时也就没有必要再执行后续的操作了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
outer: {
while (prevVNode.key === nextVNode.key) {
patch(prevVNode, nextVNode, container)
j++
if (j > prevEnd || j > nextEnd) {
break outer
}
prevVNode = prevChildren[j]
nextVNode = nextChildren[j]
}
// 更新相同的后缀节点
prevVNode = prevChildren[prevEnd]
nextVNode = nextChildren[nextEnd]
while (prevVNode.key === nextVNode.key) {
patch(prevVNode, nextVNode, container)
prevEnd--
nextEnd--
if (j > prevEnd || j > nextEnd) {
break outer
}
prevVNode = prevChildren[prevEnd]
nextVNode = nextChildren[nextEnd]
}
}

我们定义了 label 名字为 outerlabel 语句块,并分别在两个 while 循环中添加了判断语句,无论在哪个循环中,只要索引 j 的值大于了 prevEndnextEnd 二者之一,我们就 break 该语句块,从而避免了无用的代码执行

判断是否需要进行 DOM 移动

其实在上文部分,我们通过判断索引的大小关系,能够提前知道哪些元素被添加,哪些元素被移除,但这毕竟属于一种特殊情况,大部分情况下可能未必如此理想,如下图当中第一部分所示

实际上无论是 Reactdiff 算法,还是 snabbdomdiff 算法,其重点无非就是,判断是否有节点需要移动,以及应该如何移动和寻找出那些需要被添加或移除的节点,所以接下来,我们就来看看如何判断那些节点需要移动,以及如何移动

通过观察上图当中第二部分逻辑,我们会发现,此时索引 j 既不大于 prevEnd 也不大于 nextEnd,所以我们需要添加新的判断来处理这种情况,在这种情况之下,我们需要构造一个数组 source,该数组的长度等于新 children 在经过预处理之后剩余未处理节点的数量,并且该数组中每个元素的初始值为 -1,如上图第三部分所示

该数组中的每一个元素分别与新 children 中剩余未处理的节点对应,实际上 source 数组将用来存储新 children 中的节点在旧 children 中的位置,后面将会使用它计算出一个最长递增子序列,并用于 DOM 移动,如上图第四部分所示,我们可以通过两层 for 循环来完成这个工作,外层循环用于遍历旧 children,内层循环用于遍历新 children,完成后代码如下

但是这里存在一个小问题,我们使用了双层 for 循环,所以其时间复杂度为 O(n^2),在这里我们可以采用『空间换时间的方式』,将复杂度降低到 O(n),所以我们为新的 children 中的节点构建一个 key 到位置索引的索引表,如上图最后一部分所示,其实无论采用哪一种方式,最终我们的目的是对新旧 children 中具有相同 key 值的节点进行更新,同时检测是否需要移动操作

这里需要注意,我们可以从图中看到,我们的 source 数组的第四个元素值仍然为初始值 -1,这是因为新 children 中的 li-g 节点不存在于旧 children

综合后的代码如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
/**
* 基本逻辑如下
* 我们在外层循环逐个从旧 children 中取出未处理的节点,并尝试在新 children 中寻找拥有相同 key 值的可复用节点
* 一旦找到了可复用节点,则调用 patch 函数更新之
*/
// 构造 source 数组
const nextLeft = nextEnd - j + 1 // 新 children 中剩余未处理节点的数量
const source = []
for (let i = 0; i < nextLeft; i++) {
source.push(-1)
}

const prevStart = j
const nextStart = j
let moved = false
let pos = 0

// 构建索引表
// Index Map 中的键是节点的 key,值是节点在新 children 中的位置索引
// 可以使得我们能够非常快速的定位旧 children 中的节点在新 children 中的位置
const keyIndex = {}
for (let i = nextStart; i <= nextEnd; i++) {
keyIndex[nextChildren[i].key] = i
}

// 我们还需要一个数量标识,用来代表已经更新过的节点的数量
// 我们知道,已经更新过的节点数量应该小于新 children 中需要更新的节点数量
// 一旦更新过的节点数量超过了新 children 中需要更新的节点数量,则说明该节点是多余的节点,我们也应该将其移除
let patched = 0

// 遍历旧 children 的剩余未处理节点
for (let i = prevStart; i <= prevEnd; i++) {
prevVNode = prevChildren[i]

if (patched < nextLeft) {
// 通过索引表快速找到新 children 中具有相同 key 的节点的位置
const k = keyIndex[prevVNode.key]
/**
* 我们试图拿旧 children 中的节点尝试去新 children 中寻找具有相同 key 值的节点,但并非总是能够找得到
* 当 k === 'undefined' 时,说明该节点在新 children 中已经不存在了,这时我们应该将其移除
*/
if (typeof k !== 'undefined') {
nextVNode = nextChildren[k]
// patch 更新
patch(prevVNode, nextVNode, container)

// 变量 patched 将作为数量标识,它的初始值为 0,只有当条件 patched < nextLeft 不成立时
// 说明该节点已经不存在与新 children 中了,是一个多余的节点,于是我们将其移除
patched++

/**
* 这里需要注意的是,由于 k - nextStart 的值才是正确的位置索引,而非 k 本身,并且外层循环中变量 i 的值就代表了该节点在旧 children 中的位置
* 所以直接将 i 赋值给 source[k - nextStart] 即可达到目的
*/
// 更新 source 数组
source[k - nextStart] = i

/**
* 这里采用类似 react 的方式
* 变量 k 代表我们在遍历新 children 中遇到的节点的位置索引,变量 pos 用来存储遇到的位置索引的最大值
* 一旦发现后来遇到索引比之前遇到的索引要小,即 k < pos,则说明需要移动操作,这时我们更新变量 moved 的值为 true
*/
// 判断是否需要移动
if (k < pos) {
moved = true
} else {
pos = k
}
} else {
// 没找到,说明旧节点在新 children 中已经不存在了,应该移除
container.removeChild(prevVNode.el)
}
} else {
// 多余的节点,应该移除
container.removeChild(prevVNode.el)
}
}

DOM 移动的方式

在上一小节,我们的主要目的有两个

  1. 判断出是否需要进行 DOM 移动操作,所以我们建立了 moved 变量作为标识,当它的值为 true 时则说明需要进行 DOM 移动
  2. 构建 source 数组,它的长度与去掉相同的前置或者后置节点后新 children 中剩余未处理节点的数量相等,并存储着新 children 中的节点在旧 children 中位置,后面我们会根据 source 数组计算出一个最长递增子序列,并用于 DOM 移动操作

关于最长递增子序列可见 求解给定序列的最长递增子序列,这里就不详细展开了

可以如上面小结当中最后一步流程所示,现在我们已经可以通过判断变量 moved 的值来确定是否需要进行 DOM 移动操作

1
2
3
4
5
if (moved) {
// 如果 moved 为真,则需要进行 DOM 移动操作
// 调用 lis 函数求出数组 source 的最长递增子序列为 [ 0, 1 ]
const seq = lis(sources) // [ 0, 1 ]
}

实现流程如下图所示

我们对新 children 中的剩余未处理节点进行了重新编号,li-c 节点的位置是 0,以此类推,而最长递增子序列是 [ 0, 1 ](代表的是最长递增子序列中的各个元素在 source 数组中的位置索引),这就说明

在新 children 的剩余未处理节点中,位于位置 0 和位置 1 的节点的先后关系与他们在旧 children 中的先后关系相同,或者我们可以理解为位于位置 0 和位置 1 的节点是不需要被移动的节点

简单总结就是,通过最长递增子序列,我们可以在上图第一部分当中得到如下信息

  • li-c 节点和 li-d 节点将在接下来的操作中不会被移动
  • 换句话说只有 li-b 节点和 li-g 节点是可能被移动的节点
  • 又因 li-g 节点位置对应的 source 数组元素的值为 -1,这说明应该作为全新的节点被挂载
  • 所以只有 li-b 节点需要被移动

所以,如图中第二部分所示,我们使用两个索引 ij 分别指向新 children 中剩余未处理节点的最后一个节点和最长递增子序列数组中的最后一个位置,并从后向前遍历,结合上图,我们可以得到如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
if (moved) {
const seq = lis(source)
// j 指向最长递增子序列的最后一个值
let j = seq.length - 1
/**
* 变量 j 指向最长递增子序列的最后一个位置,使用 for 循环从后向前遍历新 children 中剩余未处理的子节点
* 这里的技巧在于 i 的值的范围是 0 到 nextLeft - 1,这实际上就等价于我们对剩余节点进行了重新编号
*/
// 从后向前遍历新 children 中的剩余未处理节点
for (let i = nextLeft - 1; i >= 0; i--) {

/**
* 判断当前节点的位置索引值 i 是否与子序列中位于 j 位置的值相等,总的来说分为三种情况
* 1. 如果 source[i] === -1,应该作为全新的节点挂载
* 2. 如果 i !== seq[j],如果不相等,则说明该节点需要被移动
* 3. 如果 i === seq[j],相等则说明该节点不需要被移动,并让 j 指向下一个位置
*/
if (source[i] === -1) {
// 作为全新的节点挂载

// 该节点在新 children 中的真实位置索引
const pos = i + nextStart
const nextVNode = nextChildren[pos]
// 该节点下一个节点的位置索引
const nextPos = pos + 1
// 挂载
mount(
nextVNode,
container,
false,
nextPos < nextChildren.length
? nextChildren[nextPos].el
: null
)
} else if (i !== seq[j]) {
// 说明该节点需要移动

// 为了将节点挂载到正确的位置,我们需要找到当前节点的真实位置索引
// 该节点在新 children 中的真实位置索引
const pos = i + nextStart
const nextVNode = nextChildren[pos]
// 以及当前节点的后一个节点,并挂载该节点的前面即可
const nextPos = pos + 1
// 移动
container.insertBefore(
nextVNode.el,
nextPos < nextChildren.length
? nextChildren[nextPos].el
: null
)
} else {
// 当 i === seq[j] 时,说明该位置的节点不需要移动
// 并让 j 指向下一个位置
j--
}
}
}

参考

如果想了解更多的相关内容,可以参考以下链接

# React

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×