【Vue系列】vue数据状态更新时的差异 diff 及 patch 机制

176 阅读6分钟

比较我们的新旧节点树(Diff),update 更新视图,最终是将新产生的 VNode 节点树与老 VNode 进行一个 patch 的过程,比对得出「差异」,最终将这些「差异」更新到视图上。

核心 Diff 算法--patch

patch 是用来比较两颗树之间的差异,在更新相应的 DOM 节点,在 diff 算法中,只比较同一层级的 dom 节点,事件复杂度为O(n)

/**
* 核心patch算法,比较新旧node树的差异
*/
function patch(oldVnode, vnode, parentElm) {
    if (!oldVnode) {
        /* 当旧节点不存在,直接批量插入新节点树 */
        addVnodes(parentElm, null, vnode, 0, vnode.length - 1);
    } else if (!vnode) {
        /* 当新节点不存在,直接批量删除旧节点树 */
        removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);
    } else {
        if (sameVnode(oldVNode, vnode)) {
            /* 当新旧节点相同,进行 patchVNode 操作,对比两个 node 节点 */
            patchVnode(oldVNode, vnode);
        } else {
            /* 否则两个不同直接删除旧节点,插入新节点 */
            removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1);
            addVnodes(parentElm, null, vnode, 0, vnode.length - 1);
        }
    }
}

因为 patch 的主要功能是比对新旧两个 VNode 节点,将「差异」更新到视图上,所以入参有新老两个 VNode 以及父节点的 element 。

  1. 没有老 vnode,直接插入新 vnode
  2. 没有新 vnode,直接删除旧 vnode
  3. 新旧 vnode 节点都存在,如果当前新旧 vnode 相同,进行 patchVnode操作,对比新旧 vnode 的子节点,如果新旧 vnode 不相同,则直接删除旧 vnode,插入新 vnode

新旧vnode 节点是否相同,是根据 sameVnode 这个方法来判断的。

/**
* 比较两个节点是否是相同节点
*/
function sameVnode(a, b) {
    /* 判断key、tag、是否是注释节点、是否同时定义数据(或不定义)、同时满足当标签类型为 input 的时候 type 相同 */
    return (
        a.key === b.key &&
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        (!!a.data) === (!!b.data) &&
        sameInputType(a, b)
    )
}
 
 
/**
* 比较两个节点是否同为 input 标签且 type 相同
*/
function sameInputType(a, b) {
    if (a.tag !== 'input') return true
    let i
    const typeA = (i = a.data) && (i = i.attrs) && i.type
    const typeB = (i = b.data) && (i = i.attrs) && i.type
    return typeA === typeB
}

patchVnode 函数是在 sameVNode 函数执行结果为真时才触发执行的,对新旧两个 vnode 节点的子节点进行深度比较

/**
    * 深度比较两个节点,当节点的孩子节点不等时,调用 updateChildren 操作孩子节点
    */
function patchVnode(oldVnode, vnode) {
    /* 两个节点全等,不做改变,直接 return  */
    if (oldVnode === vnode) {
        return;
    }
 
    /* 当新老节点都是静态节点且 key 都相同时,直接将 componentInstance 与 elm 从老 VNode 节点“拿过来”即可 */
    if (vnode.isStatic && oldVnode.isStatic && vnode.key === oldVnode.key) {
        vnode.elm = oldVnode.elm;
        vnode.componentInstance = oldVnode.componentInstance;
        return;
    }
 
    /* 取新老节点的 elm ,以及它们的孩子节点集合 */
    const elm = vnode.elm = oldVnode.elm;
    const oldCh = oldVnode.children;
    const ch = vnode.children;
 
    /* 新节点是文本节点,直接设置文本 */
    if (vnode.text) {
        nodeOps.setTextContent(elm, vnode.text);
    } else {
        /* 新老节点的孩子节点都存在且不相等,调用 updateChildren 比较孩子节点 */
        if (oldCh && ch && (oldCh !== ch)) {
            updateChildren(elm, oldCh, ch);
        } else if (ch) {
            /* 只有新节点的孩子节点存在 */
            /* 老节点为文本节点,则首先清除其节点文本内容  */
            if (oldVnode.text) nodeOps.setTextContent(elm, '');
            /* 批量添加新节点 */
            addVnodes(elm, null, ch, 0, ch.length - 1);
        } else if (oldCh) {
            /* 只有旧节点的孩子节点存在,批量删除 */
            removeVnodes(elm, oldCh, 0, oldCh.length - 1)
        } else if (oldVnode.text) {
            /* 当只有老节点是文本节点的时候,清除其节点文本内容 */
            nodeOps.setTextContent(elm, '')
        }
    }
}

