vue2.x Diff算法解析

438 阅读2分钟

1. 当数据发生变化时,vue是怎么更新节点的?

Vue在初次渲染之后会根据真实DOM生成一颗virtual DOM,在之后的渲染过程中,当virtual DOM某个节点的数据改变后会生成一个新的Vnode,然后VnodeoldVnode作对比,发现有不一样的地方就直接修改在真实的DOM上,然后使oldVnode的值为Vnode

diff的过程就是调用名为patch的函数,比较新旧节点,一边比较一边给真实的DOM打补丁。


export function patch(oldVnode, vnode) {
    const isRealElement = oldVnode.nodeType;
    if (isRealElement) {
        const realNode = creatElm(vnode);
        oldVnode.parentNode.insertBefore(realNode, oldVnode.nextSibling);
        oldVnode.remove();
        return realNode
    } else {
        const el = patchVnode(oldVnode, vnode)
        return el;
    }
}

patch函数收两个参数,第一个参数表示的是老的节点,它可以是虚拟DOM,也可以是真实DOM,通常情况下,在组件的初次渲染的时候,oldVnode是真实DOM。

对于真实DOM的情况的处理办法也很简单,即:删除老的DOM节点,然后插入新的DOM,新的DOM做了模板解析,同步了vm的数据。

diff算法流程图:

img

Diff算法的核心在于两个都是虚拟DOM的时候。

function patchVnode(oldVnode, vnode) {
    if (!isSameVnode(oldVnode, vnode)) {
        // 不是一个节点
        const newEl = creatElm(vnode)
        oldVnode.el.parentNode.replaceChild(newEl, oldVnode.el)
        return newEl
    }
    // 复用节点
    const el = vnode.el = oldVnode.el;
    // 处理文本节点
    if (!oldVnode.tag) {
        if (el.textContent !== vnode.text) {
            el.textContent = vnode.text;
        }
    }
    // 对比属性
    patchProps(el, vnode.data, oldVnode.data)
    const oldChildren = oldVnode.children || [];
    const newChildren = vnode.children || [];
    if (oldChildren.length && newChildren.length) {
        updateChildren(el, oldChildren, newChildren)
    } else if (newChildren.length) {
        // 新的有 老节点没有
        mountChildren(el, newChildren);
    } else if (oldChildren.length) {
        // 新的有 老的有
        el.children.forEach(child => child.remove())
    }
    return el;
}

首先我们要知道Diff算法的比较方式:在采取diff算法比较新旧节点的时候,比较只会在同层级进行, 不会跨层级比较。

img

所以在isSameVnode(oldVnode, vnode)为false的时候,就直接删除原来的节点,替换为新的节点。当两个节点的Key和Tag是相同的时候,Vue会让新的节点复用老的节点(这个时候新的虚拟节点上其实还没有el,这样做就省去了创建新的节点的过程),随后比较文本内容,比较完毕之后进行子节点的比较。

