背景
一直对Vue的核心DOM-Diff算法畏惧,觉得特别特别难,什么递归+双指针,四个命中,深度优先,时间复杂度O(n^3)->O(n)(O(n^2))等等,自己肯定不能掌握分毫,怎么会有zei(读第四声)种前端 但无意间看到了B站美女大佬的bubucuo的视频后,觉得不能怂,学就完事了。
先是看文章,反复对比源码官网、鲨鱼哥、windlany、李永宁教程、林三心
对比着看讲解视频、李永宁视频, 至于虚拟DOM什么的,各位大佬教程都有,文章也多,就不再赘述了。
DOM-Diff过程大致可分为3部分:
1. 初 patch 对比新旧节点存在情况
2. 次 patchVnode 深层次对比相同类型新旧节点( sameVnode 方法判断key/tag等等)
3. 终 updateChildren 对比更新新旧子节点( diff )
newVNode:新节点;oldVNode:旧节点;
初 patch 对比新旧节点存在情况
patch总结下来就3个情况,一切以newVNode为准,改造oldVNode:
-
新增Dom节点:newVNode存在,oldVNode不存在,在DOM中新增newVNode节点;
-
删除Dom节点:newVNode不存在,oldVNode存在,在DOM中删除oldVNode节点;
-
更新Dom节点:newVNode和oldVNode都存在,两节点不同则newVNode替换oldVNode;节点相同则patchVnode新旧节点;
patch
/**
* @description 对比两节点
* @param oldVNode:旧节点
* @param newVNode:新节点
* @param parentElm:父元素
*/
//addVnodes、removeVnodes就不细说了
function path(oldVNode, newVNode, parentElm) {
if (!oldVNode) {
//旧的不存在,新的存在,按照新的给旧的加
addVnodes(parentElm, null, newVNode, 0, newVNode.length - 1);//批量创建节点
} else if (!newVNode) {
//旧的不存在,新的不存在,按照新的给旧的删除
removeVnodes(parentElm, oldVNode, 0, oldVNode.length - 1);//批量删除节点
} else {
//新旧都存在
//判断是否为相同类型节点
if (sameVnode(oldVNode, newVNode)) {
//相同则进行深层次比较!!!
pathcVnode(oldVNode, newVNode);
} else {
//不相同,在父节点下,删除老节点,增加新节点
removeVnodes(parentElm, oldVNode, 0, oldVNode.length - 1);
addVnodes(parentElm, null, newVNode, 0, newVNode.length - 1);
}
}
return newVNode.elm;
}
sameVnode
/**
* @description 判断节点是否相同
* @param a:旧节点
* @param b:新节点
*/
function sameVnode (a, b) {
return (
a.key === b.key && // key值
a.tag === b.tag && // 标签名
a.isComment === b.isComment && // 是否为注释节点
// 是否都定义了data,data包含一些具体信息,例如onclick , style
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b) // 当标签是<input>的时候,type必须相同
)
}
次 patchVnode 深层次对比相同类型新旧节点
总结下分几种情况作比较:
- 新旧节点是否完全一样(包含是静态节点),是则直接return;
- 新节点为文本节点,看旧节点是否为文本节点(2.1 和 2.2、3.1、3.2.2操作的结果一样,调用方法不一样而已):
-
2.1 是:修改旧节点文本为新节点文本(旧的更新成新的);
-
2.2 否:把旧节点更新成新节点一样(旧的更新成新的);
-
- 新节点为元素节点,看它有没有子节点:
-
3.1 无:新节点既不是文本节点又无子节点,那么为新节点空节点,不管旧节点里面有啥,直接清空旧节点(旧的更新成新的);
-
3.2 有:看旧节点是否有子节点:
-
3.2.1 旧的没有子节点:把新子节点创建一份插入到旧节点中(旧的更新成新的);
-
3.2.2 旧的有子节点:调用 updateChildren(oldCh, newCh),递归对比更新子节点; 上面是底下源码的判断情况总结,和源码有些许 判断顺序 的区别,但判断的情况操作一致,先理解文字,建议跟着上面拿笔写一遍伪代码,再照的代码看好理解点,其实不复杂,有几种情况操作都是一样的。本人是这么做的,字丑就不放上来了
-
-
patchVnode
/**
* @description 深层次对比新旧节点
* @param oldVNode:旧节点
* @param newVNode:新节点
**/
function pathcVnode(oldVNode, newVNode) {
//新旧vnode是否完全一样,若是,退出程序
if (oldVNode === newVNode) {
return;
}
//将老 vnode 上的真实节点同步到新的 vnode 上,否则,后续更新的时候会出现 vnode.elm 为空的现象
const elm = (newVNode.elm = oldVNode.elm);
//newVNode 与 oldVNode 是否都是静态节点?如是,退出程序
if (
isTrue(newVNode.isStatic) &&
isTrue(oldVNode.isStatic) &&
newVNode.key === oldVNode.key &&
(isTrue(newVNode.isCloned) || isTrue(newVNode.isOnce))
) {
return;
}
const oldCh = oldVNode.children; //旧子节点
const newCh = newVNode.children; //新子节点
// newVNode 有 text 属性?若没有:
if (isUndef(newVNode.text)) {
//newVNode 为元素节点
//newVNode 的子节点与 oldVNode 的子节点是否都存在?
if (isDef(oldCh) && isDef(newCh)) {
//如都存在,判断子节点是否相同,不同则更新子节点!!!
if (oldCh !== newCh) updateChildren(elm, oldCh, newCh);
//是否只有 newVNode 子节点存在
} else if (isDef(newCh)) {
/*
判断 oldVNode 是否有文本?
无:则把 newVNode 的子节点添加到真实 DOM 中
有: 则清空 DOM 中的文本,再把 newVNode 的子节点添加到真实 DOM 中
*/
if (isDef(oldVNode.text)) nodeOps.setTextContent(elm, "");//清空旧节点
// vnode 的子节点添加到真实 DOM 中
addVnodes(elm, null, newCh, 0, newCh.length - 1, insertedVnodeQueue);
//若只有 oldVNode 的子节点存在
} else if (isDef(oldCh)) {
//清空DOM中的子节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
//若 newVNode 和 oldVNode 都没有子节点,但是 oldVNode 中有文本
} else if (isDef(oldVnode.text)) {
//清空 oldVnode 文本
nodeOps.setTextContent(elm, "");
//!! 上面两个判断一句话概括就是,如果 newVNode 中既没有text,也没有子节点,则为空节点,那么对应的 oldVnode 中有什么就清空什么
}
//若 newVNode 有文本,newVNode 的text属性与 oldVNode 的text属性是否相同?
} else if (oldVNode.text !== newVNode.text) {
//若不相同:则用 newVNode 的text替换真是DOM的文本
nodeOps.setTextContent(elm, newVNode.text);
}
}
终 updateChildren 对比更新新旧子节点( Diff )
新旧子节点 Diff 分为两大种,5小种情况(1.1-1.2不考虑进去):
- 用新旧子节点(前后)双指针去'非常规'循环,预测可能出现的情况,主要看(1.3-1.6的四种):
-
1.1 旧前子节点不存在,新前指针++,右移;
-
1.2 旧后子节点不存在,旧后指针--,左移;
-
1.3 新前对比旧前,如果一样,patchVnode两节点 新旧前指针++,右移;
-
1.4 新后对比旧后,如果一样,patchVnode两节点 新旧后指针--,左移;
-
1.5 新后对比旧前,如果一样,patchVnode两节点 将旧前节点移到旧后节点后面(未处理节点之后,若上一次也是这种情况命中,则上一次算已处理节点),新后指针--,左移,旧前指针++,右移;
-
1.6 新前对比旧后,如果一样,patchVnode两节点 将旧后节点移到旧前节点前面(未处理节点之前,若上一次也是这种情况命中,则上一次算已处理节点),新前指针++,右移,旧后指针--,左移;
-
- 以上都不满足,则只能常规双层循环,旧子节点的key-index构成一个Map,拿新子节点挨个去旧子节点中查找是否有相同key的节点:
-
2.1 旧子中没有找到相同的key,则将当前新前子节点创建一份插入到旧前子节点之前(未处理节点之前,若上一次也是这种情况命中,则上一次算已处理节点),新前指针++,右移;
-
2.2 旧子中找到了相同的key,看两个节点除了key一样,是否类型也一样(sameVnode):
-
2.2.1 不一样:将当前新前子节点创建一份插入到旧前子节点之前(未处理节点之前,若上一次也是这种情况命中,则上一次算已处理节点),新前指针++,右移;
-
2.2.2 一样:patchVnode 两节点,将旧节点中找到的这个节点移动到旧前子节点之前(未处理节点之前,若上一次也是这种情况命中,则上一次算已处理节点),将旧子节点原位置的节点置为undefined,防止改变原旧子节点数组,破坏初始的映射表位置,新前指针++,右移;
-
-
结束循环的标识
- 新前指针 > 新后指针:删除旧前指针——旧后指针之间的节点;
- 旧前之前 > 旧后指针:新增新前指针——新后指针之间的节点,插入到旧前节点之前;
/**
* @description diff算法核心 采用双指针 + 循环的方式 对比新旧vnode的子节点
* @param parent:父元素节点
* @param oldCh:旧子节点
* @param newCh:新子节点
*/
function updateChildren(parent, oldCh, newCh) {
let oldStartIdx = 0; //旧前下标
let oldStartVnode = oldCh[0]; //旧前节点
let oldEndIdx = oldCh.length - 1; //旧后下标
let oldEndVnode = oldCh[oldEndIdx]; //旧后子节点
let newStartIdx = 0; // 新前下标
let newStartVnode = newCh[0]; //新前节点
let newEndIdx = newCh.length - 1; // 新后下标
let newEndVnode = newCh[newEndIdx]; // 新后节点
// 创建oldCh的key-index映射表 类似 {'a':0,'b':1} 代表key为'a'的节点在第一个位置 key为'b'的节点在第二个位置
function makeIndexByKey(children) {
let map = {};
children.forEach((item, index) => {
map[item.key] = index;
});
return map;
}
// 只有当新旧子的双指针的起始位置不大于结束位置的时候 才能循环 一方停止了就需要结束循环
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 因为暴力对比过程把移动的旧子节点置为 undefined 如果不存在 vnode 节点 直接跳过
if (!oldStartVnode) {
oldStartVnode = oldCh[++oldStartIdx];
} else if (!oldEndVnode) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// 新前和旧前对比 依次向后移动指针
patchVnode(oldStartVnode, newStartVnode); //递归比较子节点及孙辈节点
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
//新后和旧后对比 依次向前移动指针
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// 旧前和新后相同 把旧前节点移动到旧后之后,未处理节点之后
patchVnode(oldStartVnode, newEndVnode);
//insertBefore可以移动或者插入真实dom
parent.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// 旧后和新前相同 把旧后移动到新前之前,未处理节点之前
patchVnode(oldEndVnode, newStartVnode);
parent.insertBefore(oldEndVnode.el, oldStartVnode.el);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// 上述四种情况都不满足 那么需要暴力对比,双层循环
// 根据旧的子节点的key和index的映射表 从新的开始子节点进行查找
// 如果可以找到就进行移动操作 如果找不到则直接进行插入
let map = makeIndexByKey(oldCh); // 生成的映射表
let moveIndex = map[newStartVnode.key];//在旧子节点中找到key与 newStartVnode 相同的节点
if (!moveIndex) {
// 旧的中找不到 直接 newStartVnode 插入到旧前之前
parent.insertBefore(createElm(newStartVnode), oldStartVnode.el);
} else {
let moveVnode = oldCh[moveIndex]; //找得到就拿到老的节点
//找到的节点和新前节点相同
if (sameVnode(moveVnode, newStartVnode)) {
patchVnode(moveVnode, newStartVnode);
oldCh[moveIndex] = undefined; //这个是占位操作 避免数组塌陷 防止旧节点移动走了之后破坏了初始的映射表位置
parent.insertBefore(moveVnode.el, oldStartVnode.el); //把找到的节点移动到旧前之前
//找到的节点和新前节点不同
} else {
parent.insertBefore(createElm(newStartVnode), oldStartVnode.el);
}
}
newStartVnode = newCh[++newStartIndex];//新前指针++
}
}
if (oldStartIdx > oldEndIdx) {//旧的循环完毕,将新的中剩余的节点插入到旧前之前
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
} else if (newStartIdx > newEndIdx) {//新的循环完毕,旧中删除剩余的节点
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}
上面的 diff 过程可以举例两个数组,按照逻辑走一下,印象深刻好理解点;注意未处理之前的节点、未处理之后的节点;
不对的地方,望掘金大佬们评论指出来,一起探讨,小弟感激不尽;