详解diff算法
1. diff算法简介
当我们修改页面的时候,如果要整个重新渲染DOM,那开销是非常大的,因此在Vue的官方文档中也指出了,它会采用一种虚拟DOM的算法,利用key去最大限度的减少动态元素。
2. 虚拟DOM
所谓的虚拟DOM就是用一种数据结构,保存DOM元素的信息。它能够告诉Vue你要渲染出来的元素是什么样的。
使用模板编译,一个真实的DOM可以变为一个虚拟的DOM。
真实的DOM:
<div>
<p>我是p元素</p>
<div>
我是div元素
</div>
</div>
转化为虚拟的DOM:
const Vnode = {
sel: 'div', //选择器
data: {}, // 储存节点属性,比如key,prop,class等。
children: [
{
sel: 'p',
text: '我是p元素',
data: {}
},
{
sel: 'div',
text: '我是div元素',
data: {}
}
]
}
我们可以通过模板引擎将真实的DOM转化为虚拟DOM,同时也可以用createElement方法去根据虚拟DOM创建一个真实的DOM节点。之后会介绍createElm这个函数。
3. diff的比较方式
在采取diff算法时,只会针对同层的元素去精细化比较。
原先是这样的结构
<div>
<div>
我是div元素
</div>
<p>
我是p元素
</p>
</div>
之后修改为
<div>
<div>
我不是div元素
</div>
<span>
我不是p元素
</span>
</div>
原先的p元素只会与之后的span元素进行比较,而不会和div进行比较,这是diff算法的一个特点。
4. diff流程图
5. diff源码(核心代码)
1. 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]();
if (!isVnode(oldVnode)) {
oldVnode = emptyNodeAt(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;
};
patch函数就是diff算法的核心,patch接受两个参数,第一个参数是老节点,第二个参数是新节点。这个老节点可以是DOM元素或者虚拟节点,因为第一步就是判断老节点是否是虚拟节点,如果不是虚拟节点,那就会将其转化成虚拟节点。
第二步就是sameVnode函数进行判断,新老节点的sel,data.is, key是否一致,如果一致那就是相同节点,否则就是不同节点。
如果是相同节点就会进入patchVnode函数进行比较,这就是精细化比较。
如果不是相同节点,就会暴力创建节点,我们可以忽视关于insertedVnodeQueue以及hook,都是有在diff算法中保存节点状态的作用,并不是核心算法。
第三步使用createElm创建节点,同时在父节点不是null的时候,也就是老节点不为空,删除老节点。插入新节点。 插入用的其实就是document.insertBefore函数。
简单的看一下createElm函数:
function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
let i: any;
let data = vnode.data;
if (data !== undefined) {
const init = data.hook?.init;
if (isDef(init)) {
init(vnode);
data = vnode.data;
}
}
const children = vnode.children;
const sel = vnode.sel;
if (sel === "!") {
if (isUndef(vnode.text)) {
vnode.text = "";
}
vnode.elm = api.createComment(vnode.text!);
} else if (sel !== undefined) {
// Parse selector
const hashIdx = sel.indexOf("#");
const dotIdx = sel.indexOf(".", hashIdx);
const hash = hashIdx > 0 ? hashIdx : sel.length;
const dot = dotIdx > 0 ? dotIdx : sel.length;
const tag =
hashIdx !== -1 || dotIdx !== -1
? sel.slice(0, Math.min(hash, dot))
: sel;
const elm = (vnode.elm =
isDef(data) && isDef((i = data.ns))
? api.createElementNS(i, tag, data)
: api.createElement(tag, data));
if (is.array(children)) {
for (i = 0; i < children.length; ++i) {
const ch = children[i];
if (ch != null) {
api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue));
}
}
} else if (is.primitive(vnode.text)) {
api.appendChild(elm, api.createTextNode(vnode.text));
}
const hook = vnode.data!.hook;
if (isDef(hook)) {
hook.create?.(emptyNode, vnode);
if (hook.insert) {
insertedVnodeQueue.push(vnode);
}
}
} else {
vnode.elm = api.createTextNode(vnode.text!);
}
return vnode.elm;
}
代码量看上去很多,但是核心代码就只有一步,api.createElement(tag, data));这里就是调用document.createElement()去创建元素。
判断选择器是否为!,如果是!那就是注释,就创建一个注释元素。
判断选择器是否为空,如果是空就进入else,直接创建一个文本元素。
最复杂的情况就是当选择器不为空,就是正常的节点元素,那就先解析data里的数据,然后创建elm,通过上文所说的document.createElement()。
当有children的时候,就把children里的每一项都当做一个新的Node,去递归的执行createElm函数就可以了,而children里的DOM元素被创建好了就要放在最初的elm元素之后,也就是上树。
2. patchVnode函数
function patchVnode(
oldVnode: VNode,
vnode: VNode,
insertedVnodeQueue: VNodeQueue
) {
const hook = vnode.data?.hook;
hook?.prepatch?.(oldVnode, vnode);
const elm = (vnode.elm = oldVnode.elm)!;
const oldCh = oldVnode.children as VNode[];
const ch = vnode.children as VNode[];
if (oldVnode === vnode) return;
if (vnode.data !== undefined) {
for (let i = 0; i < cbs.update.length; ++i)
cbs.update[i](oldVnode, vnode);
vnode.data.hook?.update?.(oldVnode, vnode);
}
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
} else if (isDef(ch)) {
if (isDef(oldVnode.text)) api.setTextContent(elm, "");
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
} else if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
} else if (isDef(oldVnode.text)) {
api.setTextContent(elm, "");
}
} else if (oldVnode.text !== vnode.text) {
if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
}
api.setTextContent(elm, vnode.text!);
}
hook?.postpatch?.(oldVnode, vnode);
}
在patchVnode当中,分为以下几步。
第一步判断
判断新老节点是否为一个对象,是的话就不用做任何操作。
如果新节点的text不是空的,且新老节点的text不一致,那就会移除老节点,同时将新节点的text插入进去。
如果新老节点的text一致,那同样不用做任何操作。
最复杂的就是当新节点的text不为空,这就意味着新节点有children,可能是一个复杂结构。
进行第二步判断
如果新老节点都有children,那么就要执行整个diff算法中最复杂的updatechildren函数。
否则就根据不同情况将老节点的text修改为新节点的text。
3. updateChildren函数
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 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];
}
}
if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
if (oldStartIdx > oldEndIdx) {
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
addVnodes(
parentElm,
before,
newCh,
newStartIdx,
newEndIdx,
insertedVnodeQueue
);
} else {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
}
updateChildren函数可以说是非常复杂,它用了4个指针,分别是新老节点的首末位元素,对应的用了4个虚拟节点,也就是4个指针指向的节点。
下文我用新前,新后,旧前,旧后来指代这4个新旧节点的首末指针。
在循环当中,用了4种比较方式:
1、新前与旧前比较,两个指针所指节点相同则两个指针+1,继续遍历。
2、新后与旧后比较,两个指针所指节点相同则两个指针-1,继续遍历。
3、新后与旧前比较,两个指针所指节点相同新后指针-1,旧前指针+1,旧前的节点插入到旧后之后的下一个节点,继续遍历。
4、新前与旧后比较,两个指针所指节点相同新前指针+1,旧后指针-1,旧后的节点插入到旧前之前的前一个节点,继续遍历。
而如果都没有命中,就将所有旧节点全部用哈希表存储下来。
此时处理的是新前节点,从储存的哈希表中寻找,新前节点是否在老的节点当中。
如果在:那么就要移动这个旧节点,把这个节点移动到旧前节点之前。
如果不在:那么只需要先将新前节点插入到目前处理的旧前节点之前即可。
由于处理的是新前节点,因此无论如何,这个节点都处理完毕了,那么就要后移新前指针。
而在任何处理节点的过程当中,你都需要递归的去执行patchVNode去打补丁。因为children里的元素,也有可能还有children。算法只是把其当做了一个整体去插入移动。
最后,在循环结束后,如果新前与新后节点之中还有元素,那就要一个个的插入到新后+1的位置之前。
而如果在旧前与旧后之间还有元素,那就直接将他们删除即可。
其实总结下来就是: 所有移动插入的过程,都是以新节点为基准,让老节点以最小的代价变为新的节点,因此在移动的过程当中,如果觉得很难记住,哪个节点要移动到哪个节点,只需要比对新节点的相对位置,然后将旧节点对应的节点移动到新节点的相对位置上即可。