前言
本文根据掘金一些大佬的文章总结+自己理解+《Vue.js设计与实现》总结出来的,加粗部分可以直接当面试答案来理解记忆回答,不加粗部分也很重要是加分项!!!
diff算法(虚拟dom)
- Vue2:其实就是snabbdom库。
源码架构
-src
-moduel
- attributes
- props
- styles
- class
...core
⭐️ sameVnode:判断两个vnode节点是否相同
源码: 通过判断vnode节点上的key、sel、幽灵标签或者文本等等是否相等,这里说一下sel是选择器(div或者class等等),key就是唯一标识
function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
const isSameKey = vnode1 . key === vnode2 . key;
const isSameIs = vnode1 . data ?. is === vnode2 . data ?. is;
const isSameSel = vnode1 . sel === vnode2 . sel;
const isSameTextOrFragment =
!vnode1 . sel && vnode1 . sel === vnode2 . sel
? typeof vnode1 . text === typeof vnode2 . text
: true;
return isSameSel && isSameKey && isSameIs && isSameTextOrFragment;
}
⭐️ init:核心core代码,根据传入的module,返回一个函数 patch (里面是核心逻辑)
⭐️ patch :整个流程核心,
return function patch(
oldVnode: VNode | Element | DocumentFragment,
vnode: VNode
): VNode {
let i: number, elm: Node, parent: Node;
const insertedVnodeQueue: VNodeQueue = [];
for (i = 0; i < cbs . pre . length; ++i) cbs . pre[i]();
// if (isElement(api, oldVnode)) {
// oldVnode = emptyNodeAt(oldVnode);
// } else if (isDocumentFragment(api, oldVnode)) {
// oldVnode = emptyDocumentFragmentAt(oldVnode);
// }
if (sameVnode(oldVnode, vnode)) {
// 如果是相同节点
patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
// 如果不是相同节点
elm = oldVnode . elm!;
parent = api . parentNode(elm) as Node;
createElm(vnode, insertedVnodeQueue);
if (parent !== null) {
api . insertBefore(parent, vnode . elm!, api . nextSibling(elm));
removeVnodes(parent, [oldVnode], 0, 0);
}
}
for (i = 0; i < insertedVnodeQueue . length; ++i) {
insertedVnodeQueue[i] . data! . hook! . insert!(insertedVnodeQueue[i]);
}
for (i = 0; i < cbs . post . length; ++i) cbs . post[i]();
return vnode;
};
}
⭐️ patchVnode:对比两个节点的差异,这里设计的情况比较多,源码比较的复杂,所以我引用@渣渣xiong 这位大佬的思维导图
具体源码:
function patchVnode(
oldVnode: VNode,
vnode: VNode,
insertedVnodeQueue: VNodeQueue
) {
const hook = vnode . data ?. hook;
hook ?. prepatch ?. (oldVnode, vnode);
const elm = (vnode . elm = oldVnode . elm)!;
if (oldVnode === vnode) return;
if (
vnode . data !== undefined ||
(vnode . text !== undefined && vnode . text !== oldVnode . text)
) {
vnode . data ??= {};
oldVnode . data ??= {};
for (let i = 0; i < cbs . update . length; ++i)
cbs . update[i](oldVnode, vnode);
vnode . data ?. hook ?. update ?. (oldVnode, vnode);
}
const oldCh = oldVnode . children as VNode[];
const ch = vnode . children as VNode[];
if (vnode . text === undefined) {
if (oldCh !== undefined && ch !== undefined) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
} else if (ch !== undefined) {
if (oldVnode . text !== undefined) api . setTextContent(elm, "");
addVnodes(elm, null, ch, 0, ch . length - 1, insertedVnodeQueue);
} else if (oldCh !== undefined) {
removeVnodes(elm, oldCh, 0, oldCh . length - 1);
} else if (oldVnode . text !== undefined) {
api . setTextContent(elm, "");
}
} else if (oldVnode . text !== vnode . text) {
if (oldCh !== undefined) {
removeVnodes(elm, oldCh, 0, oldCh . length - 1);
}
api . setTextContent(elm, vnode . text);
}
hook ?. postpatch ?. (oldVnode, vnode);
}
⭐️ updateChildren:判断子节点的差异,这里也就是diff算法的核心,
diff算法是干什么的就显而易见了,就是为了对比虚拟节点中子节点的差异的,当然diff算法有好多版本,传统版本采用的是逐一比较,复杂度是O(n^3)显然复杂度较高,这里snabbdom采用的是同一层级进行比较(因为变化是局部的,大多数的变化都是在同一层的),这样大大节省了时间复杂度
源码:这里首先是分别定义了新旧节点的开始索引和结束索引
使用while循环,当新节点的开始索引和结束索引碰头了,或者旧节点的开始和结束索引碰头了,那我们就跳出循环,这里也就是说我们的diff算法进行完毕,已经比较出差异
单次循环:
-
新、旧开始节点相同,patchVnode(发现差异并更新),两者的
StartIdx都++ -
新、旧结束节点相同,patchVnode(发现差异并更新),两者的
EndIdx都-- -
新节点的开始节点、旧节点结束节点相同,patchVnode(发现差异并更新)
newStartIdx++oldEndIdx--修改结束节点的真实dom然后移动到最前面 -
新节点的结束节点、旧节点开始节点相同,patchVnode(发现差异并更新)
newEndIdx--oldStartIdx--修改开始节点的真实dom然后移动到最后面 -
如果都不满足则直接在旧节点中遍历寻找相同的旧节点,然后patchVnode(发现差异并更新)
newStartIdx++, 直接将新节点对应的真实dom插入到最前面
function updateChildren(
parentElm: Node,
oldCh: VNode[],
newCh: VNode[],
insertedVnodeQueue: VNodeQueue
) {
let oldStartIdx = 0;
let newStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
let oldStartVnode = oldCh[0];
let oldEndVnode = oldCh[oldEndIdx];
let newEndIdx = newCh.length - 1;
let newStartVnode = newCh[0];
let newEndVnode = newCh[newEndIdx];
let oldKeyToIdx: KeyToIndexMap | undefined;
let idxInOld: number;
let elmToMove: VNode;
let before: any;
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx]; // Vnode might have been moved left
} else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx];
} else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
api.insertBefore(
parentElm,
oldStartVnode.elm!,
api.nextSibling(oldEndVnode.elm!)
);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
idxInOld = oldKeyToIdx[newStartVnode.key!];
if (idxInOld === undefined) {
// `newStartVnode` is new, create and insert it in beginning
api.insertBefore(
parentElm,
createElm(newStartVnode, insertedVnodeQueue),
oldStartVnode.elm!
);
newStartVnode = newCh[++newStartIdx];
} else if (oldKeyToIdx[newEndVnode.key!] === undefined) {
// `newEndVnode` is new, create and insert it in the end
api.insertBefore(
parentElm,
createElm(newEndVnode, insertedVnodeQueue),
api.nextSibling(oldEndVnode.elm!)
);
newEndVnode = newCh[--newEndIdx];
} else {
// Neither of the new endpoints are new vnodes, so we make progress by
// moving `newStartVnode` into position
elmToMove = oldCh[idxInOld];
if (elmToMove.sel !== newStartVnode.sel) {
api.insertBefore(
parentElm,
createElm(newStartVnode, insertedVnodeQueue),
oldStartVnode.elm!
);
} else {
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
oldCh[idxInOld] = undefined as any;
api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!);
}
newStartVnode = newCh[++newStartIdx];
}
}
}
if (newStartIdx <= newEndIdx) {
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
addVnodes(
parentElm,
before,
newCh,
newStartIdx,
newEndIdx,
insertedVnodeQueue
);
}
if (oldStartIdx <= oldEndIdx) {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
Vue3中的diff算法
⭐️ 预处理(快捷路径)
- 借鉴了文本预处理的方式
就像这样:
新、旧前置节点比较和新、旧后置节点进行比较,如果有一端的前后索引碰头的话就代表已经diff完成不用进行核心的diff处理,这样在只添加或者删除节点,并且顺序没有改变的情况下节省了性能
- 具体操作方法:
前置节点: 我们可以建立索引 j,初始值为0。
后置节点: 因为可能两组节点长度不同,所以我们在尾端设置新旧两个指针
两个while循环: 让j递增,让newEnd和oldEnd递减,遇到不同的节点就停下,相同的节点就打补丁(调用patch进行更新)
跳出循环后判断:
-
j<=newEnd && j > oldEnd 说明有新节点,那么就在newEnd+1的索引位置添加新的节点
-
j>newEnd && j<=oldEnd1 说明有旧的节点被删除了,就卸载 j到oldEnd之间的节点。
⭐️ 判断是否要进行dom移动操作
预处理因为只是针对在节点顺序不改变的情况下,增添和删除节点的情况(比较理想化的情况).
构造一个source数组,长度为预处理之后剩余的未处理节点的数量,储存未处理节点在旧节点数组的索引,后面将会使用它计算出一个最长递增子序列,并用于辅助完成 DOM 移动的操作
填充source数组: 构造一个索引表,遍历未处理新节点数组,key为当前节点的key,value是新节点的索引值,
遍历旧节点对应的数组,oldVnode.key->查询索引表->newVnode索引->source[newVnode索引]=oldVNode索引
定义k(最大的key值)和move(是否需要移动): 如果当前key小于k那说明需要移动,大于k则不需要移动
问:为什么要通过最大的key值来判断是否需要移动呢
因为正常情况下oldNode的key值是递增的趋势,所以newNode中小于k的就说明应该在前面某个位置,所以我们需要用一个当前循环中最大的key值来的判断是否需要移动这个节点
问:为什么要维护一张索引表,而不直接去遍历旧节点一个个的去寻找呢
因为那样两层嵌套循环的复杂度为On^2,这样做能够将时间复杂度降到On,
⭐️ 如何移动元素
根据source数组计算一个最大递增子序列, 返回的是source数组中最长序列元素的索引,
重新对未处理的子节点数组索引编排,第一个元素为索引为0
在source和新节点数组末尾定义两个索引s和i,判断i==seq[s]
-
不相等(i--,s不变)
- 是-1,表示是新增节点,在i+newStart中挂载
- 不是-1,通过insert移动
-
相等(i--,s--)
Vue2和Vue3中diff算法的差异
-
vue2用的是双端diff算法,vue3使用的是快速diff算法
-
vue2中分5种情况(头对头,头对尾,尾对头,尾对尾) 进行判断然后移动、添加或者卸载节点,vue3经过预处理,生成 最长递增子序列判断是否需要移动、添加或者卸载, 然后对需要移动的节点进行移动
-
在 Vue 2 中,Diff 算法的时间复杂度为 O(n) 。而在 Vue 3 中,Diff 算法的时间复杂度为 O(n log N)。
diff算法的复杂度,其本质是一个什么算法
Vue2其实就是找dom树树之间的差异,Vue3其实就是最长递增子序列
diff算法的理解
是干什么的:vue2中snabbdom中对比子节点差异使用的一种算法(updateChildren),snabbdom把时间复杂度降低到了On,传统的diff算法是逐个进行比较的,复杂度为On^3
是什么:比较两个虚拟dom对象树的差异,双端比较,使用同层比较的方法,时间复杂度为On
详解:5种情况(上面提到的)
Vue2和Vue3中diff算法的差异
vue3中使用的是最长递增子序列的方法,二分查找+贪心算法(为什么不用动态规划是因为最长的子序列可能有很多个,动态规划只是能求出长度但无法保存有哪些最长子序列是最长的,所以这里用了贪心+二分)
diff算法的复杂度,其本质是一个什么算法
diff 算法的时间复杂度为 O(n),diff 算法本质上是一个比较两个虚拟 DOM 树差异的算法