[Vue源码] 图解Vue源码 DOM-diff算法中的子节点更新策略

本文详细介绍了Vue框架中DOM-Diff算法的子节点更新策略,通过四种命中策略优化了更新过程,降低了时间复杂度。内容包括新旧虚拟DOM节点的比较,以及不同命中策略下的节点更新、移动和删除操作,确保视图与数据的同步。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

介绍

Vue是数据驱动视图的,数据发生变化视图就要随之更新,在更新视图的时候难免要操作DOM,而操作真实DOM非常耗费性能。

Vue通过JS模拟出一个称为虚拟DOM节点来代表一个真实的DOM节点。当数据发生变化时,我们对比变化前后的虚拟DOM节点,通过DOM-Diff算法计算出需要更新的地方,然后去更新需要更新的视图。

本文主要介绍DOM-diff算法中的子节点更新策略。

首先看下面的流程图的位置A,当我们发现新旧的虚拟DOM节点是同一个节点时(key值相同),我们会对这两个节点进行精细化比较。

在这里插入图片描述

然后进入了更新节点的流程,现在要根据新的虚拟DOM节点来修改旧的虚拟DOM节点对应的真实DOM节点。看下面流程图的B位置,当我们的新旧虚拟DOM节点都有子节点的时候,情况就很复杂了,我们需要去比较新旧虚拟节点的子节点。也就是本文要介绍的子节点更新策略。

下面的流程图展示的过程是更新节点的过程,是源码中patchVnod函数做的事情。

在这里插入图片描述

子节点更新

这里先定义一些变量

newChildren:数组,里面是新虚拟DOM节点中包含的子虚拟节点。

oldChildren: 数组,里面是旧虚拟DOM节点中包含的子虚拟节点。

新前:指针,指向新虚拟节点的第一个未处理的子虚拟节点。

新后:指针,指向新虚拟节点的最后一个未处理的子虚拟节点。

旧前:指针,指向旧虚拟节点的第一个未处理的子虚拟节点。

旧后:指针,指向旧虚拟节点的最后一个未处理的子虚拟节点。

子节点更新策略的原则就是:如果newChildren中的某个子节点在oldChildren数组中存在,那么就进行位置移动和节点更新。如果newChildren中的某个子节点在oldChildren数组中找不到,那么我们就创建一个新的DOM节点插入到合适位置。如果oldChildren数组中存在某个元素是newChildren数组中没有的,那么我们就把它对应的真实DOM节点删除了。

注意:所有都是对照新虚拟节点,来操作旧虚拟节点对应的真实DOM。这样就可以更新视图,使视图和新的虚拟节点一样了。

上面的原则,可以简单的通过双层for循环来做,外层循环newChildren数组,内层循环oldChildren数组,每循环外层newChildren数组里的一个子节点,就去内层oldChildren数组里找看有没有与之相同的子节点。

但是上面这样做会很乱,而且时间复杂度很高。

DOM-diff算法中对子节点更新设计了很好的策略,下面具体讲解。

DOM-diff算法中的子节点更新策略

我们要在旧的虚拟节点中查找是否有新的虚拟节点,然后进行更新,新增或删除操作,那么DOM-diff算法是怎么查找的呢?

四种命中策略查找:

策略1:先看看 新前和旧前 是否是同一个节点,如果是,表示策略1命中,就去更新,新前这个节点就处理好啦。

策略2:再看 新后与旧后 是否是同一个节点,如果是,表示策略2命中,就去更新,新后这个节点就处理好啦。

策略3:再看 新后与旧前 是否是同一个节点,如果是,表示策略3命中,就去更新,新后这个节点就处理好啦。

策略4:再看 新前与旧后 是否是同一个节点,如果是,表示策略4命中,就去更新,新前这个节点就处理好啦。

如果策略1命中,那么根据新前去更新旧前,并移动指针。

如果策略1没有命中,策略2命中,则根据新后去更新旧后,并移动指针。

如果策略1 和 2都没有命中,策略3命中,则根据新后去更新旧前,并且移动旧前所指真实DOM到合适的位置。并移动指针。

如果策略1 和 2 和 3都没有命中,策略4命中,则根据新前去更新旧后,并且移动旧后所指真实DOM到合适的位置。并移动指针。

如果策略1 和 2 和 3 和 4都没有命中,那么只能依次去比较每一个新旧节点拉。

这个命中策略很符合我们平时的操作,比起双层循环的查找要好很多。

再讨论之前定义的指针,新前,新后,旧前,旧后。

旧前(包括)和旧后(包括)之前的虚拟节点是没有处理的节点(如果之间有已经处理的,会把打成undefined标记),旧前之前和旧后之后的节点都是已经处理好的节点。所以我们处理一个节点后,就要正确的移动的指针,使得旧前之前和旧后之后的节点都是已经处理好的节点

下面的h('li', {key: 'C'}, 'C')只需要看作一个虚拟节点即可,标签名是li, key值为C, 标签的innerText是C

在这里插入图片描述

源码

// 源码位置:src/core/vdom/patch.js  
//更新子节点的策略,其中使用的四个索引和搜索方法,很大程度上优化了时间复杂度.
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    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, 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)
    }
    //以"新前"、"新后"、"旧前"、"旧后"的方式开始比对节点
    // 因为我们处理过程中对于处理过的 位于旧前旧后之间的节点 如果被处理了 会被标记为undefined
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        //如果旧节点的前后开始的节点都为null,就往中间走,继续下一个节点的判断
        if (isUndef(oldStartVnode)) {
            oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
        } else if (isUndef(oldEndVnode)) {
            oldEndVnode = oldCh[--oldEndIdx]
        } 
        //从这里开始  新前 旧前 对比  相同,就把两个节点进行patch更新,并且移动索引位置
        else if (sameVnode(oldStartVnode, newStartVnode)) {
            patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
            oldStartVnode = oldCh[++oldStartIdx]
            newStartVnode = newCh[++newStartIdx]
        } 
        //从这里开始  新后 旧后 对比
        else if (sameVnode(oldEndVnode, newEndVnode)) {
            patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
            oldEndVnode = oldCh[--oldEndIdx]
            newEndVnode = newCh[--newEndIdx]
        } 
        //从这里开始  新后 旧前 对比
        //相同使用patch更新,更新之后还需要将旧前对应的真实DOM移到旧后对应的真实DOM之后,也就是旧的所有未处理节点后.
        else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
            patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
            canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
            oldStartVnode = oldCh[++oldStartIdx]
            newEndVnode = newCh[--newEndIdx]
        } 
        //从这里开始  新前 旧后 对比
        //相同使用patch更新,更新之后还需要将旧后对应的真实DOM移到旧前对应的真实DOM之前,也就是旧的所有未处理节点前.
        else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
            patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
            canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
            oldEndVnode = oldCh[--oldEndIdx]
            newStartVnode = newCh[++newStartIdx]
        } 
        // 如果不属于以上四种情况,就进行常规的循环比对patch
        else {
            if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
            idxInOld = isDef(newStartVnode.key)
                ? oldKeyToIdx[newStartVnode.key]
            : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
            //如果在当前oldnode上找不到同样的new,新创建 
            if (isUndef(idxInOld)) { // New element
                createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
            } else {
                //找到了.判断是不是内容都一样
                vnodeToMove = oldCh[idxInOld]
                if (sameVnode(vnodeToMove, newStartVnode)) {
                    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)
    }
}

在这里插入图片描述

图解子节点更新策略

验证策略1命中的情况

在这里插入图片描述

第一步:策略1新前与旧前命中:

  1. 执行更新节点操作,根据新前更新旧前指的真实DOM
  2. 改变指针。将新前和旧前往下移。

在这里插入图片描述

第二步:策略1新前与旧前又命中了:

  1. 执行更新节点操作,根据新前更新旧前指的真实DOM
  2. 改变指针。将新前和后前往下移。

在这里插入图片描述

直至,循环结束,因为旧后在旧前下面了。

旧前于旧后之前没有节点了,且新前与新后之间也没了。处理完了。

上面的小蓝圈圈就代表页面DOM,页面DOM已经和新的虚拟节点一致了。

验证策略3命中的情况

在这里插入图片描述

第一步:新后与旧前命中:

更新key为c的真实DOM

将旧前指向的真实DOM移动到所有未处理节点之后,也就是把旧前指向的真实DOM移动到旧后指向的真实DOM之后。

改变指针

在这里插入图片描述

第二步:新后与旧前命中:

更新key为B的真实DOM

将旧前指向的真实DOM移动到所有未处理节点之后,也就是把旧前指向的真实DOM移动到旧后指向的真实DOM之后。

改变指针

在这里插入图片描述

第三步:新前与旧前命中:

更新key为A的节点

不需要移动节点

改变指针

在这里插入图片描述

直至,循环结束,因为旧后在旧前下面了。

旧前于旧后之前没有节点了,且新前与新后之间也没了。处理完了。

上面的小蓝圈圈就代表页面DOM,页面DOM已经和新的虚拟节点一致了。DOM节点的顺序是ABC与新的虚拟节点一致。

验证4种策略都没有命中的情况

在这里插入图片描述

第一步:策略二新后与旧后命中:

1.更新key为A的节点

2.无需移动节点

3.改变指针

在这里插入图片描述

第二步:四种策略都无法命中

维护一个对象,主要是维护的旧的虚拟节点的key属性和他的索引

oldKeyToIdx: {
  F: 0,
  E: 1C: 2
}

通过这个oldKeyToId这个对象,我们发现新前这个节点D在旧的虚拟节点中是没有的所以创建一个新的DOM,插入到所有未处理之前,也就是插入到旧前的前面

然后移动指针

在这里插入图片描述

第三步:四种策略都没有命中

通过这个oldKeyToId这个对象,

我们发现新前这个节点E在旧的虚拟节点中是有的, 且能获得它的索引是1,对新旧的E节点进行更新节点操作

并且需要移动位置,将旧的E的DOM移动到所有未处理之前,也就是移动到旧前的前面。

处理了E节点后我们需要打标记,防止重复处理。把E这个位置 置为undefined

然后移动指针

在这里插入图片描述

第四步:四种策略都没有命中

通过这个oldKeyToId这个对象,我们发现新前这个节点B在旧的虚拟节点中是没有的

所以创建一个新的DOM,插入到所有未处理之前,也就是插入到旧前的前面

然后移动指针

在这里插入图片描述

循环结束,因为新前在新后的下面了

由于旧前仍然小于旧后指针。说明在oldChildren中还有未处理的子节点,那么把这些节点移除。把节点C和F对应的DOM移除。

注意,E已经处理了不需要移除

在这里插入图片描述

看结果:旧的节点,看小蓝色的圈圈就是DOM

DOM已经和新的一样了。DOM的顺序的DEBA。和新的一样。

参考:
《Vue中的虚拟DOM》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值