深入分析 diff 算法核心
持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第9天,点击查看活动详情
vue相关原理系列文章
- 双端 Diff 算法原理解析及 snabbdom 简单实现
- 一次 vue2 Watch 监听失效引发的对 Watch 的思考
- vue-router原理解析并实现简单的vue-router
- vuex原理解析并实现一个简单的vuex
- vue2响应式原理解析并实现一个简单响应系统
- 从“小胡子”到 vue 插值语法
前面一篇文章介绍了双端 diff 算法流程并交代了一些实现细节,最后实现了一个简单的双端 diff 算法并配合实现的简单渲染函数可以简单的看到效果。本篇文章我们来详细讨论一下 diff 算法的核心部分即新旧虚拟节点都有子节点的情况。
两层循环实现简单 diff 算法
我们知道 diff 算法的最终目的就是找到新旧虚拟节点数组的最小差异达到最小更新的目的。既然是找到两个数组的最小差异,那么你可能会首先想到使用双层循环实现。接下来我们就来实现一下。
API说明:
sameVnode检查两虚拟节点是否一样,继而判断是否可复用。patchdiff 算法的入口函数,内部会判断若旧节点不存在,则是新增节点,会调用函数新建真实 DOM 节点,否则就进行正常的 diff。unmount卸载虚拟节点对应真实 DOM 节点。- 本篇博客移动 DOM 调用
insert方法,底层都是依赖insertBefore方法,所以我们需要找到插入基点 basic。
基础版
撸代码前先思考一下:双层循环的外层循环应该遍历新虚拟节点还是旧虚拟节点呢?
答:外层应该遍历新虚拟节点,因为我们应该拿着每一个新的虚拟节点去到旧的虚拟节点数组中找是否有可以复用的节点,如果外层遍历旧虚拟节点,就变成拿着旧的虚拟节点去到新的虚拟节点数组中找了。
function updateChildren(oldVnode, newVnode, container) {
if (Array.isArray(newVnode.children)) {
// 获取新旧虚拟子节点数组
const oldChildren = oldVnode.children;
const newChildren = newVnode.children;
// 双重循环找差异
for (let i = 0; i < newChildren.length; i++) {
const nVnode = newChildren[i];
for (let j = 0; j < oldChildren.length; j++) {
const oVnode = oldChildren[i];
if (sameVnode(oVnode, nVnode)) {
// 如果找到可以复用的节点,就调用 patchVnode 更新节点内容
patch(oVnode, nVnode, container);
break;
}
}
}
}
// 这里省略了其他情况,只考虑新旧子节点存在的情况
}
进阶版(处理新增和移出)
基础版代码不出差错的前提有两个:
- 新旧虚拟节点的数量一样。
- 新的虚拟节点总能在旧虚拟节点数组找到可复用的节点。
但是不可能每次都能满足这两个前提吧,所以我们需要继续更新 updateChildren 函数,使其更健壮。
新旧虚拟节点数量不一致
有两种情况:
- 新虚拟节点数量多于旧虚拟节点,需要根据多余的新虚拟节点新建 DOM 节点。
- 旧虚拟节点数量多于新虚拟节点,卸载所有旧的不可复用的 DOM 节点。
新虚拟节点在旧虚拟节点数组中找不到可复用节点
当新虚拟节点在旧虚拟节点数组中找不到可复用节点说明该新虚拟节点是新节点,需要新建该新虚拟节点。现在再来想一想新虚拟节点数量多于旧虚拟节点的情况,由于我们外层遍历新虚拟节点,所以那些多余的新虚拟节点也是找不到可复用节点的情况。
综上所述就剩下两种情况:
- 新虚拟节点在旧虚拟节点数组中找不到可复用节点,根据新虚拟节点新建 DOM 节点。
- 旧虚拟节点数量多于新虚拟节点,卸载所有旧的不可复用的 DOM 节点。
function updateChildren(oldVnode, newVnode, container) {
if (Array.isArray(newVnode.children)) {
// 获取新旧虚拟子节点数组
const oldChildren = oldVnode.children;
const newChildren = newVnode.children;
// 双重循环找差异
for (let i = 0; i < newChildren.length; i++) {
const nVnode = newChildren[i];
let find = false; //是否找到可复用旧虚拟节点的标志
for (let j = 0; j < oldChildren.length; j++) {
const oVnode = oldChildren[i];
if (sameVnode(oVnode, nVnode)) {
find = true;
// 如果找到可以复用的节点,就调用 patchVnode 更新节点内容
patch(oVnode, nVnode, container);
break;
}
}
// 处理未找到的情况
if (!find) {
// 这里你采用 insertBefore 新增 DOM 节点,所以需要先找到新增基点
const preNode = newChildren[i - 1];
let basic = null;
if (preNode) {
basic = preNode.el.nextSibling;
} else {
// preNode 不存在说明当前节点是
basic = container.firstChild;
}
patch(null, nVnode, container, basic);
}
}
// 处理需要卸载的情况
for (let i = 0; i < oldChildren.length; i++) {
const oldVnode = oldChildren[i];
// 拿着旧虚拟节点到新虚拟节点列表中找是否存在
const isHas = newChildren.find(vnode => sameVnode(vnode, oldVnode));
// 不存在需要卸载虚拟节点对应真实 DOM 节点
if (!isHas) {
unmount(oldVnode);
}
}
}
// 这里省略了其他情况,只考虑新旧子节点存在的情况
}
完整版(处理复用节点的移动)
接下来就该解决找到可复用节点后的操作了。我们找到可复用节点后就可以复用其对应的真实 DOM 节点,但是很有可能它们的顺序不一致,这时就需要我们移动节点(注意这里移动的节点是虚拟节点对应的真实 DOM 节点)。
如何判断节点是否需要移动
分析如下例子:
我们发现新虚拟节点 x3 对应节点 y3 在旧虚拟节点数组中索引为 2。
x1 对应虚拟节点 y1 的索引为 0,说明 y1 在旧虚拟列表中排在 y3 前面,但是在新虚拟节点列表中 x1 却排在了 x3 后边,所以 y1 需要移动。又因 x2 对应虚拟节点 y2 索引为 1,但 x2 排在 x3 后边,同理 y2 也需要移动。如下图
由此我们可以总结一下:在旧虚拟节点列表中寻找可复用节点时,记录遇到的最大索引值,若后续遇到的复用节点的索引值小于记录的最大索引值,该节点就需要移动。
function updateChildren(oldVnode, newVnode, container) {
if (Array.isArray(newVnode.children)) {
// 获取新旧虚拟子节点数组
const oldChildren = oldVnode.children;
const newChildren = newVnode.children;
let maxIndex = 0;
// 双重循环找差异
for (let i = 0; i < newChildren.length; i++) {
const nVnode = newChildren[i];
let find = false; //是否找到可复用旧虚拟节点的标志
for (let j = 0; j < oldChildren.length; j++) {
const oVnode = oldChildren[i];
if (sameVnode(oVnode, nVnode)) {
find = true;
// 如果找到可以复用的节点,就调用 patchVnode 更新节点内容
patch(oVnode, nVnode, container);
// 需要移动节点
if (j < maxIndex) {
const preNode = newChildren[i - 1];
// 找到插入基点
if (preNode) {
const basic = preNode.el.nextSibling;
insert(nVnode.el, container, basic);
}
} else { // 不需要移动节点,说明 j > maxIndex,更新 maxIndex
maxIndex = j;
}
break;
}
}
// 处理未找到的情况
if (!find) {
// 这里你采用 insertBefore 新增 DOM 节点,所以需要先找到新增基点
const preNode = newChildren[i - 1];
let basic = null;
if (preNode) {
basic = preNode.el.nextSibling;
} else {
basic = container.firstChild;
}
// 若第一个参数为null,说明是挂载新节点,将会挂载载basic之前
patch(null, nVnode, container, basic);
}
}
// 处理需要卸载的情况
for (let i = 0; i < oldChildren.length; i++) {
const oldVnode = oldChildren[i];
// 拿着旧虚拟节点到新虚拟节点列表中找是否存在
const isHas = newChildren.find(vnode => sameVnode(vnode, oldVnode));
// 不存在需要卸载虚拟节点对应真实 DOM 节点
if (!isHas) {
unmount(oldVnode);
}
}
}
// 这里省略了其他情况,只考虑新旧子节点存在的情况
}
双端 diff 算法
相信你如果认真看完了简单 diff 算法的实现后一定会发现两个问题,第一个就是采用双层循环会大大提高时间复杂度,当节点数量很多的时候,会进行大量不必要的比对。第二个就是看这个例子
其实我们只需要将 y3 移动到最上面一次操作就够了,但是简单 diff 算法无法实现,它移动了两次。
但是这些问题双端 diff 算法都完美解决了,下面来看看它的实现细节。注意:双端 diff 是同层比较。
何为双端
双端是指:同时对新旧两组子节点的两个端点进行比较。因此就需要四个“指针”分别指向新旧虚拟节点的头和尾。
function updateChildren(oldVnode, newVnode, container) {
const oldChildren = oldVnode.children;
const newChildren = newVnode.children;
// 四个索引值
let oldStartIdx = 0; // 旧的第一个下标
let oldEndIdx = oldChildren.length - 1; // 旧的最后一个下标
let newStartIdx = 0; // 新的第一个下标
let newEndIdx = newChildren.length - 1; // 新的最后一个下标
let oldStartVnode = oldChildren[0]; // 旧的第一个节点
let oldEndVnode = oldChildren[oldEndIdx]; // 旧的最后一个节点
let newStartVnode = newChildren[0]; // 新的第一个节点
let newEndVnode = newChildren[newEndIdx]; // 新的最后一个节点
let oldKeyToIdx = null;//旧节点key到索引的映射
}
四次比较和找不到可复用节点的情况
有了上面的这些信息就可以进行四次比较,这四次比较能够很快的找出一部分的可复用节点。
四次比较
- 新头节点与旧头结点:如果命中,就调用 patchVnode 对两节点数据进行更新,并且更新新头和旧头索引和节点。这里不需要移动节点,因为都是头结点,顺序没有变化。
// 对比旧开始和新开始
if (sameVnode(oldStartVnode, newStartVnode)) {
// 更新节点数据
patch(oldStartVnode, newStartVnode);
// 更新新头和旧头索引和节点
oldStartVnode = oldChildren[++oldStartIdx];
newStartVnode = newChildren[++newStartIdx];
}
- 新尾节点与旧尾结点:如果命中,就调用 patchVnode 对两节点数据进行更新,并且更新新尾和旧尾索引和节点。这里不需要移动节点,因为都是尾结点,顺序没有变化。
// 对比旧结束和新结束
if (sameVnode(oldEndVnode, newEndVnode)) {
patch(oldEndVnode, newEndVnode);
oldEndVnode = oldChildren[--oldEndIdx];
newEndVnode = newChildren[--newEndIdx];
}
- 新尾节点与旧头结点:如果命中,就调用 patchVnode 对两节点进行更新,同时需要移动旧头虚拟节点对应真实 DOM 节点,将其插入到当前旧尾(是当前旧尾指针指向的节点,不是整个数组的最后一个)的后边,因为当前命中条件说明旧头结点在新顺序中是当前未处理序列的尾节点,对应的就是旧尾节点的后边。并且更新新尾和旧头索引。
// 对比旧开始和新结束
if (sameVnode(oldStartVnode, newEndVnode)) {
patch(oldStartVnode, newEndVnode);
// 移动节点
insert(oldStartVnode.el, container, oldEndVnode.el.nextSibling);
oldStartVnode = oldChildren[++oldStartIdx];
newEndVnode = newChildren[--newEndIdx];
}
- 新头节点与旧尾结点:如果命中,就调用 patchVnode 对两节点进行更新,同时需要移动旧尾虚拟节点对应真实 DOM 节点,将其插入到当前旧头(是当前旧头指针指向的节点,不是整个数组的第一个)的前边,因为当前命中条件说明旧尾结点在新顺序中是当前未处理序列的头节点,对应的就是旧头节点的前边。并且更新新头和旧尾索引。
// 对比旧结束和新开始
if (sameVnode(oldEndVnode, newStartVnode)) {
patch(oldEndVnode, newStartVnode);
// 移动节点
insert(oldEndVnode.el, container, oldStartVnode.el.nextSibling);
oldEndVnode = oldChildren[--oldEndIdx];
newStartVnode = newChildren[--newStartIdx];
}
寻找可复用节点的策略
当以上四种情况均未命中时,此时就需要拿着新头节点遍历旧虚拟节点列表找对应的可复用节点。
若都有 key 值时,先遍历旧虚拟节点列表并构建一个 key 到虚拟节点的映射表,就可以快速找到 key 值一样的虚拟节点,再调用 sameVnode 判断该节点是否可复用。否则就遍历调用 sameVnode 方法找可复用节点。若找可复用节点只需将该虚拟节点对应真实 DOM 节点移动到旧头对应真实节点之前就好了,若未找到,就需要根据新头虚拟点创建真实 DOM 节点并挂载到 旧头虚拟节点对应真实 DOM 之前。
// 创建映射表
function createKeyToOldIdx(children, beginIdx, endIdx) {
let i, key;
const map = {};
for (i = beginIdx; i <= endIdx; ++i) {
key = children[i].key;
if (key) map[key] = i;
}
return map;
}
// 不包括以上四种快捷比对方式
// 获取旧开始到结束节点的 key 和下表集合
if (!oldKeyToIdx)
oldKeyToIdx = createKeyToOldIdx(oldChildren, oldStartIdx, oldEndIdx);
// 获取新节点key在旧节点key集合里的下标
idxInOld = newStartVnode.key)? oldKeyToIdx[newStartVnode.key]:findIdxInOld(newStartVnode, oldChildren, oldStartIdx, oldEndIdx;
if (!idxInOld) {
// 找不到对应的下标,表示新节点是新增的,需要创建新 dom
patch(null, newStartVnode, container, oldStartVnode.el);
} else {
// 能找到对应的下标,表示是已有的节点,移动位置即可
const vnodeToMove = oldChildren[idxInOld];
if (sameVnode(vnodeToMove, newStartVnode)) {
patch(vnodeToMove, newStartVnode,container);
oldChildren[idxInOld] = undefined;// 将处理过的就虚拟节点赋值为 undefined,减少无效的比对
insert( vnodeToMove.el,container oldStartVnode.el);
} else {
patch(null, newStartVnode, container, oldStartVnode.el);
}
}
newStartVnode = newChildren[++newStartIdx];
双端 diff 算法新头与旧尾命中 DOM 移动演示。
最后需要注意由于我们需要比对两个数组,所以我们应将上边的操作放在一个 while 循环中,循环条件就是
oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx,因为oldStartIdx > oldEndIdx说明旧虚拟节点列表遍历完了,newStartIdx > newEndIdx说明新虚拟节点列表遍历完了,无论谁遍历完了就没有继续循环下去的必要了。
同时我们在处理四种比对都未命中的情况时,若在旧虚拟节点列表中找到了新头可复用的节点并处理完后,我们需要将复用的旧虚拟节点赋值为 undefined,在循环时跳过,减少无效的比较,提高效率。
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (!oldStartVnode) {
// / 跳过因位移留下的undefined
} else if (!oldEndVnode) {
// 跳过因位移留下的undefine
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// 对比旧开始和新开始
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// 对比旧结束和新结束
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// 对比旧开始和新结束
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// 对比旧结束和新开始
} else {
// 处理以上几种比对未命中情况
}
}
新增和移除
新增
结束循环后,当新子节点数量大于旧子节点数量时,while循环结束条件一定是
oldStartIdx > oldEndIdx,所以我们要新建 [newStartIdx, newEndIdx] 之间的真实 DOM 节点。
if (oldStartIdx > oldEndIdx) {
// 如果旧节点列表先处理完,则表示剩余的节点是新增的节点,然后添加这些节点
for(let i = newStartIdx; i <= newEndIdx; i++) {
const basic = newChildren[newEndIdx + 1] ? newChildren[newEndIdx + 1].el: null;
patch(null, newChildren[i], container, basic);
}
}
移除
当新子节点数量大于旧子节点数量时,while循环结束条件一定是
newStartIdx > newEndIdx,所以我们要移除 [oldStartIdx, oldEndIdx] 之间对应的所有真实 DOM 节点。
要时刻记得 diff 算法的核心诉求就是寻找可复用节点,减少操作真实 DOM 节点,提高性能。
if (newStartIdx > newEndIdx) {
// 如果新节点列表先处理完,则剩余旧节点是多余的,删除
for (; oldStartIdx <= oldEndIdx; oldStartIdx++) {
unmount(oldChildren[oldStartIdx]);
}
}
快速 diff 算法
接下来聊聊 vue3 借鉴并扩展了的快速 diff 算法,正如其名,在实测中本算法性能要稍优于双端 diff 算法。本算法的实现思路是先处理头和尾的可复用节点,再处理中间的节点。
首先处理头和尾可复用的节点。
function updateChildren(oldVnode, newVnode, container) {
const oldChildren = oldVnode.children;
const newChildren = newVnode.children;
// 处理可复用的前置节点
let j = 0;
let oVnode = oldChildren[j];
let nVnode = newChildren[j];
while (sameVnode(oVnode, nVnode)) {
patchVnode(oVnode, nVnode, container);
// 更新索引
j++;
oVnode = oldChildren[j];
nVnode = newChildren[j];
}
// 处理可复用的前置节点
let oldEnd = oldChildren.length - 1;
let newEnd = newChildren.length - 1;
oVnode = oldChildren[oldEnd];
nVnode = newChildren[newEnd];
while (sameVnode(oVnode, nVnode)) {
patchVnode(oVnode, nVnode, container);
// 更新索引
oldEnd--;
newEnd--;
oVnode = oldChildren[oldEnd];
nVnode = newChildren[newEnd];
}
}
当我们处理完可复用前置和后置后可能会出现四种情况:
- 新子节点已经处理完了,旧子节点还未处理完,新增。
2. 旧子节点已经处理完了,新子节点还未处理完,移除。
3. 新旧子节点都未处理完,比对移动节点。
4. 新旧子节点都已经处理完了(不用做特殊处理,上面已经默认处理)。
新增和移除
// 新增
if (j > oldEnd && j <= newEnd) {
// 基点索引
const basicIndex = newEnd + 1;
// 找到基点
const basic = basicIndex < newChildren.length ? newChildren[basicIndex] : null;
while (j <= newEnd) {
patchVnode(null, newChildren[j++], container, basic);
}
} else if (j > newEnd && j <= oldEnd) { // 移除
// 卸载[j, oldEnd]之间的节点
while (j <= oldEnd) {
unmount(oldChildren[j++]);
}
}
移动节点
当新旧子节点都未处理完时,我们需要对比节点判断是否能找到可复用的虚拟节点并移动他们。
这里我们需要使用一个 source 数组,长度为新子节点处理完头尾结点后剩余未处理的虚拟节点数量,并且每个元素的初始值都为 -1,它用来存储新的子节点在旧的子节点列表中的索引,用它来求出一个最长递增子序列(不要求连续),辅助完成 DOM 的移动。至于为什么要递增子序列,原因其实跟简单 diff 算法中找最大索引原理一样,其实简单 diff 中不断更新最大索引值的过程就是一个递增序列。
示例:
生成虚拟节点 key 到索引的映射
仔细阅读上述 source 数组含义,我们需要求出新的子节点在旧的子节点列表中的索引,那么就需要双层循环遍历新旧虚拟节点列表,但是我们可以做一个小优化,先生成新或旧虚拟节点列表的key 到索引的映射,这样就减少了一层循环。
那么到底生成新虚拟节点列表的映射还是旧的?应该生成新的;因为这样就会循环遍历旧虚拟节点列表,我们就可以在遍历过程中做一些更新、卸载 DOM 节点的操作,优化代码。比如无法在映射表中找到旧虚拟节点的 key 值,此时说明新虚拟节点中没有该节点,需要卸载该虚拟节点对应真实 DOM 节点。
const keyIndex = {}; //记录key值到索引的映射
for (let k = newStart; k <= newEnd; k++) {
keyIndex[newChildren[k].key] = k;
}
填充 source 数组
这里还进行了优化,用一个变量记录已经处理过的旧虚拟节点数量,一旦超过新虚拟节点的数量,那么后续只需要卸载旧虚拟节点对应真实 DOM 节点,减少了无效对比。
还有一个是否需要移动节点标志 move,初始值为 false,一旦在填充 source 数组时,找到可复用节点,就将标志置为 true,表示有节点需要移动处理。
const oldStart = j;
const newStart = j;
let move = false; // 是否移动标志
let pos = 0;
let move = false; // 是否移动标志
const count = newEnd - j + 1; // 计算剩余虚拟节点数量
const source = new Array(count).fill(-1); // 初始化source数组
let patched = 0; //记录剩余节点中已经处理过的节点数量
for (let i = oldStart; i <= oldEnd; i++) {
const oVnode = oldChildren[i];
// 如果已经处理过的节点数量小于剩余节点的数量则继续处理,否则直接卸载 DOM 节点
if (patched <= count) {
const k = keyIndex[oVnode.key];
// 旧节点key值存在于新节点列继续处理,否则卸载
if (typeof k != "undefined") { // 找到可复用节点,更新就节点
nVnode = newChildren[k];
patch(oVnode, nVnode, container);
patched++;
source[k - newStart] = i;
if (k < pos) {
move = true;
} else {
pos = k;
}
} else {// 新虚拟节点列表中没有该就虚拟节点
unmount(oVnode);
}
} else {// 新节点虚拟列表已经处理完了
unmount(oVnode);
}
}
处理需要移动节点的情况
求出最长递增子序列是 arr(注意这里的 arr 里保存的是递增子序列在 source 中的索引),如上面的例子arr = [0, 1],含义是在新的虚拟列表中重新编号后索引值为 0 和 1 的两个节点在更新前后顺序没有变化,不需要移动。
注意:source 数组元素为 -1,说明该位置的新虚拟节点是新增节点。遍历新虚拟节点列表应该从后往前遍历,因为我们移动节点底层依赖 insertBefore 方法,所以需要先确定后边的 DOM 节点。
if (move) {
// 寻找最长递增子序列
const arr = lis(source);
let end = arr.length - 1;
for (let i = count - 1; i >= 0; i--) {
// 新增
if (source[i] === -1) {
const pos = i + newStart;
const nVnode = newChildren[pos];
const nextPos = pos + 1;
const basic = nextPos < newChildren.length ? newChildren[nextPos].el : null;
patch(null, nVnode, container, basic);
} else if (i != seq[end]) { // 说明节点需要移动
const pos = i + newStart;
const nVnode = newChildren[pso];
const nextPos = pos + 1;
const basic = nextPos < newChildren.length ? newChildren[nextPos].el : null;
insert(nVnode.el, container, basic);
} else {// 节点不需要移动
end--;
}
}
}
}
完整示例代码
function updateChildren(oldVnode, newVnode, container) {
const oldChildren = oldVnode.children;
const newChildren = newVnode.children;
// 处理可复用的前置节点
let j = 0;
let oVnode = oldChildren[j];
let nVnode = newChildren[j];
while (sameVnode(oVnode, nVnode)) {
patchVnode(oVnode, nVnode, container);
// 更新索引
j++;
oVnode = oldChildren[j];
nVnode = newChildren[j];
}
// 处理可复用的后置节点
let oldEnd = oldChildren.length - 1;
let newEnd = newChildren.length - 1;
oVnode = oldChildren[oldEnd];
nVnode = newChildren[newEnd];
while (sameVnode(oVnode, nVnode)) {
patchVnode(oVnode, nVnode, container);
// 更新索引
oldEnd--;
newEnd--;
oVnode = oldChildren[oldEnd];
nVnode = newChildren[newEnd];
}
// 处理新增
if (j > oldEnd && j <= newEnd) {
// 基点索引
const basicIndex = newEnd + 1;
const basic =
basicIndex < newChildren.length ? newChildren[basicIndex] : null;
while (j <= newEnd) {
patchVnode(null, newChildren[j++], container, basic);
}
} else if (j > newEnd && j <= oldEnd) {// 处理移除
while (j <= oldEnd) {
unmount(oldChildren[j++]);
}
} else {
const count = newEnd - j + 1; // 计算剩余虚拟节点数量
const source = new Array(count).fill(-1); // 初始化source数组
const oldStart = j;
const newStart = j;
let move = false; // 是否移动标志
let pos = 0; //
let patched = 0; //记录剩余节点中已经处理过的节点数量
const keyIndex = {}; //记录key值到索引的映射
for (let k = newStart; k <= newEnd; k++) {
keyIndex[newChildren[k].key] = k;
}
for (let i = oldStart; i <= oldEnd; i++) {
const oVnode = oldChildren[i];
// 如果已经处理过的节点数量小于剩余节点的数量则继续处理,否则直接卸载 DOM 节点
if (patched <= count) {
const k = keyIndex[oVnode.key];
// 旧节点key值存在于新节点列继续处理,否则卸载
if (typeof k != "undefined") {
nVnode = newChildren[k];
patch(oVnode, nVnode, container);
patched++;
source[k - newStart] = i;
if (k < pos) {
move = true;
} else {
pos = k;
}
} else {
unmount(oVnode);
}
} else {
unmount(oVnode);
}
}
if (move) {
// 寻找最长递增子序列
const arr = lis(source);
let end = arr.length - 1;
for (let i = count - 1; i >= 0; i--) {
// 新增
if (source[i] === -1) {
const pos = i + newStart;
const nVnode = newChildren[pos];
const nextPos = pos + 1;
const basic =
nextPos < newChildren.length ? newChildren[nextPos].el : null;
patch(null, nVnode, container, basic);
} else if (i != seq[end]) { // 说明节点需要移动
const pos = i + newStart;
const nVnode = newChildren[pso];
const nextPos = pos + 1;
const basic =
nextPos < newChildren.length ? newChildren[nextPos].el : null;
insert(nVnode.el, container, basic);
} else {// 节点不需要移动
end--;
}
}
}
}
}
总结
本篇博客有些长,但我相信能读到这的同学对 diff 算法一定有了更深的认识。通过上边的分析可以得出一个结论:diff 算法的核心部分要处理的操作就是找复用节点并更新数据和移动节点、新增、移除,只要想清楚了里面的细节其实也很简单。
好了,这篇博客就到这了,我是孤城浪人,一名正在前端路上摸爬滚打的菜鸟,欢迎你的关注。