vue2.x源码解析 - 虚拟DOM

149 阅读4分钟

概述

平时我们学习vue或者react的源码都会注意到,内部DOM渲染都是用的虚拟DOM

虚拟DOM就是用JS对象来模拟DOM节点,当数据更新时,通过对比数据变化前后的状态,计算出视图中哪些地方需要更新,只更新需要更新的地方,而不需要更新的地方则不需关心,这样我们就可以尽可能少的操作DOM了。

为什么要用虚拟DOM,为了减少DOM操作带来的性能消耗,具体可以看下源码讲解 虚拟dom简介

这次主要讲一下虚拟DOM更新时的DIFF算法。

patch

Vue中,把DOM-Diff过程叫做patch过程。patch意为“补丁”,即指对旧的VNode修补,打补丁从而得到新的VNode

DOM的变动操作大概分三个:创建节点、删除节点和更新节点。

这个是内部的patch方法源码(src/core/vdom/patch.js):


function patch (oldVnode, vnode, hydrating, removeOnly) {

    ...

    // 旧的节点不存在,直接创建节点

    if (isUndef(oldVnode)) {

        // empty mount (likely as component), create new root element

        isInitialPatch = true

        createElm(vnode, insertedVnodeQueue)

    } else {

        const isRealElement = isDef(oldVnode.nodeType)

        // 相同节点,处理子集节点(更新或者删除)

        if (!isRealElement && sameVnode(oldVnode, vnode)) {

            // patch existing root node

            patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
            ...
        }

    }

    ...

}


