Vue源码学习3.8:组件更新&diff算法

1,426 阅读17分钟

建议PC端观看,移动端代码高亮错乱

1. 介绍

当数据发生变化的时候,会触发 渲染watcher 的回调函数,进而执行组件的更新过程。

updateComponent = () => {
  vm._update(vm._render(), hydrating)
}
new Watcher(vm, updateComponent, noop, {
  before () {
    if (vm._isMounted) {
      callHook(vm, 'beforeUpdate')
    }
  }
}, true /* isRenderWatcher */)

组件的更新还是调用了 vm._update 方法,而 vm._update 会执行 vm.$el = vm.__patch__(prevVnode, vnode),它最终仍然会调用 patch 函数,在 src/core/vdom/patch.js 中定义:

function patch (
  oldVnode,
  vnode,
  hydrating, // undefined
  removeOnly // undefined
{
  // ...

  const insertedVnodeQueue = [] // 存放占位符vnode,调用子组件的mounted

  if (isUndef(oldVnode)) {
    // ...
  } else {
    const isRealElement = isDef(oldVnode.nodeType)
    
    // 一、新旧vnode相同的情况
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      patchVnode(oldVnode, vnode, insertedVnodeQueue, nullnull, removeOnly)
    
    // 二、新旧vnode不同的情况
    } else {
      // 2.1. 创建新节点
      const oldElm = oldVnode.elm
      const parentElm = nodeOps.parentNode(oldElm)
      createElm(
        vnode,
        insertedVnodeQueue,
        parentElm,
        nodeOps.nextSibling(oldElm)
      )

      // 2.2. 递归更新父的占位符节点
      if (isDef(vnode.parent)) {
        let ancestor = vnode.parent
        const patchable = isPatchable(vnode) // 是否可挂载
        while (ancestor) {
          // 遍历 cbs.destroy,依次destroy执行钩子...
          
          ancestor.elm = vnode.elm // 在创建新节点的步骤中会给 elm 赋值
          
          if (patchable) {
            // 遍历cbs.create,依次执行create钩子...
          } else {
            registerRef(ancestor)
          }
          
          ancestor = ancestor.parent
        }
      }

      // 2.3 删除旧节点
      if (isDef(parentElm)) {
        removeVnodes([oldVnode], 00// 里面会调用module和vnode的 destroy钩子
      } else if (isDef(oldVnode.tag)) {
        invokeDestroyHook(oldVnode) // 调用module和vnode的 destroy钩子
      }
    }
  }

  // ...
  return vnode.elm
}

这里执行 patch 的逻辑和首次渲染是不一样的,因为 oldVnode 不为空,并且它和 vnode 都是 VNode 类型,接下来会通过 sameVNode(oldVnode, vnode) 判断它们是否是相同的 VNode 来决定走不同的更新逻辑:

// src/core/vdom/patch.js
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)
      )
    )
  )
}

