前言
再看VUE源码的过程中稍微理解了一下这个牛逼的diff算法,据说能最少量更新DOM,提高页面的性能。
虚拟节点Vnode
<div key="1">message</div>
与之对应的Vnode
{
sel:'div',
children:[],
data:{
key:1
},
key:1,
elm:elment,
text:'message'
}
Vnode中的elm为节点的真实DOM,初始值为null,在vue源码中在patch上树的过程中会将当前节点的真实DOM赋值给elm属性;children为当前元素的子元素,其子元素也是一个Vnode,这样一层层得嵌套就完成了对真实DOM的映射。而我们的diff算法就是建立在Vnode上的,通过比较新老Vnode的差异,然后更新到真正的DOM试图上。
这里实现一个Vnode生成函数:
//sel:标签名 data:属性对象 children:子元素 text:文本节点的文本 elm:节点真实DOM
function vnode(sel, data, children, text, elm) {
let key = data.key || undefined;
return {
sel, data, children, text, elm, key
}
}
h函数
在vue源码中编译的过程中首先会将我们的模版编译,生成render函数,在render中的其实就调用了一个h函数来生成Vnode。
这里完成我们自己得h函数:
function h(sel, data, c) {
if (typeof c == 'string' || typeof c == 'number') {//如果是文本节点
return vnode(sel, data, undefined, c, undefined);
} else if (Array.isArray(c)) {//如果是个数组,表示有子元素
let children = [];
for (let i = 0; i < c.length; i++) {
children.push(c[i]);
}
return vnode(sel, data, children, undefined, undefined);
} else {//最后一种情况就是只有一个子元素,可以直接传入一个vnode对象
return vnode(sel, data, [c], undefined, undefined)
}
}
测试用例:
let vnode1 = h('div', {}, [
h('h1', { key: 'A' }, 'A'),
h('h1', { key: 'B' }, 'B'),
h('h1', { key: 'D' }, 'D'),
]);
patch
patch函数在vue源码中是diff算法的开始,也是将虚拟Vnode挂在到页面的开始,也称作为上树。
可以先看下这张流程图: 阶段说明:
- 如果oldVnode是一个真实DOM元素,就要创建空的Vnode。(这种情况在第一次渲染时会出现)
- 如果如果oldVnode和newVnode的key和sel(标签名)都相同,就需要进行pathVnode精细化比较,否则就直接暴力拆除旧,放上新的。
function patch(oldVnode, newVnode) {
if (!oldVnode.sel) {//如果老节点不是个虚拟节点,就需要手动创建虚拟节点
oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, undefined, undefined, oldVnode);
}
if (oldVnode.key == newVnode.key && oldVnode.sel == newVnode.sel) {//是同一个节点:key相同且标签名相同
patchVnode(oldVnode, newVnode);
} else {//否则就暴力拆除旧的,换新的
let newVnodeElm = createElement(newVnode);
if (newVnodeElm) {
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm);//在容器前插入dom
}
oldVnode.elm.parentNode.removeChild(oldVnode.elm);//删除老节点
}
}
createElement
createElment函数接收一个Vnode作为参数,目的就是在我们的Vnode节点的elm属性上加上真实的DOM元素,返回真实DOM。
function createElement(vnode) {
let domNode = document.createElement(vnode.sel);//创建真实DOM
if (vnode.text && (vnode.children === undefined || vnode.children.length == 0)) {//如果是文本节点
domNode.innerText = vnode.text;
} else if (Array.isArray(vnode.children) && vnode.children.length > 0) {//如果存在子元素
for (let i = 0; i < vnode.children.length; i++) {
let ch = vnode.children[i];
let chDom = createElement(ch);//递归创建子元素
domNode.appendChild(chDom);
}
}
vnode.elm = domNode;//给虚拟Vnode的elm挂在真实DOM
return vnode.elm;//返回真实DOM
}
可以看到我们之前测试的结果:
patchVnode
阶段说明:
- 如果oldVnode和newVnode指向的是同一个对象,直接返回。
- 如果newVnode是一个文本节点,直接将oldVnode的真实dom 替换。
- 如果oldVnode是文本节点,newVnode不是文本节点,直接将老元素清空,将新元素的子元素依次加入。
- (最复杂的情况)如果oldVnode和newVnode都有子元素,那就要进行updateChildren递归比较。
function patchVnode(oldVnode, newVnode) {//diff算法开始
if (oldVnode === newVnode) {//如果新老vnode是同一个节点
return;
} else if (newVnode.text) {//新节点是否有text,直接替换老节点
if (newVnode.text != oldVnode.text) {
oldVnode.text = newVnode.text;
oldVnode.children = undefined;
oldVnode.elm.innerText = newVnode.text;
}
} else {
if (oldVnode.children && oldVnode.children.length > 0) {//最复杂的情况,都有子元素
updateChildren(oldVnode.elm, oldVnode.children, newVnode.children);
} else {//老的有text
oldVnode.text = undefined;
oldVnode.elm.innerText = "";
oldVnode.children = [];
for (let i = 0; i < newVnode.children.length; i++) {
let dom = createElement(newVnode.children[i]);
oldVnode.children.push(newVnode.children[i]);
oldVnode.elm.appendChild(dom);
}
}
}
}
updateChildren
diff算法的精妙所在。
当新老Vnode都存在子节点时,diff算法定义了4种新老节点的命中方式,这4种命中方式也是我们正常使用中最常见的4种方式。
diff算法定义的4个指针,新前,旧前,新后,旧后。
命中1:旧前节点(oldVnode的第一个子元素)与新前节点(newVnode的第一个子元素)是否相等sameVnode。如果相等,旧前指针和新前指针下移。
命中2:旧后节点和新后节点是否相等。如果相等,旧前指针和新前指针上移。
命中3:新后节点和旧前节点是否相等。如果相等,旧前指针下移,新后节点上移。此时命中,需要将旧前指向节点移动到旧后节点的后面(旧后节点的下一个元素,而不是在后面不断累加,这里需要注意)。
命中4:新前节点和旧后节点是否相等。如果相等,旧后指针上移,新前节点下移。此时命中,需要将就后节点移动到就新的前面,同理。
如果4种都没命中,就需要循环遍历oldVnode的当前开始指针和结束指针之间的节点,如果没有就需要在oldVnode前新增这个节点。如果找到就需要移动位置,并且将原位置的oldVnode置位undefined。
function updateChildren(parentElm, oldCh, newCh) {//diff算法核心
let oldStartIdx = 0;//老节点开始指针
let newStartIdx = 0;//新节点开始指针
let oldEndIdx = oldCh.length - 1;//老节点结束指针
let newEndIdx = newCh.length - 1;//新节点结束指针
let oldStartVnode = oldCh[oldStartIdx];
let oldEndVnode = oldCh[oldEndIdx];
let newStartVnode = newCh[newStartIdx];
let newEndVnode = newCh[newEndIdx];
let keyMap = null;
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (checkSameVnode(oldStartVnode, newStartVnode)) {//新前和久前命中,指针后移
console.log("1命中")
patchVnode(oldStartVnode, newStartVnode);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (checkSameVnode(oldEndVnode, newEndVnode)) {//新后和久后,
console.log("2命中")
patchVnode(oldEndVnode, newEndVnode);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (checkSameVnode(oldStartVnode, newEndVnode)) {//新后和久前
console.log("3命中");
patchVnode(oldStartVnode, newEndVnode);//当3命中的时候,需要将新后指向的这个节点移动到久后的后面
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx];
} else if (checkSameVnode(oldEndVnode, newStartVnode)) {//新前和久后
console.log("4命中");
patchVnode(oldEndVnode, newStartVnode);//当4命中的时候,需要将新前指向节点移动到久前的前面
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx];
} else {//四种都没命中
if (!keyMap) {
keyMap = {};
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
let key = oldCh[i].key;
if (key) {
keyMap[key] = i;
}
}
}
let idxInOld = keyMap[newStartVnode.key];
if (idxInOld == undefined) {//是全新的项 需要添加
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm);
} else {//需要移动
let elmToMove = oldCh[idxInOld];//需要移动的项
patchVnode(elmToMove, newStartVnode);
oldCh[idxInOld] = undefined;
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm);
}
newStartVnode = newCh[++newStartIdx];
}
}
if (newStartIdx <= newEndIdx) {//如果老节点循环完毕,新的没有完毕 有新增
let before = newCh[newEndIdx + 1] ? newCh[newEndIdx + 1].elm : null;
console.log(before)
for (let i = newStartIdx; i <= newEndIdx; i++) {//批量添加newStartId和newEndIdx之间的节点
parentElm.insertBefore(createElement(newCh[i]), before);
}
} else if (oldStartIdx <= oldEndIdx) {//有删除
for (let i = oldStartIdx; i <= oldEndIdx; i++) {//批量删除newStartId和newEndIdx之间的节点
if (oldCh[i]) {
parentElm.removeChild(oldCh[i].elm);
}
}
}
}
结尾
大家可以创建个例子来看下这个更新:
<div id="app"></div>
<button onclick="change()">改变</button>
let vnode1 = h('div', {}, [
h('h1', { key: 'A' }, 'A'),
h('h1', { key: 'B' }, 'B'),
h('h1', { key: 'C' }, 'C'),
]);
let vnode2 = h('div', {}, [
h('h1', { key: 'A' }, 'A'),
h('h1', { key: 'B' }, 'B'),
h('h1', { key: 'C' }, 'C'),
h('h1', { key: 'D' }, 'D'),
]);
let app = document.getElementById('app');
patch(app, vnode1);//第一次挂载
function change() {//改变新旧vnode
patch(vnode1, vnode2);
}
点击改变后: