Vue源码学习笔记 | diff 算法

329 阅读2分钟

原理

Diff 算法是一种对比算法。对比两者是旧虚拟 DOM 和新虚拟 DOM,对比出是哪个虚拟节点更改了,找出这个虚拟节点,并只更新这个虚拟节点所对应的真实节点,而不用更新其他数据没发生改变的节点,实现精准地更新真实 DOM,进而提高效率。

vue2 采用双指针的方式来比较两个节点

特点

  1. 平级比对,不跨级
  2. 两个节点不是同一个节点直接替换
if (!isSameVnode(oldVNode, vnode)) {
    return oldVNode.el.parentNode.replaceChild(createElm(vnode), oldVNode.el);
};
  1. 判断是否是同一个节点(判断 tag 和 key)
function isSameVnode(vnode1,vnode2) {
    return vnode1.tag === vnode2.tag && vnode1.key === vnode2.key;
}
  1. 比较属性是否有差异,复用节点属性
  2. 节点比对完成后比对儿子节点

比对思路

  1. 头头比对,从左往右比较头结点,尾指针位置在节点尾部保持不动,头指针比对完成后往前移动一位 ++
  2. 尾尾比对,从右往左比较尾结点,头指针位置在节点头部保持不动,尾指针比对完成后往后移动一位 --
  3. 交叉比对,分为老头新尾比对、老尾新头比对,如果比对相同则老头结点往尾结点插入或老尾结点往老头结点前插入
  4. 头尾指针相遇则停止对比
  5. 乱序比对,根据老的做一个映射表,用新结点的去映射表找,找到则移动到最新的老头结点前,标记找的老结点的位置为 undefined,找不到则添加到最新的老头结点前面,最后多余的删除

实现

function patchVnode(oldVNode, vnode) {
    if (!isSameVnode(oldVNode, vnode)) {
        return oldVNode.el.parentNode.replaceChild(createElm(vnode), oldVNode.el);
    };

    // 文本判断
    let el = vnode.el = oldVNode.el; // 复用老结点元素
    if (!oldVNode.tag) { // 是文本
        if (oldVNode.text !== vnode.text) { // 文本内容不一致
            el.textContent = vnode.text; // 替换为新文本
        }
    }

    // 比较属性是否有差异,复用节点属性
     patchProps(el, oldVNode.data, vnode.data);

    /* 比较儿子节点
     * 1.一方有儿子,一方没儿子
     * 2.双方都有儿子
     */
     let oldChildren = oldVNode.children || [];
     let newChildren = vnode.children || [];

     if (oldChildren.length > 0 && newChildren.length > 0) { // 双方都有儿子
         // 完整的diff算法
         updateChildren();
     } else if (newChildren.length > 0) { // 新的有儿子,老的无,挂载即可
         // 直接挂载上去
         mountChildren(el, newChildren);
     } else if (oldChildren.length > 0) { // 新的无,老的有,删除即可
         el.innerHTML = '';
     }

     return el
}
function isSameVnode(vnode1,vnode2) {
    return vnode1.tag === vnode2.tag && vnode1.key === vnode2.key;
}

 function mountChildren(el, newChildren) {
     for (let i = 0; i < newChildren.length;i++) {
         let child = newChildren[i];
         el.appendChild(createElm(child)); // 插入真实dom createElm 是创建真实dom的
     }
 }



/** 该方法处理标签样式及属性 **/
function patchProps(el, oldProps, props) {
    let oldStyles = oldProps.style || {};
    let newStyles = props.style || {};
    for (let key in oldStyles) { // 对比新老样式
        if (!newStyles[key]) {
            el.style[key] = ''; // 新的样式里没有老样式则删除样式
        }
    }
    // 对比新老属性
    for (let key in oldProps) {
        if (!props[key]) {
            el.removeAttribute(key); // 新的结点没有该属性则删除
        }
    }

    // 新结点覆盖操作
    for (let key in props) {
        if (key === 'style') { // 样式操作
            for (let styleNamee in props.style) {
                el.style[styleName] = props.style[styleName];
            }
        } else { // 属性操作
            el.setAttribute(key, props[key]
        }
    }
}

/** 完整diff算法, 双方都有子节点时 **/
function updateChildren(el, oldChildren, newChildren) {
    /* 优化策略,提升性能
     * 1.vue2采用双指针的方式来比较两个节点
     * 特点
     * 1)头头比对,从左往右比较头结点,尾指针位置在节点尾部保持不动,头指针比对完成后往前移动一位 ++
     * 2)尾尾比对,从右往左比较尾结点,头指针位置在节点头部保持不动,尾指针比对完成后往后移动一位 --
     * 3)交叉比对,分为老头新尾比对、老尾新头比对,如果比对相同则老头结点往尾结点插入或老尾结点往老头结点前插入
     * 4)头尾指针相遇则停止对比
     * 5)乱序比对,根据老的做一个映射表,用新结点的去映射表找,找到则移动到最新的老头结点前,标记找的老结点的位置为undefined,找不到则添加到最新的老头结点前面,最后多余的删除
     */

    // 定义新老头尾指针
    let oldStartIndex = 0;
    let newStartIndex = 0
    let oldEndIndex = oldChildren.length - 1;
    let newEndIndex = newChildren.length - 1;

    // 获取新老结点的头尾结点
    let oldStartVnode = oldChildren[0];
    let newStartVnode = newChildren[0];
    let oldEndVnode = oldChildren[oldEndIndex];
    let newEndVnode = newChildren[newEndIndex];

    let map = makeIndexByKey(oldChildren); // 生成映射表,用于乱序比对

    // 双方有一方头指针大于等于尾部指针则停止循环
    while(oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {

       if (!oldStartVnode) {
           oldStartVnode = oldChildren[++oldStartIndex];
       } else if (!oldEndVnode) {
           oldEndVnode = oldChildrenp[--oldEndIndex];
       } else if (isSameVnode(oldStartVnode, newStartVnode)) {  // 从左到右比较结点 尾尾比对
            patchVnode(oldStartVnode, newStartVnode); // 相同结点则递归执行子结点

            // 头结点往前移
            oldStartVnode = oldChildren[++oldStartIndex];
            newStartVnode = newChildren[++newStartIndex];
        } else if (isSameVnode(oldEndVnode, newEndVnode)) {  // 从右到左比较 头头比对
             patchVnode(oldEndVnode, newEndVnode); // 相同结点则递归执行子结点

            // 尾结点往后移
            oldEndVnode = oldChildren[--oldEndIndex];
            newEndVnode = newChildren[--newEndIndex];
        } else if (isSameVnode(oldEndVnode, newStartVnode)) { // 交叉比对,老尾新头,
            patchVnode(oldEndVnode, newStartVnode);

              // 结点相同,旧的老尾移动到新的老头结点前
            el.insertBefore(oldEndVnode.el, oldStartVnode.el)

            // 老尾结点往后移 --
            oldEndVnode = oldChildren[--oldEndIndex];
            // 新头结点往前移 ++
            newStartVnode = newChildren[++newStartIndex]
        } else if (isSameVnode(oldStartVnode, newEndVnode)) { // 交叉比对,老头新尾,
            patchVnode(oldStartVnode, newEndVnode);

              // 结点相同,旧的老头移动到新的老尾结点后
            el.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling)

            // 老头结点往前移 ++
            oldStartVnode = oldChildren[++oldStartIndex];
            // 新尾结点往后移 --
            newEndVnode = newChildren[--newEndIndex]
        } else {  // 乱序比对
            let maveIndex = map[newStartVnode.key];
            if (moveIndex !== undefined) { // 如果新节点在映射表上
              let moveVnode  oldChildren[moveIndex]; // 找到对应的结点
              el.insertBefore(moveVnode.el, oldStartVnode.el); // 移动到最新的老头结点前
              oldChildren[moveIndex] = undefined; // 表示结点移动走了
              patchVnode(moveVnode, newStartVnode); // 比对属性和子结点
            } else {
                // 移动到最新的老开始结点前
                el.insertBefore(createElm(newStartVnode), oldStartVnode.el)
            }

            newStartVnode = newChildren[++newStartIndex]; // 指针进行往前推
            }
    }

    // 新增情况,如果老结点已经前后指针重合, 说明新结点的前指针<=尾指针, 新节点多余
    if (newStartIndex <= newEndIndex) {
        for (let i = newStartIndex;i <= newEndIndex;i++) {
            let child = createElm(newChildren[i]);
            // el.appendChild(child);
            // 获取插入的下一个元素结点,如果是往前加则anchor不为null
            let anchor = newChildren[newEndIndex + 1] ? newChildren[newEndIndex + 1].el : null;
            el.insertBefore(child, anchor); // 巧妙点,如果anchor为null则是appendChild的效果
        }
    }

    // 删除情况,即新结点指针重合了,老结点多余
    if (oldStartIndex <= oldEndIndex) {
         for (let i = oldStartIndex;i <= newEndIndex;i++) {
            if (oldChild[i]) {
              let child = oldChild[i].el;
              el.removeChild(child);
            }
        }
    }

    // 生成映射表,用于乱序比对
    function makeIndexBykey(children) {
        let map = {};
        children.forEach((child, index)=>{
            map[child.key] = index;
        })
        return map;
    }
}

参考

# 15张图,20分钟吃透Diff算法核心原理,我说的!!!
# 实现完整diff算法