vue2 diff算法
先到语
在上一篇中,我们学习了vue2的vdom,并用snabbdom对比了使用vdom渲染和不使用vdom渲染时的区别。那今天,我们就来学习vdom中的核心API-diff算法。
diff 概述
vdom-diff算法的特点:
- 只比较同一层级,不跨级比较
- tag不相同,则直接删掉重建
- tag和key相同则认为两者相同,不做深度比较
知道了diff算法的这些特点,我们接下来就通过snabbdom的源码来看看具体的diff算法的流程是怎样的。
snabbdom 源码解读
vue2 的vdom是参考snabbdom来实现的,所以这里我们就直接通过snabbdom来学习vue2的diff算法。
在上一篇文章中,我们使用snabbdom做了一个vdom的demo,通过这个demo,大家有没有很好奇snabbdom是如何生成一个vnode的?它又是如何去对比新旧vnode的?
snabbdom如何生成一个vnode?
在上一节的demo中,我们看到了,是通过snabbdom的h函数来生成的,所以接下来我们找到snabbdom中的h函数,在h.ts中:找到h函数:
export function h(sel: string): VNode;
export function h(sel: string, data: VNodeData | null): VNode;
export function h(sel: string, children: VNodeChildren): VNode;
export function h(
sel: string,
data: VNodeData | null,
children: VNodeChildren
): VNode;
export function h(sel: any, b?: any, c?: any): VNode {
let data: VNodeData = {};
let children: any;
let text: any;
let i: number;
if (c !== undefined) {
if (b !== null) {
data = b;
}
if (is.array(c)) {
children = c;
} else if (is.primitive(c)) {
text = c.toString();
} else if (c && c.sel) {
children = [c];
}
} else if (b !== undefined && b !== null) {
if (is.array(b)) {
children = b;
} else if (is.primitive(b)) {
text = b.toString();
} else if (b && b.sel) {
children = [b];
} else {
data = b;
}
}
if (children !== undefined) {
for (i = 0; i < children.length; ++i) {
if (is.primitive(children[i]))
children[i] = vnode(
undefined,
undefined,
undefined,
children[i],
undefined
);
}
}
if (
sel[0] === "s" &&
sel[1] === "v" &&
sel[2] === "g" &&
(sel.length === 3 || sel[3] === "." || sel[3] === "#")
) {
addNS(data, children, sel);
}
return vnode(sel, data, children, text, undefined);
}
可以看到h函数最后返回的是一个vnode函数:
export function vnode(
sel: string | undefined,
data: any | undefined,
children: Array<VNode | string> | undefined,
text: string | undefined,
elm: Element | Text | undefined
): VNode {
const key = data === undefined ? undefined : data.key;
return { sel, data, children, text, elm, key };
}
vnode函数中返回的是一个对象,对象中有:sel(传入的dom对象),data(dom对象上的style,事件以及一些自定义属性等),children(dom 对象的子对象数组),text(如果dom对象不存在children,则渲染text的内容),elm(挂载的对象),key。
在生成vnode之后,要通过patch函数来渲染,那patch函数又是那里来的呢?在上一节的demo中我们可以看到patch函数是通过snabbdom的init函数返回出来的,所以我们找到源码中的init函数:
在init.ts中,找到init方法,我们可以看到init方法返回了patch函数:
return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node;
const insertedVnodeQueue: VNodeQueue = [];
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
//如果第一个元素不是vnode,就创建一个空的vnode,也就是:patch(container, vnode)这种调用方法
if (!isVnode(oldVnode)) {
oldVnode = emptyNodeAt(oldVnode);
}
//如果第一个元素是vnode,也就是:patch(vnode, newVnode)这种调用方法,并且这两次传入的vode的tag,key和sel相同,则执行patchVnode方法
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
//如果oldVnode和vnode不相同,则把之前的vnode删除掉,直接插入新的vnode
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;
};
patch 方法有两种调用方式分别是:patch(element,vnode)和patch(vnode,vnode),patch方法中的核心思想就是:先去判断是那种调用方式,如果patch(element,vnode)就先去创建一个空的vnode对象,然后比较两个vnode对象是否相等,如果相等,就去做patchVnode的操作,如果不相等,则直接删掉oldVnode,再根据vnode重建。
在patch方法中判断两个vnode是否相等的函数是:
function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
const isSameKey = vnode1.key === vnode2.key;//key是否相同
const isSameIs = vnode1.data?.is === vnode2.data?.is; //data是否相同
const isSameSel = vnode1.sel === vnode2.sel; //挂载的元素是否像否
return isSameSel && isSameKey && isSameIs; // 如果都相同,则返回true,否则返回false
}
如何对比新旧vnode
在上面的patch方法中,当我们确认两次的vnode相等之后,会去调用patchNode的方法。接下来我们来看看patchNode方法。
function patchVnode(
oldVnode: VNode, //旧vnode
vnode: VNode, //新vnode
insertedVnodeQueue: VNodeQueue
) {
//执行hook操作
const hook = vnode.data?.hook;
hook?.prepatch?.(oldVnode, vnode);
const elm = (vnode.elm = oldVnode.elm)!; //把旧的vnode的elm赋值给新vnode的elm
const oldCh = oldVnode.children as VNode[]; //旧vnode的children
const ch = vnode.children as VNode[]; //新vnode的Children
//如果新旧Vnode的children相同,则直接返回
if (oldVnode === vnode) return;
//hook操作
if (vnode.data !== undefined) {
for (let i = 0; i < cbs.update.length; ++i)
cbs.update[i](oldVnode, vnode);
vnode.data.hook?.update?.(oldVnode, vnode);
}
//isUndef判断是否为undefined
//如果新vnode的text为undefined(新Vnode的children一般有值)
if (isUndef(vnode.text)) {
//如果新Vnode和旧Vnode的children都有值,则进行updateChildren操作
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
} else if (isDef(ch)) {
//如果只有新的children有值,则直接把把之前的vnode的text清空,然后添加上新Vnode的children
if (isDef(oldVnode.text)) api.setTextContent(elm, "");
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
} else if (isDef(oldCh)) {
//如果只有旧的vnode children有值,则直接删掉旧的vnode的children,然后加上新Vnode的text的内容
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
} else if (isDef(oldVnode.text)) {
//如果只有旧的vnode的text有值,则直接清空elm的text内容
api.setTextContent(elm, "");
}
} else if (oldVnode.text !== vnode.text) { //如果新旧Vnode的text不相等
//如果只有oldchildren 不为空,则清空elm的children
if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
}
api.setTextContent(elm, vnode.text!);
}
hook?.postpatch?.(oldVnode, vnode);
}
在patchVnode方法中的核心思想就是:对比新旧vnode的children,如果新旧vnode都存在children的话,则执行updateChildren方法。否则则执行相应的添加和清空操作!
对比新旧vnode里面的关键方法是:updateChildren
function updateChildren(
parentElm: Node,
oldCh: VNode[],
newCh: VNode[],
insertedVnodeQueue: VNodeQueue
) {
//四个索引
let oldStartIdx = 0;
let newStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
let newEndIdx = newCh.length - 1;
//四个索引对应的节点
let oldStartVnode = oldCh[0];
let oldEndVnode = oldCh[oldEndIdx];
let newStartVnode = newCh[0];
let newEndVnode = newCh[newEndIdx];
//
let oldKeyToIdx: KeyToIndexMap | undefined;
let idxInOld: number;
let elmToMove: VNode;
let before: any;
//根据四个索引来做while 循环
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 as string];
//
if (isUndef(idxInOld)) {
// New element
api.insertBefore(
parentElm,
createElm(newStartVnode, insertedVnodeQueue),
oldStartVnode.elm!
);
} else {
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];
}
}
这个updateChildren里面写的比较复杂,但是没关系,我们搞清楚这个方法的核心思路就可以了!
如上图所示:
首先先进行olCh和newCh的四个索引节点的对比,如果命中了,就执行相应的操作,并且改变索引。如果四个索引节点的对比均没有命中的话,则把newch中的节点依次与oldCh中的每个节点做对比,看看是否能和oldCh中其他位置的节点对应,如果能对应上,也执行相应的操作,如果一个都没有对应上,则将newCh中的该节点按照新节点来操作。
结束语
今天主要是学习snabbdom中diff算法的实现流程。但是我们需要注意一点,不同的框架中diff算法的实现方式可能会有不同,比如说vue3中的diff算法就不再是snabbdom中的这种。所以我们主要是了解下diff算法的流程。 那么,下次见,好好学习,天天向上!