乐趣区

vue源码解析diff过程一探究竟

也看过其他讲 vue diff 过程的文章,但是感觉都只是讲了其中的一部分(对比方式),没有对其中细节的部分做详细的讲解,如

  • 匹配成功后进行的 patchVnode 是做了什么?为什么的有的紧接着要进行 dom 操作,有的没有?
  • 在 diff 的过程中,指针的具体如何移动?及哪些部分发生了变化?
  • insertedVnodeQueue 又是何用?为何一直带着?
  • 然后也是困惑很久的,很多文章在移动这部分直接操作的 oldChildren,然而 oldChildren 会发生移动么?那么到底是谁发生了移动呢?

这里并不会直接就开始讲 diff,为了让大家能了解到 diff 的详细过程,所在开始核心部分之前,有些简单的概念和流程需要提前说明一下,当然最好是希望你已经对 vue 源码 patch 这部分有些了解。

几个概念

由于核心是说明 diff 的过程,所以会先把 diff 涉及到的核心概念简单说明一下,对于这些若仍有疑问可以在评论区留言:

1. vnode

简单的说就是真实 dom 的描述对象,这也是 vue 的特点之一 – virtual dom。由于原生的 dom 结构过于复杂,当需要获取并了解节点信息的时候,并不需要操作复杂的 dom, 相应的 vue 是先用其描述对象进行分析(diff 对比也就是 vnode 的对比),然后再反应到真实的 dom。

export default class VNode {
  tag: string | void;
  data: VNodeData | void;
  children: ?Array<VNode>;
  text: string | void;
  elm: Node | void;
  ns: string | void;
  context: Component | void; // rendered in this component's scope
  key: string | number | void;
  componentOptions: VNodeComponentOptions | void;
  componentInstance: Component | void; // component instance
  parent: VNode | void; // component placeholder node

  // strictly internal
  raw: boolean; // contains raw HTML? (server only)
  isStatic: boolean; // hoisted static node
  isRootInsert: boolean; // necessary for enter transition check
  isComment: boolean; // empty comment placeholder?
  isCloned: boolean; // is a cloned node?
  isOnce: boolean; // is a v-once node?
  asyncFactory: Function | void; // async component factory function
  asyncMeta: Object | void;
  isAsyncPlaceholder: boolean;
  ssrContext: Object | void;
  functionalContext: Component | void; // real context vm for functional nodes
  functionalOptions: ?ComponentOptions; // for SSR caching
  functionalScopeId: ?string; // functioanl scope id support

  constructor () {...}

}

需要注意的是后面会涉及到的几个属性:

  • childrenparent 通过这个建立其 vnode 之间的层级关系,对应的也就是真实 dom 的层级关系
  • text 如果存在值,证明该 vnode 对应的就是一个文件节点,跟 children 是一个互斥的关系,不可能同时有值
  • tag 表明当前 vnode,对应真实 dom 的标签名,如‘div’、‘p’
  • elm 就是当前 vnode 对应的真实的 dom

2. patch

阅读源码中复杂函数的小技巧:看‘一头’‘一尾’。‘头’指的的入参,提炼出能看懂和能理解的参数 (oldVnodevnodeparentElm),‘尾’指的是函数的处理结果,这个返回的elm。所以可以根据‘头尾’总结下,patch 完成之后,新的 vnode 上会对应生成 elm,也就是真实的 dom,且是已经挂载到parentElm 下的 dom。简单的来说,如 vue 实例初始化、数据更改导致的页面更新等,都需要经过 patch 方法来生成 elm。

  function patch (oldVnode, vnode, hydrating, removeOnly, parentElm, refElm) {
    // ...
    const insertedVnodeQueue = []
    // ...
    if (isUndef(oldVnode)) {
      isInitialPatch = true
      createElm(vnode, insertedVnodeQueue, parentElm, refElm)
    } 
    // ...
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      // patch existing root node
      patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
    } 
    // ...
    return vnode.elm
  }

patch 的过程 (除去边界条件) 主要会有三种 case:

  • 不存在 oldVnode, 则进行createElm
  • 存在 oldVnode 和 vnode,但是 sameVnode 返回 false, 则进行createElm
  • 存在 oldVnode 和 vnode,但是 sameVnode 返回 true, 则进行patchVnode

3. sameVnode

上面提到了sameVnode,代码如下:

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)
      )
    )
  )
}

简单的举个的 case,比如之前是一个 <div> 标签,由于逻辑的变动,变为 <p> 标签了,则 sameVnode 会返回 false(a.tag === b.tag 返回 false)。所以sameVnode 表明的是,满足以上条件就是同一个元素,才可进行 patchVnode。反过来理解就是,只要以上任意一个发生改变,则无需进行pathchVnode,直接根据vnode 进行 createElm 即可。

注意,sameVnode 返回 true,不能说明是同一个 vnode,这里的相同是指当前的以上指标一致,他们的 children 可能发生了变化,仍需进行 patchVnode 进行更新。

patchVnode

patch 方法,我们知道 patchVnode 方法和 createElm 的方法最终的处理结果一样,就是生成或更新了当前 vnode 对应的 dom。

经过上面的分析,总结下,就是当需要生成 dom,且前后 vnode 进行 sameVnodetrue的情况下,则进行patchVnode

function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
    // ...
    const elm = vnode.elm = oldVnode.elm
    // ...
    const oldCh = oldVnode.children
    const ch = vnode.children
    // ...
    if (isUndef(vnode.text)) {if (isDef(oldCh) && isDef(ch)) {if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      } else if (isDef(ch)) {if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
      } else if (isDef(oldCh)) {
        // 具体是何种情况下会走到这个逻辑???removeVnodes(elm, oldCh, 0, oldCh.length - 1)
      } else if (isDef(oldVnode.text)) {nodeOps.setTextContent(elm, '')
      }
    } else if (oldVnode.text !== vnode.text) {nodeOps.setTextContent(elm, vnode.text)
    }
    // ...
  }

以上是 patchVnode 的部分代码,展示出来的这部分逻辑,也是 patchVnode 的核心处理逻辑。

以上代码,充斥大量的if else,大家可以思考几个问题?

  1. 根据以上代码分析,对于一个 vnode,可分成三种 vnode: 文本 vnode、存在 chilren 的 vnode、不存在 children 的 vnode。对于 oldVnode 和 vnode 交叉组合的话,应该会有 9 种 case,那么以上的代码有全部覆盖所有 case 么?
  2. 那比如,具体哪些 case 会进入到 removeVnodes 的逻辑?

这其实也是我在阅读的时候思考的问题,最终我采用了以下的方式(对着代码绘制表格)来解决这种复杂的 if else 逻辑的解读:

| | oldVnode.text | oldCh | !oldCh|
| :——| ——: | :——: |:——: |
| vnode.text | setTextContent |setTextContent |setTextContent |
| ch | addVnodes | updateChildren |addVnodes |
| !ch | setTextContent | removeVnodes |setTextContent |

对应着表格,然后对应着代码,相信你能找到答案。

updateChildren

经过上面的分析,只有在 oldChch都存在的情况下才会执行 updateChildren,此时入参是oldChch,所以可以知道的是,updateChildren进行的是同层级下的 children 的更新比较,也就是‘传说中的’diff 了。

开始分析之前,可以思考下:若现在 js 来操作原生 dom 的一个 <ul> 列表,当然这个列表也是用原生的 js 来实现的,现在如果其中的数据顺序发生了变化,第一条要排到末尾或具体的某个位置,或者有新增数据、删除数据等,该如何操作。

let listData = [
  '测试数据 1',
  '测试数据 2',
  '测试数据 3',
  '测试数据 4',
  '测试数据 5',
]
let ulElm = document.createElement('ul');
let liStr = '';
for(let i = 0; i < listData.length; i++){liStr += `<li>${listData[i]}</li>`
}
ulElm.append(liStr)
document.body.innerHTML = ''
document.body.append(ulElm)

这个时候由于变化的不确定性,不希望在业务代码逻辑中维护繁琐的insertBeforeappendChildremoveChildreplaceChild,立马能想到的粗暴的解决方式是,我们拿到最新的listData,把上面面创建的流程再走一遍。

然而 vue 采取的是 diff 算法,简单的说就是:

  1. 还是和上面一样,依然先获取到最新的listData
  2. 然后新的 data 进行 _render 操作,得到新的 vnode
  3. 对比前后 vnode,也就是 patch 过程
  4. 对于同一层级的节点,会进行 updateChildren 操作(diff),进行最小的变动

diff