function sameInputType (a, b{
  if (a.tag !== 'input'return true
  let i
  const typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.type
  const typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.type
  return typeA === typeB || isTextInputType(typeA) && isTextInputType(typeB)
}
  • 如果两个 vnodekey 不相等,则肯定是不同的
  • 否则继续判断对于同步组件,则判断 isCommentdatainput 类型等是否相同
  • 对于异步组件,则判断 asyncFactory 是否相同。

所以根据新旧 vnode 是否为 sameVnode,会走到不同的更新逻辑,我们先来说一下不同的情况。

2. 新旧vnode不同

其实在日常开发中基本上不会走到这个逻辑,只有当我们这么编写组件时:

<template>
  <div v-if="flag">
  </div>
  <ul v-else>
    <li>1</li>
    <li>2</li>
  </ul>
</template>

<script>
export default {
  data() {
    return {
      flag: true
    }
  }
};
</script>

由于我们在最外层节点用了 v-if,所以会产生新旧节点不同的情况,但是通常我们都是在最外层用一个标签包裹的,也就是说只有一个根元素。

回到 patch 函数,来看看新旧节点不同的情况,这部分逻辑分为三部分:

  • 创建新节点
  • 递归更新父的占位符节点
  • 删除旧节点

2.1 创建新节点

const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)
// create new node
createElm(
  vnode,
  insertedVnodeQueue,
  parentElm,
  nodeOps.nextSibling(oldElm)
)
  • 以旧 vnodeDOM 为基础获得父节点。
  • 调用 createElm:通过旧 vnode 创建真实的 DOM 并插入到它的父节点中。

2.2 递归更新父的占位符节点

if (isDef(vnode.parent)) {
  let ancestor = vnode.parent
  const patchable = isPatchable(vnode)
  while (ancestor) {
    // 遍历 cbs.destroy,依次destroy执行钩子...

    ancestor.elm = vnode.elm

    // 是否可挂载
    if (patchable) {
      // 遍历cbs.create,依次执行create钩子...
    } else {
      registerRef(ancestor)
    }

    ancestor = ancestor.parent
  }
}

我们只关注主要逻辑即可:获取当前 渲染vnode父占位符vnode (如果存在的话),先执行各个 moduledestroy 的钩子函数,如果当前占位符是一个可挂载的节点,则执行 modulecreate 钩子函数。对于这些钩子函数的作用,在之后的章节会详细介绍。

通常情况下 while 循环只执行一次:

// parent.vue
<template>
    <div>
        parent
        <Child></Child>
    </div>
</template>

// child.vue
<template>
    <div>child</div>
</template>

只有以下这种情况 while 循环会多次执行

// parent.vue
<template>
    <Child></Child>
</template>

// child.vue
<template>
    <div>child</div>
</template>

也就是说 父占位符vnode 同时又是一个 渲染vnode 的情况。

通过 isPatchable 函数用于判断是否可挂载,源码如下:

function isPatchable (vnode{
  // 存在 componentInstance 表示当前的渲染vnode,同时也是另一个组件的占位符vnode
  // 这种情况则循环,直到找到最深层的组件
  while (vnode.componentInstance) {
    vnode = vnode.componentInstance._vnode
  }
  return isDef(vnode.tag)
}

2.3 删除旧节点

if (isDef(parentElm)) {
  removeVnodes([oldVnode], 00)
else if (isDef(oldVnode.tag)) {
  invokeDestroyHook(oldVnode)
}

如果存在 parentElm 时:

  • oldVnode 从当前 DOM 树中删除,这其中执行 destroy 钩子。

否则直接执行 destroy 钩子

3. 新旧vnode相同

patch 函数中,当新旧 vnode 相同时:

if (!isRealElement && sameVnode(oldVnode, vnode)) {
  patchVnode(oldVnode, vnode, insertedVnodeQueue, nullnull, removeOnly)
}

执行了 patchVnode 方法,参数我们只关注 oldVnode & vnode 即可。

// src/core/vdom/patch.js
function patchVnode (
  oldVnode,
  vnode,
  insertedVnodeQueue,
  ownerArray,
  index,
  removeOnly
{
  if (oldVnode === vnode) {
    return
  }

  const elm = vnode.elm = oldVnode.elm

  // ...

  // 执行 prepatch 钩子函数
  let i
  const data = vnode.data
  if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
    i(oldVnode, vnode)
  }

  // 执行 update 钩子函数
  if (isDef(data) && isPatchable(vnode)) {
    for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
    if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
  }
  
  // patch 过程
  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 (process.env.NODE_ENV !== 'production') {
        checkDuplicateKeys(ch)
      }
      if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
    } else if (isDef(oldCh)) {
      removeVnodes(oldCh, 0, oldCh.length - 1)
    } else if (isDef(oldVnode.text)) {
      nodeOps.setTextContent(elm, '')
    }
  } else if (oldVnode.text !== vnode.text) {
    nodeOps.setTextContent(elm, vnode.text)
  }
  
  // 执行 postpatch 钩子函数
  if (isDef(data)) {
    if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
  }
}

patchVnode 函数的主要做了这几件事:

  • 执行 prepatch 钩子函数,这部分在下一章再结合 props 展开分析
  • 会执行所有 moduleupdate 钩子函数以及用户自定义的 update 钩子函数
  • 核心 patch 过程
  • 执行 postpatch 钩子函数

我们本章重点来关注核心的 patch 过程

// patch 过程
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 (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(ch)
    }
    if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
    addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
  } else if (isDef(oldCh)) {
    removeVnodes(oldCh, 0, oldCh.length - 1)
  } else if (isDef(oldVnode.text)) {
    nodeOps.setTextContent(elm, '')
  }
else if (oldVnode.text !== vnode.text) {
  nodeOps.setTextContent(elm, vnode.text)
}
  • 如果 vnode 不是文本节点,则判断它们的子节点,并分了几种情况处理:

    • oldChch 都存在且不相同时,使用 updateChildren 函数来更新子节点,这个稍后重点讲。
    • 如果只有 ch 存在,表示旧节点不需要了。如果旧的节点是文本节点则先将节点的文本清除,然后通过 addVnodesch 批量插入到新节点 elm 下。
    • 如果只有 oldCh 存在,表示更新的是空节点,则需要将旧的节点通过 removeVnodes 全部清除。
    • 当只有旧节点是文本节点的时候,则清除其节点文本内容。
  • 否则 vnode 是个文本节点且新旧文本不相同时,直接替换文本内容。

4. updateChildren

先贴出完整代码,再结合图片一步一步来看。

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly{
  let oldStartIdx = 0
  let oldEndIdx = oldCh.length - 1
  let oldStartVnode = oldCh[0]
  let oldEndVnode = oldCh[oldEndIdx]
  
  let newStartIdx = 0
  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
  // 这里 canMove 为 true
  const canMove = !removeOnly

  if (process.env.NODE_ENV !== 'production') {
    checkDuplicateKeys(newCh) // 检查重复的key
  }

  // 循环条件
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    if (isUndef(oldStartVnode)) {
      oldStartVnode = oldCh[++oldStartIdx]
    } else if (isUndef(oldEndVnode)) {
      oldEndVnode = oldCh[--oldEndIdx]
      
    // 旧头 === 新头 
    } 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]
      
    // 旧头 === 新尾  
    } 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]
      
    // 旧尾 === 新头  
    } 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]
      
    // 其他情况
    } else {
      if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
      
      // 新vnode在oldCh的index
      idxInOld = isDef(newStartVnode.key)
        ? oldKeyToIdx[newStartVnode.key]
        : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)

      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) {
    refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
    addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    
  // 老节点比新节点多,批量删除节点  
  } else if (newStartIdx > newEndIdx) {
    removeVnodes(oldCh, oldStartIdx, oldEndIdx)
  }
}

3.1 变量介绍

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

  • oldStartIdxoldCh 的开始指针,对应的vnodeoldStartVnode
  • oldEndIdxoldCh 的结束指针,对应的 vnodeoldEndVnode
  • newStartIdxch 的开始指针,对应的 vnodenewStartVnode
  • newEndIdxch 的结束指针,对应的 vnodenewEndVnode
  • oldKeyToIdx 是一个 map,其中 key 就是常在 for 循环中写的 key 的值,value 就是当前 vnode,也就是可以通过唯一的 key,在 map 中找到对应的 vnode

3.2 循环条件

接下来是一个 while 循环,在这过程中,oldStartIdxnewStartIdxoldEndIdx 以及 newEndIdx 会逐渐向中间靠拢。

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

首先当 oldStartVnode 或者 oldEndVnode 不存在的时候,oldStartIdxoldEndIdx 继续向中间靠拢,并更新对应的 oldStartVnodeoldEndVnode 的指向

if (isUndef(oldStartVnode)) {
  oldStartVnode = oldCh[++oldStartIdx]
else if (isUndef(oldEndVnode)) {
  oldEndVnode = oldCh[--oldEndIdx]
}

接下来是将 oldStartVodenewStartVodeoldEndVode 以及 newEndVode 两两比对的过程,一共会出现 2*2=4 种情况。

3.2 旧头 === 新头

首先是 oldStartVnodenewStartVnode 符合 sameVnode 时,直接进行 patchVnode,同时 oldStartIdxnewStartIdx 向后移动一位。

else if (sameVnode(oldStartVnode, newStartVnode)) {
  patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
  oldStartVnode = oldCh[++oldStartIdx]
  newStartVnode = newCh[++newStartIdx]
}

3.3 旧尾 === 新尾

其次是 oldEndVnodenewEndVnode 符合 sameVnode,同样进行 patchVnode 操作并将 oldEndVnodenewEndVnode 向前移动一位。

else if (sameVnode(oldEndVnode, newEndVnode)) {
  patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
  oldEndVnode = oldCh[--oldEndIdx]
  newEndVnode = newCh[--newEndIdx]
}

3.4 旧头 === 新尾

先是 oldStartVnodenewEndVnode 符合 sameVnode 的时候,将 oldStartVnode.elm 这个节点直接移动到 oldEndVnode.elm 这个节点的后面即可。然后 oldStartIdx 向后移动一位,newEndIdx 向前移动一位。

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

3.5 旧尾 === 新头

同理,oldEndVnodenewStartVnode 符合 sameVnode 时,将 oldEndVnode.elm 插入到 oldStartVnode.elm 前面。同样的,oldEndIdx 向前移动一位,newStartIdx 向后移动一位。

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

3.6 查找 map

最后是当以上情况都不符合的时候,这种情况怎么处理呢?

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, 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]
}

通过 createKeyToOldIdx 产生 keyindex 索引对应的一个 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
}

比如说:

[
    {xx: xx, key: 'key0'},
    {xx: xx, key: 'key1'}, 
    {xx: xx, key: 'key2'}
]

在经过 createKeyToOldIdx 转化以后会变成:

{
    key0: 0, 
    key1: 1, 
    key2: 2
}

我们可以根据某一个 key 的值,快速地从 oldKeyToIdx 这个 map 中获取相同 key 的节点的索引 idxInOld,然后找到相同的节点。

如果没有 key 值则调用 findIdxInOldoldCh 找到相同 vnodefindIdxInOld 函数定义如下:

function findIdxInOld (node, oldCh, start, end{
  for (let i = start; i < end; i++) {
    const c = oldCh[i]
    if (isDef(c) && sameVnode(node, c)) return i
  }
}

如果没有找到相同的节点,则通过 createElm 创建一个新节点,并将 newStartIdx 向后移动一位。

if (isUndef(idxInOld)) { // New element
  createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}

否则如果找到了节点,同时它符合 sameVnode,则将这两个节点进行 patchVnode,将该位置的老节点赋值 undefined,同时将 vnodeToMove.elm 插入到 oldStartVnode.elm 的前面。同理,newStartIdx 往后移动一位。

如果不符合 sameVnode,只能创建一个新节点插入到 parentElm 的子节点中,newStartIdx 往后移动一位。

3.7 新节点比旧节点多

while 循环结束以后,如果 oldStartIdx > oldEndIdx,说明老节点比对完了,但是新节点还有多的,需要将新节点插入到真实 DOM 中去,调用 addVnodes 将这些节点插入即可。

if (oldStartIdx > oldEndIdx) {
  refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
  addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
}

3.8 老节点比新节点多

同理,如果满足 newStartIdx > newEndIdx 条件,说明新节点比对完了,老节点还有多,将这些无用的老节点通过 removeVnodes 批量删除即可。

else if (newStartIdx > newEndIdx) {
  removeVnodes(oldCh, oldStartIdx, oldEndIdx)
}

总结