patchVnode 这个方法中,oldVnode 与 vnode的对比流程

  1. 拿出老 vnode 和新 vnode 两者的children: oldCh, ch

  2. 如果vnode是文本节点,直接操作真实dom, 设置文本节点为 vnode.text

  3. 如果vnode是元素节点

    2.1 如果 oldCh, ch 两者都存在且不相同,进入 updateChildren 流程

    2.2 否则如果只是 ch 存在,oldCh 不存在,那么直接操作真实 dom, 添加 ch 节点

    2.3 否则如果只是 oldCh 存在,ch 不存在,那么直接操作真实 dom, 删除 oldCh 节点

    2.4 否则如果只是 oldCh 是文本, ch 不存在,直接操作真实 dom 设置文本节点为空 ''

总结:patchVnode这个方法的主要作用是对比两个虚拟节点的子节点过程中去更新真实dom

接下来是,updateChildren 这个函数

/**
* 比对两个节点的孩子节点集合
*/
function updateChildren(parentElm, oldCh, newCh) {
    /* 定义 oldStartIdx、newStartIdx、oldEndIdx 以及 newEndIdx 分别是新老两个 VNode 的两边的索引 */
    /* 定义 oldStartVnode、newStartVnode、oldEndVnode 以及 newEndVnode 分别指向这索引对应的 VNode 节点 */
    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, idxInOld, elmToMove, refElm;
 
    // 只要有一组遍历完(开始索引超过结束索引)则跳出循环
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        /* 当 oldStartVnode 不存在时,直接更新索引以及对应的节点指向 */
        if (!oldStartVnode) {
            oldStartVnode = oldCh[++oldStartIdx];
        } else if (!oldEndVnode) {
            /* 当 oldEndVnode 不存在时,直接更新索引以及对应的节点指向 */
            oldEndVnode = oldCh[--oldEndIdx];
        } else if (sameVnode(oldStartVnode, newStartVnode)) {
            /* 当两个节点相同时,调用 patchVnode 并更新索引以及对应的节点指向 */
            patchVnode(oldStartVnode, newStartVnode);
            oldStartVnode = oldCh[++oldStartIdx];
            newStartVnode = newCh[++newStartIdx];
        } else if (sameVnode(oldEndVnode, newEndVnode)) {
            /* 当两个节点相同时,调用 patchVnode 并更新索引以及对应的节点指向 */
            patchVnode(oldEndVnode, newEndVnode);
            oldEndVnode = oldCh[--oldEndIdx];
            newEndVnode = newCh[--newEndIdx];
        } else if (sameVnode(oldStartVnode, newEndVnode)) {
            /* 当两个节点相同时,调用 patchVnode ,将 oldStartVnode 插入父节点最后并更新索引以及对应的节点指向 */
            patchVnode(oldStartVnode, newEndVnode);
            nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm));
            oldStartVnode = oldCh[++oldStartIdx];
            newEndVnode = newCh[--newEndIdx];
        } else if (sameVnode(oldEndVnode, newStartVnode)) {
            /* 当两个节点相同时,调用 patchVnode ,将 oldStartVnode 插入父节点最前面并更新索引以及对应的节点指向 */
            patchVnode(oldEndVnode, newStartVnode);
            nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
            oldEndVnode = oldCh[--oldEndIdx];
            newStartVnode = newCh[++newStartIdx];
        } else {
            /* 当以上条件都不满足,调用 createKeyToOldIdx 获取一个 key-索引 的 map 集合 */
            let elmToMove = oldCh[idxInOld];
            if (!oldKeyToIdx) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
            /* 取出对应 key 的节点索引,不存在则为 null  */
            idxInOld = newStartVnode.key ? oldKeyToIdx[newStartVnode.key] : null;
            if (!idxInOld) {
                /* 索引不存在,直接创建一个新的节点 */
                createElm(newStartVnode, parentElm);
                newStartVnode = newCh[++newStartIdx];
            } else {
                /* 索引存在 */
                elmToMove = oldCh[idxInOld];
if (sameVnode(elmToMove, newStartVnode)) {
                /* 两个节点相同,调用 patchVnode 将老节点集合中对应节点赋值为 undefined ,将节点插入 oldStartVnode 之前,更新对应索引 */
                    patchVnode(elmToMove, newStartVnode);
                    oldCh[idxInOld] = undefined;
                    nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm);
                    newStartVnode = newCh[++newStartIdx];
                } else {
                    /* 两节点不相同,直接创建新节点插入,更新对应索引 */
                    createElm(newStartVnode, parentElm);
                    newStartVnode = newCh[++newStartIdx];
                }
            }
        }
    }
    /* 终止条件, oldStartIdx > oldEndIdx 说明 newCh 中还有剩余节点,直接批量添加 */
    if (oldStartIdx > oldEndIdx) {
        refElm = (newCh[newEndIdx + 1]) ? newCh[newEndIdx + 1].elm : null;
        addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx);
    } else if (newStartIdx > newEndIdx) {
    /* 终止条件, newStartIdx > newEndIdx 说明 oldCh 中还有剩余节点,直接批量删除 */
        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }
}
/**
* 创建一个 key-索引 对应 map 表
*/
function createKeyToOldIdx (children, beginIdx, endIdx) {
    let i, key
    const map = {}
    for (i = beginIdx; i <= endIdx; ++i) {
        key = children[i].key
        if (isDef(key)) map[key] = i
    }
    return map
}