updateChildren代码如下:

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

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {if (isUndef(oldStartVnode)) {oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
      } else if (isUndef(oldEndVnode)) {oldEndVnode = oldCh[--oldEndIdx]
      } else if (sameVnode(oldStartVnode, newStartVnode)) {patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
        oldStartVnode = oldCh[++oldStartIdx]
        newStartVnode = newCh[++newStartIdx]
      } else if (sameVnode(oldEndVnode, newEndVnode)) {patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
        canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      } else {if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
        if (isUndef(idxInOld)) { // New element
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
        } else {vnodeToMove = oldCh[idxInOld]
          /* istanbul ignore if */
          if (process.env.NODE_ENV !== 'production' && !vnodeToMove) {
            warn(
              'It seems there are duplicate keys that is causing an update error.' +
              'Make sure each v-for item has a unique key.'
            )
          }
          if (sameVnode(vnodeToMove, newStartVnode)) {patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
            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)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
    if (oldStartIdx > oldEndIdx) {refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else if (newStartIdx > newEndIdx) {removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
    }
  }

之前分析了,oldChch 表示的是同层级的 vnode 的列表,也就是两个数组

开始之前定义了一系列的变量,分别如下:

  • oldStartIdx 开始指针,指向 oldCh 中待处理部分的头部,对应的 vnode 也就是oldStartVnode
  • oldEndIdx 结束指针,指向 oldCh 中待处理部分的尾部,对应的 vnode 也就是oldEndVnode
  • newStartIdx 开始指针,指向 ch 中待处理部分的头部,对应的 vnode 也就是newStartVnode
  • newEndIdx 结束指针,指向 ch 中待处理部分的尾部,对应的 vnode 也就是newEndVnode
  • oldKeyToIdx 是一个 map,其中 key 就是常在 for 循环中写的 v-bind:key 的值,value 对应的就是当前 vnode, 也就是可以通过唯一的 key,在 map 中找到对应的 vnode

updateChildren使用的是 while 循环来更新 dom 的,其中的退出条件就是 !(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx),换种理解方式:oldStartIdx > oldEndIdx || newStartIdx > newEndIdx,什么意思呢,就是只要有一个发生了‘交叉’(下面的例子会出现交叉) 就退出循环。

举个栗子

原有的 oldCh 的顺序是 A、B、C、D、E、F、G,更新后成 ch 的顺序 F、D、A、H、E、C、B、G。

图解说明

为了更好理解后续的 round,开始之前先看下相关符合标记的说明

diff 的过程

round1:
对比顺序:A-F -> G-G,匹配成功,然后:

  1. 对 G 进行 patchVnode 的操作,更新oldEndVnode G 和newEndVnode G 的 elm
  2. 指针移动,两个尾部指针向左移动,即oldEndIdx-- newEndIdx--

round2:
对比顺序:A-F -> F-B -> A-B -> F-F,匹配成功,然后:

  1. 对 F 进行 patchVnode 的操作,更新oldEndVnode F 和newEndVnode F 的 elm
  2. 指针移动,移动指针,即oldEndIdx-- newStartIdx++
  3. 找到 oldStartVnode 在 dom 中所在的位置 A,然后在其前面插入更新过的 F 的 elm

round3:
对比顺序:A-D -> E-B -> A-B -> E-D,仍未成功,取 D 的 key,在 oldKeyToIdx 中查找,找到对应的 D,查找成功,然后:

  1. 将 D 取出赋值到 vnodeToMove
  2. 对 D 进行 patchVnode 的操作,更新vnodeToMove D 和newStartVnode D 的 elm
  3. 指针移动,移动指针,即newStartIdx++
  4. 将 oldCh 中对应 D 的 vnode 置undefined
  5. 在 dom 中找到oldStartVnode A 的 elm 对应的节点,然后在其前面插入更新过的 D 的 elm

round4:
对比顺序:A-A,对比成功,然后:

  1. 对 A 进行 patchVnode 的操作,更新oldStartVnode A 和newStartVnode A 的 elm
  2. 指针移动,两个尾部指针向左移动,即oldStartIdx++ newStartIdx++

round5:
对比顺序:B-H -> E-B -> B-B , 对比成功,然后:

  1. 对 B 进行 patchVnode 的操作,更新oldStartVnode B 和newStartVnode B 的 elm
  2. 指针移动,即oldStartIdx++ newEndIdx--
  3. 在 dom 中找到 oldEndVnode E 的 elm 的nextSibling 节点(即 G 的 elm),然后在其前面插入更新过的 B 的 elm

round6:
对比顺序:C-H -> E-C -> C-C , 对比成功,然后(同 round5):

  1. 对 C 进行 patchVnode 的操作,更新oldStartVnode C 和newStartVnode C 的 elm
  2. 指针移动,即oldStartIdx++ newEndIdx--
  3. 在 dom 中找到 oldEndVnode E 的 elm 的nextSibling 节点(即刚刚插入的 B 的 elm),然后在其前面插入更新过的 C 的 elm

round7:
获取 oldStartVnode 失败(因为 round3 的步骤 4), 然后:

  1. 指针移动,即oldStartIdx++

round8:
对比顺序:E-H、E-E, 匹配成功,然后(同 round1):

  1. 对 E 进行 patchVnode 的操作,更新oldEndVnode E 和newEndVnode E 的 elm
  2. 指针移动,两个尾部指针向左移动,即oldEndIdx-- newEndIdx--

last
round8 之后 oldCh 提前发生了‘交叉’,退出循环。


last:

  1. 找到 newEndIdx+1 对应的元素 A
  2. 待处理的部分(即 newStartIdxnewEndIdx 中的 vnode)则为新增的部分,无需 patch,直接进行createElm
  3. 所有的这些待处理的部分,都会插到步骤 1 中 dom 中 A 的 elm 所在位置的后面

需要注意的点:

  • oldCh 和 ch 在过程中他们的位置并不会发生变化
  • 真正进行操作的是进入 updateChildren 传入的parentElm,即父 vnode 的 elm
  • while 每一次的循环体,我称之为回和,也就是 round
  • 多次提到 patchVnode,往前看patchVnode 的部分,其处理的结果就是 oldVnode.elm 和 vnode.elm 得到了更新
  • 有多次的原生的 dom 的操作,insertBefore, 重点是要先找到插入的地方

总结

每一个 round(以上例子中涉及到的)做的事情如下(优先级从上至下):

  • oldStartVnode 则移动(参照 round6)
  • 对比头部,成功则更新并移动(参照 round4)
  • 对比尾部,成功则更新并移动(参照 round1)
  • 头尾对比,成功则更新并移动(参照 round5)
  • 尾头对比,成功则更新并移动(参照 round2)
  • oldKeyToIdx 中根据 newStartVnode 的可以进行查找,成功则更新并移动(参照 round3)

(更新并移动:patchVnode 更新对应 vnode 的 elm,并移动指针)

关于插入的问题,为何有的紧接着进行的 dom 操作,有的没有?何时在 oldStartVnode 的 elm 前插,何时在 oldEndVnode 的 elm 的 nextSibling 前插?

这里只要记住,oldChch 都是参照物,其中,ch是我们的目标顺序,而 oldCh 是我们用来了解当前 dom 顺序的参照,也就是开篇提到的 vnode 的介绍。所以整个 diff 过程,就是对比 oldChch,确认当前 round,oldCh如何移动更靠近 ch,由于oldCh 中待处理的部分仍在 dom 中,所以可以根据 oldCh 中的 oldStartVnode 的 elm 和 oldEndVnode的 elm 的位置,来确定匹配成功的元素该如何插入。

  • ‘头头’匹配成功的时候,证明当前 oldStartVnode 位置正是现在的位置,无需移动,进行 patchVnode 更新即可
  • ‘尾尾’匹配成功同‘头头’匹配成功,也无需移动
  • 若‘尾头匹配成功’,即 oldEndVnodenewSatrtVnode匹配成功,这里注意成功的是 newSatrtVnode,所以是在待处理 dom 的头部前插。如 round2, 当前待处理的部分,也就是oldCh 中黑块的部分,头部也就是 oldStartVnode。也就是在oldStartVnode 的 elm 前面插入 newSatrtVnode 的 elm。
  • 同理,若‘头尾匹配成功’,即 oldStartVnodenewEndVnode匹配成功,这里注意成功的是 newEndVnode,所以是在待处理 dom 的尾部插入(就是尾部元素的下一个元素前插)。如 round5, 当前待处理的部分,也就是oldCh 中黑块的部分,尾部也就是 oldEndVnode。也就是先找到oldEndVnode 的 elm 的 nextSibling 前面插入 newEndVnode 的 elm。

(这里有提到‘待处理块’,具体大家可以看示意图,注意 oldCh 中的待处理块部分和 dom 中待处理的部分)

以上已经包含 updateChildren 中大部分的内容了,当然还有部分没有涉及到的就不一一说明的,具体的大家可以对着源码,找个实例走整个的流程即可。


最后还有一个问题没回答,insertedVnodeQueue有何用?为啥一直带着?

这部分涉及到组件的 patch 的过程,这里可以简单说下:组件的 $mount 函数之后之后并不会立即触发组件实例的 mounted 钩子,而是把当前实例 pushinsertedVnodeQueue中,然后在 patch 的倒数第二行,会执行 invokeInsertHook,也就是触发所有组件实例的insert 的钩子,而组件的 insert 钩子函数中才会触发组件实例的 mounted 钩子。比方说,在 patch 的过程中,patch 了多个组件 vnode,他们都进行了 $mount 即生成 dom,但没有立即触发 $mounted,而是等整个patch 完成,再逐一触发。

退出移动版