带你一行一行手写Vue2.0源码系列(五) -Diff算法原理

462 阅读6分钟

前言

再看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'),
]);

1634087437(1).png

patch

patch函数在vue源码中是diff算法的开始,也是将虚拟Vnode挂在到页面的开始,也称作为上树。

可以先看下这张流程图: 微信图片_20211011094426.png 阶段说明:

  1. 如果oldVnode是一个真实DOM元素,就要创建空的Vnode。(这种情况在第一次渲染时会出现)
  2. 如果如果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
}

可以看到我们之前测试的结果:

1634088964(1).png

patchVnode

阶段说明:

  1. 如果oldVnode和newVnode指向的是同一个对象,直接返回。
  2. 如果newVnode是一个文本节点,直接将oldVnode的真实dom 替换。
  3. 如果oldVnode是文本节点,newVnode不是文本节点,直接将老元素清空,将新元素的子元素依次加入。
  4. (最复杂的情况)如果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。

1634173655(1).png

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);
}

1634260808(1).png

点击改变后:

1634260816(1).png

vue2.0 和 vue3.0系列文章(后续更新)