function updateChildren(el, oldChildren, newChildren) {
    let oldStartIndex = 0;
    let newStartIndex = 0;

    let oldEndIndex = oldChildren.length - 1;
    let newEndIndex = newChildren.length - 1;

    let oldStartVnode = oldChildren[oldStartIndex];
    let oldEndVnode = oldChildren[oldEndIndex];

    let newStartVNode = newChildren[newStartIndex];
    let newEndVNode = newChildren[newEndIndex];

    function makeKeyByIndex(children) {
        const map = new Map();
        children.forEach((child, index) => map.set(child.key, index))
        return map
    }
    const map = makeKeyByIndex(oldChildren);
    while (newStartIndex <= newEndIndex && oldStartIndex <= oldEndIndex) {
        if (!oldStartVnode) {
            oldStartVnode = oldChildren[++oldStartIndex]
        } else if (!oldEndVnode) {
            oldEndVnode = [--oldEndIndex]
        } else if (isSameVnode(newStartVNode, oldStartVnode)) {
            patch(oldStartVnode, newStartVNode);
            oldStartVnode = oldChildren[++oldStartIndex];
            newStartVNode = newChildren[++newStartIndex];
        } else if (isSameVnode(newEndVNode, oldEndVnode)) {
            patch(oldEndVnode, newEndVNode);
            oldEndVnode = oldChildren[--oldEndIndex];
            newEndVNode = newChildren[--newEndIndex];
        } else if (isSameVnode(newStartVNode, oldEndVnode.el)) {
            patch(oldEndVnode, newStartVNode);
            el.insertBefore(oldEndVnode.el, oldStartVnode.el);
            oldEndVnode = oldChildren[--oldEndIndex];
            newStartVNode = newChildren[++newStartIndex];
        } else if (isSameVnode(newEndVNode, oldStartVnode)) {
            patch(oldStartVnode, newEndVNode);
            el.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling);
            newEndVNode = newChildren[--newEndIndex];
            oldStartVnode = oldChildren[++oldStartIndex];
        } else {
            // 乱序比对
            const moveChildIndex = map.get(newStartVNode.key);
            if (moveChildIndex !== undefined) {
                const moveChild = oldChildren[moveChildIndex];
                el.insertBefore(moveChild.el, oldStartVnode.el);
                oldChildren[moveChildIndex] = null;
                patch(moveChild, newStartVNode);
            } else {
                el.insertBefore(moveChild.el, creatElm(newStartVNode));
            }
            newStartVNode = newChildren[++newStartIndex];
        }
    }

    if (newStartIndex <= newEndIndex) {
        // 新的节点多了
        for (let i = newStartIndex; i <= newEndIndex; i++) {
            const newChild = creatElm(newChildren[i]);
            const anchor = oldEndVnode.el.nextSibling || null;
            el.insertBefore(newChild, anchor);
        }
    }

    if (oldStartIndex <= oldEndIndex) {
        // 老的节点多了
        for (let i = oldStartIndex; i <= oldEndIndex; i++) {
            oldChildren[i] && oldChildren[i].el.remove()
        }
    }
}

子节点的比对是比较困难的地方,vue2的算法diff算法使用的是双指针比对,也就是:oldStartIndex,newStartIndex,oldEndIndex,newEndIndex,分别指向了新头新尾和老头老尾。vue把节点分为了5种情况,分别是新头=老头、新尾=老尾、新头=老尾、新尾=老头以及乱序。

新头=老头、新尾=老尾是比较好理解的,这是比较常规的头头和尾尾比较,当比对成功的时候,就执行让新老节点执行patch方法,递归比较新老节点的子节点或者文本内容,这也就说明Vue 的diff 算法是一种深度优先的算法。

常规比较

img

img

新尾和老尾的比较也是大同小异。

交叉比较

当新头=老尾或者新尾=老头的时候

img

就会进行新节点的头部与老节点的尾部比较:

img

比对完毕后,把老节点的A插入到老节点中的指向最后元素的指针指向的下一个,然后新头向后移动,老尾向前移动

img

乱序比对

以上都是比较有规律的情况,还有一种就是乱序比对了,当两个节点不满足上面的规律的时候就会进行乱序比对。

img

乱序比对的时候,Vue会先为老节点生成一份map映射表,新的节点会在映射表中查找是否有对应的key,有的话就会直接把对应地老节点移动到开头的位置

img

如果没有,就会根据新的虚拟节点创建真实DOM,插入到老节点头指针指向的节点的前一个

img

在循环比对完毕之后,Vue会对新老节点进行最后一步的处理:

    if (newStartIndex <= newEndIndex) {
        // 新的节点多了
        for (let i = newStartIndex; i <= newEndIndex; i++) {
            const newChild = creatElm(newChildren[i]);
            const anchor = oldEndVnode.el.nextSibling || null;
            el.insertBefore(newChild, anchor);
        }
    }

    if (oldStartIndex <= oldEndIndex) {
        // 老的节点多了
        for (let i = oldStartIndex; i <= oldEndIndex; i++) {
            oldChildren[i] && oldChildren[i].el.remove()
        }
    }

也就是删除老节点多余的,添加新节点新增的。

img

至此Vue2.xDIff算法介绍完毕

参考

详解vue的diff算法 juejin.cn/post/684490…