10张图、10分钟,包你学会 Vue3 的双端对比 diff 算法
话说在射雕三部曲
中论哪门武功比较有意思,笔者认为左右互博应该可以加入群聊。你也可以尝试一下左手画圆、右手画方,看看是不是歪七扭八的。咳咳,不说废话。咱们还是讨论一下 Vue3 的双端对比 diff 算法吧!
声明:左右互博 ≈ 双端对比
进入正题之前,笔者认为有必要复习虚拟 dom 和 diff 算法,来,请接招~
前置知识
- 虚拟 dom
是一个对象
,我们将真实的 dom 通过某种方式转换成一个 对象- 对象的好处就是
跨平台
,如今的 json 就是最好的例子 - 操作虚拟 dom 和 操作真实 dom 哪个快(有时间可以阅读一下 尤大的知乎回答),简言之,纯看速度真实DOM > 虚拟DOM,后者需要对比得出差异才更新。但是基于性能来说
差异更新
要比全量更新
产生的代价更便宜
- diff 算法
- Vue3 是通过
双端对比
+最长递增子序列
算法得出最小的更新消耗 - 双端对比:两个指针一个从前面开始,一个从后面开始,得出中间改变的部分
- 最长递增子序列:中间乱序部分,通过新老对比得出新的节点中
元素
与老节点中相对位置
不需要改变的序列
- Vue3 是通过
亮剑:常见的更新问题
为了节约大家的时间以及 diff 算法主要运用在第四种情况,所以在本文笔者与你一起讨论 old array => new array。
举个🌰
如图所示,变化无非有以下三种:
- 移动,c、d、e 位置不一样了
- 删除,f 不存在了
- 新增,e 是新加的
那么,我们要怎样确定那些元素变动了呢?又是从哪个元素开始变动的?
任务拆解
- 确定左边开始变动的位置 => 左序遍历
- 确定右边开始变动的位置 => 右序遍历
Vue 提供了一种方案 => 双端对比算法,也就是咱们开头说的左右互博,具体的看下图:
三个指针 i、e1、e2,i 表示从左边开始变动的位置,e1 和 e2 分别表示新老节点从右边开始变动的位置。通过循环 new tree 的节点,来确定变动位置,最终我们会得如图所示的结果:
(ps:有疑问❓一定要用三个指针,不能用两个指针吗?例如使用 j 指针从 new tree 末端开始,看似没啥毛病,仔细一想。如果新老节点数相等的时候,确实可以只用两个,当新老树节点不等的时候,一个指针就不能正确的表示差异节点的初始位置了~)
接下来,咱们具体的看下左序遍历和右序遍历的实现方式。
左序遍历
咱们的目的是要确定 i 的位置,首先得清楚循环条件,什么时候该退出循环。因为新老节点都是数组
,所以 i
要小于或等于 e1(老节点的最后一位)
和 e2(新节点的最后一位)
,代码如下:
// 比较函数 c1 为老的虚拟节点 c2 为新的虚拟节点
// c 为 children 的简写,e 为 element 的简写
function patchKeyedChildren(c1, c2){
const len2 = c2.length // 后面多次用到,提取
// 定义三个指针
let i = 0 // 从新的节点开始
let e1 = c1.length - 1 // 老的最后一个 索引值
let e2 = len2 - 1 // 新的最后一个 索引值
// 移动 i 指针
while (i <= e1 && i <= e2) {
const n1 = c1[i];
const n2 = c2[i];
if (isSomeVNodeType(n1, n2)) {
// ... 在循环的比较此节点内的节点
// patch
} else {
break;
}
i++;
}
// 粗略的比较,实际对比要更复杂
function isSomeVNodeType(n1, n2) {
// 对比节点是否相等 可以通过 type 和 key
return n1.type === n2.type && n1.key === n2.key
}
}
左序算法,我们主要做了以下几件事:
- 循环 i ,拿到 c1[i] 和 c2[i]
- 如果相等,就继续循环比较,对比到头,全都一样的,就 i++,移动指针
- 如果不相等,就结束比较,停止移动指针
左边变动的位置确定后,接下来就确定右边变动的位置,这就是任务分解。接下来咱们看下右序遍历是如何实现的呢?
右序遍历
咱们从右边开始遍历,那循环条件是什么呢?是不是也只需要 i <= e1 和 i <= e2 就行了呀!i 的位置确定了,临界值无非是 i = e1 或 i = e2 的情况。e1 和 e2 分别是老节点和新节点的最后一个的索引值,实现代码如下:
function patchKeyedChildren(c1, c2){
const len2 = c2.length
let i = 0 // 从新的节点开始
let e1 = c1.length - 1 // 老的最后一个 索引值
let e2 = len2 - 1 // 新的最后一个 索引值
// 左序遍历
while (i <= e1 && i <= e2) {
...
i++;
}
// 右序遍历
while (i <= e1 && i <= e2) {
const n1 = c1[e1];
const n2 = c2[e2];
if (isSomeVNodeType(n1, n2)) {
// ... 在循环的比较此节点内的节点
// patch
} else {
break;
}
e1--;
e2--;
}
// 粗略的比较,实际对比要更复杂
function isSomeVNodeType(n1, n2) {
// 对比节点是否相等 可以通过 type 和 key
return n1.type === n2.type && n1.key === n2.key
}
}
细看代码,右序遍历其实就是拿到老节点和新节点的最后一个值对比,相等的话,e1--、e2-- 往前移动,不相等就停止移动。
中间乱序部分
经过左序遍历和右序遍历,我们得出了以下的结果,圈出来的就是乱序的部分。
因为此部分篇幅较长,涉及到最长递增子序列算法,咱们可以移步 => 传送门 一起讨论。
写在最后
最后,一图帮你再看一下左序遍历和右序遍历。
另外,如果你想学习 Vue3 源码,推荐先入手 mini-vue,带你实现 Vue3 最简模型。