function patchVnode (

    oldVnode,

    vnode,

    insertedVnodeQueue,

    ownerArray,

    index,

    removeOnly

) {

    ...

    // 新旧数据是否都是存在子节点的,存在的话会更新子节点,不存在则对比渲染。

    if (isUndef(vnode.text)) {

        if (isDef(oldCh) && isDef(ch)) {

            if (oldCh !== ch) 
                updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)

            } else if (isDef(ch)) {

    ...

}

updateChildren

假如我们现有一份新的newChildren数组和旧的oldChildren数组,内部的渲染策略是:

  • 先把newChildren数组里的所有未处理子节点的第一个子节点和oldChildren数组里所有未处理子节点的第一个子节点做比对,如果相同,那就直接进入更新节点的操作;

  • 如果不同,再把newChildren数组里所有未处理子节点的最后一个子节点和oldChildren数组里所有未处理子节点的最后一个子节点做比对,如果相同,那就直接进入更新节点的操作;

  • 如果不同,再把newChildren数组里所有未处理子节点的最后一个子节点和oldChildren数组里所有未处理子节点的第一个子节点做比对,如果相同,那就直接进入更新节点的操作,更新完后再将oldChildren数组里的该节点移动到与newChildren数组里节点相同的位置;

  • 如果不同,再把newChildren数组里所有未处理子节点的第一个子节点和oldChildren数组里所有未处理子节点的最后一个子节点做比对,如果相同,那就直接进入更新节点的操作,更新完后再将oldChildren数组里的该节点移动到与newChildren数组里节点相同的位置;

  • 最后四种情况都试完如果还不同,那就按照之前循环的方式来查找节点。

其过程如下图所示:

8.e4c85c40.png

读源码之前,我们先有这样一个概念:那就是在我们前面所说的优化策略中,节点有可能是从前面对比,也有可能是从后面对比,对比成功就会进行更新处理,也就是说我们有可能处理第一个,也有可能处理最后一个,那么我们在循环的时候就不能简单从前往后或从后往前循环,而是要从两边向中间循环。

那么该如何从两边向中间循环呢?请看下图:

15.e9bdf5c1.png


function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {

    let oldStartIdx = 0 // oldChildren开始索引

    let newStartIdx = 0 // newChildren开始索引

    let oldEndIdx = oldCh.length - 1 // oldChildren结束索引

    let oldStartVnode = oldCh[0] // oldChildren中所有未处理节点中的第一个

    let oldEndVnode = oldCh[oldEndIdx] // oldChildren中所有未处理节点中的最后一个

    let newEndIdx = newCh.length - 1 // newChildren结束索引

    let newStartVnode = newCh[0] // newChildren中所有未处理节点中的第一个

    let newEndVnode = newCh[newEndIdx] // newChildren中所有未处理节点中的最后一个

    let oldKeyToIdx, idxInOld, vnodeToMove, refElm

    // removeOnly is a special flag used only by <transition-group>

    // to ensure removed elements stay in correct relative positions

    // during leaving transitions

    const canMove = !removeOnly

    if (process.env.NODE_ENV !== 'production') {

        checkDuplicateKeys(newCh)

    }

    // 以"新前"、"新后"、"旧前"、"旧后"的方式开始比对节点

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {

        if (isUndef(oldStartVnode)) {

            // 如果oldStartVnode不存在,则直接跳过,比对下一个

            oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left

        } else if (isUndef(oldEndVnode)) {

            // 如果oldEndVnode不存在,则直接跳过,比对下一个

            oldEndVnode = oldCh[--oldEndIdx]

        } else if (sameVnode(oldStartVnode, newStartVnode)) {

            // 如果新前与旧前节点相同,就把两个节点进行patch更新

            patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)

            oldStartVnode = oldCh[++oldStartIdx]

            newStartVnode = newCh[++newStartIdx]

        } else if (sameVnode(oldEndVnode, newEndVnode)) {

            // 如果新后与旧后节点相同,就把两个节点进行patch更新

            patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)

            oldEndVnode = oldCh[--oldEndIdx]

            newEndVnode = newCh[--newEndIdx]

        } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right

            // 如果新后与旧前节点相同,就把两个节点进行patch更新,然后换位置

            patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)

            canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))

            oldStartVnode = oldCh[++oldStartIdx]

            newEndVnode = newCh[--newEndIdx]

        } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left

            // 如果新前与旧后节点相同,就把两个节点进行patch更新,然后换位置

            patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)

            canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)

            oldEndVnode = oldCh[--oldEndIdx]

            newStartVnode = newCh[++newStartIdx]

        } else {

            // 如果不属于以上四种情况,就进行常规的循环比对patch

            if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)

            idxInOld = isDef(newStartVnode.key)

            ? oldKeyToIdx[newStartVnode.key]

            : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)

            // 如果在oldChildren里找不到当前循环的newChildren里的子节点

            if (isUndef(idxInOld)) { // New element

                // 新增节点并插入到合适位置

                createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)

            } else {

                // 如果在oldChildren里找到了当前循环的newChildren里的子节点

                vnodeToMove = oldCh[idxInOld]

                // 如果两个节点相同

                if (sameVnode(vnodeToMove, newStartVnode)) {

                    // 调用patchVnode更新节点

                    patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)

                    oldCh[idxInOld] = undefined

                    canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)

                } else {

                    // same key but different element. treat as new element

                    createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)

                }

            }

            newStartVnode = newCh[++newStartIdx]

        }

    }

    if (oldStartIdx > oldEndIdx) {

        /**

        * 如果oldChildren比newChildren先循环完毕,

        * 那么newChildren里面剩余的节点都是需要新增的节点,

        * 把[newStartIdx, newEndIdx]之间的所有节点都插入到DOM中

        */

        refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm

        addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)

    } else if (newStartIdx > newEndIdx) {

        /**

        * 如果newChildren比oldChildren先循环完毕,

        * 那么oldChildren里面剩余的节点都是需要删除的节点,

        * 把[oldStartIdx, oldEndIdx]之间的所有节点都删除

        */

        removeVnodes(oldCh, oldStartIdx, oldEndIdx)

    }

}

看下对比VNode节点的方法:


function sameVnode (a, b) {

    return (

        a.key === b.key && (

            (

                a.tag === b.tag &&

                a.isComment === b.isComment &&

                isDef(a.data) === isDef(b.data) &&

                sameInputType(a, b)

            ) || (

                isTrue(a.isAsyncPlaceholder) &&

                a.asyncFactory === b.asyncFactory &&

                isUndef(b.asyncFactory.error)

            )

        )

    )

}

内部会优先根据key值来对比是否为同一个节点,这也是为什么写循环一定要加key的原因,能加快运行效率。

相关参考文献

vue源码-虚拟DOM篇