定义 oldStartIdx、newStartIdx、oldEndIdx 以及 newEndIdx 分别是新老两个 VNode 的两边的索引,定义 oldStartVnode、newStartVnode、oldEndVnode 以及 newEndVnode 分别指向这索引对应的 VNode 节点

  • 参数 parentElm:真实的dom元素,做为父节点,供更新children时去插入
  • 参数 oldCh:老的虚拟树的children
  • 参数 newCh: 新的虚拟树的children
  • oldStartIdx:老数组的开始的下标index
  • newStartIdx: 新数组的开始的下标index
  • oldEndIdx: 老数组的结束的下标index
  • newEndIdx :新数组的结束的下标index
  • oldStartVnode : 老数组的开始的节点
  • oldEndVnode : 老数组的结束的节点
  • newStartVnode :新 vnode 树的开始的节点
  • newEndVnode :新 vnode 树的结束的节点
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx)

在这个过程中,oldStartIdx、newStartIdx、oldEndIdx 以及 newEndIdx 会逐渐向中间靠拢

  1. 首先当 oldStartVnode 或者 oldEndVnode 不存在的时候,oldStartIdx 与 oldEndIdx 继续向中间靠拢,并更新对应的 oldStartVnode 与 oldEndVnode 的指向
if (!oldStartVnode) {
    oldStartVnode = oldCh[++oldStartIdx];
} else if (!oldEndVnode) {
    oldEndVnode = oldCh[--oldEndIdx];
}
  1. 接下来,是将 oldStartIdx、newStartIdx、oldEndIdx 以及 newEndIdx 两两比对的过程
else if (sameVnode(oldStartVnode, newStartVnode)) {
    patchVnode(oldStartVnode, newStartVnode);
    oldStartVnode = oldCh[++oldStartIdx];
    newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
    patchVnode(oldEndVnode, newEndVnode);
    oldEndVnode = oldCh[--oldEndIdx];
    newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
    patchVnode(oldStartVnode, newEndVnode);
    nodeOps.insertBefore(parentElm, oldStartVnode.elm,         
    nodeOps.nextSibling(oldEndVnode.elm));
    oldStartVnode = oldCh[++oldStartIdx];
    newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
    patchVnode(oldEndVnode, newStartVnode);
    nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
    oldEndVnode = oldCh[--oldEndIdx];
    newStartVnode = newCh[++newStartIdx];
}

updateChildren 新旧两个 vnode 数组,都有双端指针,两端指针向中间靠拢,直到某个数组的两端指针相交则退出循环。

  • 首先,比较新旧两个子节点的开头是否相同,若相同,深度递归进行 patchVnode 流程
  • 其次,比较新旧两个节点的结尾是否相同,如果相同,深度递归进行 patchVnode 流程
  • 比较旧节点的开头和新节点的结尾是否相同,相同则深度递归进行 patchVnode 流程
  • 比较旧节点的结尾和新节点的开头是否相同,相同则深度递归进行 patchVnode 流程

如果以上4种情况都不符合,那就基于旧数组遍历一次,拿到每个节点的 key 和 index,就是oldKeyToIdx: {key1: 0, key2: 1}这种情况。然后去新数组首个节点开始匹配,匹配到就进行递归 patchVnode 流程,没匹配到就进行创建新节点,插入到真实 dom 节点里面去。

当循环结束,此时要么是旧数组相交,要么是新数组相交,只有这两种情况:

  1. 如果老的 VNode 先于新的 VNode 遍历结束,那么要根据相交的位置插入新 vnode 剩余的未遍历到节点
  2. 如果新的 VNode 先于老的 VNode 遍历结束,那么要删除老 vnode 剩余的未遍历到的节点

至此diff流程结束。

有问题欢迎指出,谢谢!