Vue3 diff 优化点
1. 静态标记 + 非全量 Diff(Vue 3在创建虚拟DOM树的时候,会根据DOM中的内容会不会发生变化,添加⼀个静态标记。之后在与上次虚拟节点进行对比的时候,就只会对比这些带有静态标记的节点。
2. 使用最长递增子序列优化对比流程,可以最大程度的减少 DOM 的移动,达到最少的 DOM 操作
实现思路
vue3的diff算法其中有两个理念。第⼀个是相同的前置与后置元素的预处理;第⼆个则是最长递增子序列。
前置与后置的预处理
我们看这两段文字
1 hello chenguanhui
2 hey chenguanhui
我们会发现,这两段文字是有⼀部分是相同的,这些文字是不需要修改也不需要移动的,真正需要进行修改中间的几个字母,所以diff就变成以下部分
1 text1: llo
2 text2: y
接下来换成 vnode我们来看下 :
图中的被绿色框起来的节点,他们是不需要移动的,只需要进⾏打补丁 patch 就可以了。我们把该逻辑写成代码。
function vue3Diff(prevChildren, nextChildren, parent) {
let j = 0,
prevEnd = prevChildren.length - 1,
nextEnd = nextChildren.length - 1,
prevNode = prevChildren[j],
nextNode = nextChildren[j];
while (prevNode.key === nextNode.key) {
patch(prevNode, nextNode, parent)
j++
prevNode = prevChildren[j]
nextNode = nextChildren[j]
}
prevNode = prevChildren[prevEnd]
nextNode = prevChildren[nextEnd]
while (prevNode.key === nextNode.key) {
patch(prevNode, nextNode, parent)
prevEnd--
nextEnd--
prevNode = prevChildren[prevEnd]
nextNode = prevChildren[nextEnd]
}
}
这时候,我们需要考虑边界情况,⼀种是j > prevEnd;另⼀种是j > nextEnd。
在上图中,此时j > prevEnd且j <= nextEnd,只需要把新列表中 j 到nextEnd之间剩下的节点插⼊进去就可以了。相反, 如果j > nextEnd时,把旧列表中 j 到prevEnd之间的节点删除就可以了。
function vue3Diff(prevChildren, nextChildren, parent) {
// ...
if (j > prevEnd && j <= nextEnd) {
let nextpos = nextEnd + 1,
refNode = nextpos >= nextChildren.length
? null
: nextChildren[nextpos].el;
while(j <= nextEnd) mount(nextChildren[j++], parent, refNode)
} else if (j > nextEnd && j <= prevEnd) {
while(j <= prevEnd) parent.removeChild(prevChildren[j++].el)
}
}
在while循环时,指针是从两端向内逐渐靠拢的,所以我们应该在循环中就应该去判断边界情况,我们使⽤label语法,当我们触发边界情况时,退出全部的循环,直接进入判断:
function vue3Diff(prevChildren, nextChildren, parent) {
let j = 0,
prevEnd = prevChildren.length - 1,
nextEnd = nextChildren.length - 1,
prevNode = prevChildren[j],
nextNode = nextChildren[j];
// label语法
outer: {
while (prevNode.key === nextNode.key) {
patch(prevNode, nextNode, parent)
j++
// 循环中如果触发边界情况,直接break,执⾏outer之后的判断
if (j > prevEnd || j > nextEnd) break outer
prevNode = prevChildren[j]
nextNode = nextChildren[j]
}
prevNode = prevChildren[prevEnd]
nextNode = prevChildren[nextEnd]
while (prevNode.key === nextNode.key) {
patch(prevNode, nextNode, parent)
prevEnd--
nextEnd--
// 循环中如果触发边界情况,直接break,执⾏outer之后的判断
if (j > prevEnd || j > nextEnd) break outer
prevNode = prevChildren[prevEnd]
nextNode = prevChildren[nextEnd]
}
}
// 边界情况的判断
if (j > prevEnd && j <= nextEnd) {
let nextpos = nextEnd + 1,
refNode = nextpos >= nextChildren.length
? null
: nextChildren[nextpos].el;
while(j <= nextEnd) mount(nextChildren[j++], parent, refNode)
} else if (j > nextEnd && j <= prevEnd) {
while(j <= prevEnd) parent.removeChild(prevChildren[j++].el)
}
}
判断是否需要移动 (最长递增子序列)
接下来,就是找到移动的节点,然后给他移动到正确的位置
当前/后置的预处理结束后,我们进⼊真正的diff环节。首先,我们先根据新列表剩余的节点数量,创建⼀个Source数组,并将数组填满-1。
function vue3Diff(prevChildren, nextChildren, parent) {
//...
outer: {
// ...
}
// 边界情况的判断
if (j > prevEnd && j <= nextEnd) {
// ...
} else if (j > nextEnd && j <= prevEnd) {
// ...
} else {
let prevStart = j,
nextStart = j,
nextLeft = nextEnd - nextStart + 1, // 新列表中剩余的节点⻓度
source = new Array(nextLeft).fill(-1); // 创建数组,填满-1
}
}
Source是用来做新旧节点的对应关系的,我们将新节点在旧列表的位置存储在该数组中,我们在根据Source计算出它的最长递增子序列用于移动DOM节点。为此,先建立一个对象存储当前新列表中的节点与index的关系,再去旧列表中去找位置。
注意:如果旧节点在新列表中没有的话,直接删除就好。除此之外,我们还需要⼀个数量表示记录我们已经patch过的节点,如果数量已经与新列表剩余的节点数量⼀样,那么剩下的旧节点我们就直接删除了就可以了
function vue3Diff(prevChildren, nextChildren, parent) {
//...
outer: {
// ...
}
// 边界情况的判断
if (j > prevEnd && j <= nextEnd) {
// ...
} else if (j > nextEnd && j <= prevEnd) {
// ...
} else {
let prevStart = j,
nextStart = j,
nextLeft = nextEnd - nextStart + 1, // 新列表中剩余的节点⻓度
source = new Array(nextLeft).fill(-1), // 创建数组,填满-1
nextIndexMap = {}, // 新列表节点与index的映射
patched = 0; // 已更新过的节点的数量
// 保存映射关系
for (let i = nextStart; i <= nextEnd; i++) {
let key = nextChildren[i].key
nextIndexMap[key] = i
}
// 去旧列表找位置
for (let i = prevStart; i <= prevEnd; i++) {
let prevNode = prevChildren[i],
prevKey = prevNode.key,
nextIndex = nextIndexMap[prevKey];
// 新列表中没有该节点 或者 已经更新了全部的新节点,直接删除旧节点
if (nextIndex === undefind || patched >= nextLeft) {
parent.removeChild(prevNode.el)
continue
}
// 找到对应的节点
let nextNode = nextChildren[nextIndex];
patch(prevNode, nextNode, parent);
// 给source赋值
source[nextIndex - nextStart] = i
patched++
}
}
}
DOM如何移动
判断完是否需要移动后,我们就需要考虑如何移动了。⼀旦需要进行DOM移动,我们首先要做的就是找到source的最长递增子序列。
最长递增子序列:给定⼀个数值序列,找到它的一个子序列,并且子序列中的值是递增的,序列中
的元素在原序列中不⼀定连续。
例如给定数值序列为:[ 0, 8, 4, 12 ]。
那么它的最长递增子序列就是:[0, 8, 12]。
当然答案可能有多种情况,例如:[0, 4, 12] 也是可以的。
上⾯的代码中,我们调用lis 函数求出数组source的最长递增子序列为[ 0, 1 ]。我们知道 source 数组的值为 [4,2, 3, 1, -1],很显然最长递增子序列应该是[ 2, 3 ],计算出的结果是[ 1, 2 ]代表的是最长递增子序列中的各个元素在source数组中的位置索引,如下图所示
我们根据source,对新列表进行重新编号,并找出了最长递增子序列。
我们从后向前进行遍历source每⼀项。此时会出现三种情况:
1. 当前的值为-1,这说明该节点是全新的节点,又由于我们是从后向前遍历,我们直接创建好DOM节点插入到队尾就可以了;
2. 当前的索引为最长递增子序列中的值,也就是i === seq[j],这说说明该节点不需要移动;
3. 当前的索引不是最长递增子序列中的值,那么说明该DOM节点需要移动,这里也很好理解,我们也
是直接将DOM节点插⼊到队尾就可以了,因为队尾是排好序的;
Diff更新的详细过程图
那么在第一次更新中,发现g节点当前值是-1,那么按新增节点进行处理,将其插入到新e节点前:
第二次更新,发现b不在最长递增子序列中,说明DOM节点需要移动,直接插入队尾中(因为我们是从后向前进行遍历)
在第三次更新中,发现d节点存在于最长递增子序列中,所以本次更新可以跳过:
在第四次更新中,同理发现c节点存在于最长递增子序列中,所以本次更新可以跳过:
在第五次更新,此时f节点可复用,则将其移动到新c节点前即可。
到此为止,上图的diff就结束了。
代码如下
function vue3Diff(prevChildren, nextChildren, parent) {
//...
if (move) {
// 需要移动
const seq = lis(source); // [0, 1]
let j = seq.length - 1; // 最⻓⼦序列的指针
// 从后向前遍历
for (let i = nextLeft - 1; i >= 0; i--) {
let pos = nextStart + i, // 对应新列表的index
nextNode = nextChildren[pos], // 找到vnode
nextPos = pos + 1, // 下⼀个节点的位置,⽤于移动DOM
refNode = nextPos >= nextChildren.length ? null : nextChildren[nextPos].
cur = source[i]; // 当前source的值,⽤来判断节点是否需要移动
if (cur === -1) {
// 情况1,该节点是全新节点
mount(nextNode, parent, refNode)
} else if (cur === seq[j]) {
// 情况2,是递增⼦序列,该节点不需要移动
// 让j指向下⼀个
j--
} else {
// 情况3,不是递增⼦序列,该节点需要移动
parent.insetBefore(nextNode.el, refNode)
}
}
} else {
//不需要移动
}
}
说完了需要移动的情况,再说说不需要移动的情况。如果不需要移动的话,我们只需要判断是否有全新的节点给他添加进去就可以了。具体代码如下:
function vue3Diff(prevChildren, nextChildren, parent) {
//...
if (move) {
const seq = lis(source); // [0, 1]
let j = seq.length - 1; // 最⻓⼦序列的指针
// 从后向前遍历
for (let i = nextLeft - 1; i >= 0; i--) {
let pos = nextStart + i, // 对应新列表的index
nextNode = nextChildren[pos], // 找到vnode
nextPos = pos + 1, // 下⼀个节点的位置,⽤于移动DOM
refNode = nextPos >= nextChildren.length ? null : nextChildren[nextPos].
cur = source[i]; // 当前source的值,⽤来判断节点是否需要移动
if (cur === -1) {
// 情况1,该节点是全新节点
mount(nextNode, parent, refNode)
} else if (cur === seq[j]) {
// 情况2,是递增⼦序列,该节点不需要移动
// 让j指向下⼀个
j--
} else {
// 情况3,不是递增⼦序列,该节点需要移动
parent.insetBefore(nextNode.el, refNode)
}
}
} else {
//不需要移动
for (let i = nextLeft - 1; i >= 0; i--) {
let cur = source[i]; // 当前source的值,⽤来判断节点是否需要移动
if (cur === -1) {
let pos = nextStart + i, // 对应新列表的index
nextNode = nextChildren[pos], // 找到vnode
nextPos = pos + 1, // 下⼀个节点的位置,⽤于移动DOM
refNode = nextPos >= nextChildren.length ? null : nextChildren[nextPos
mount(nextNode, parent, refNode)
}
}
}
}
对比 Vue2
总所周知,Vue2中的diff算法时间复杂度为O(n),而Vue3中的diff算法基于最长递增子序列最高有O(nlogn)。那么为什么要改用算法呢?
个人觉得是为了减少对DOM的操作,虽然大多数情况下,两种算法的对DOM的操作次数几乎相等,但也有不少的情况Vue3对DOM的操作次数少于Vue2,比如下面的例子:
在上面的例子中,Vue2的操作次数为3次;Vue3的操作次数为2次。
了解vue2 Diff算法
这种DOM操作的优势在递增序列越长时更显著。