浅曦Vue源码-46-patch 阶段-patchVNode

246 阅读8分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的5天,点击查看活动详情

一、前情回顾 & 背景

上一篇文章讨论了 patch 方法的大致结构,另外还讨论了进入到 DOM-diff 高潮的方法—— patchVnode 的执行条件:

  1. 如果新节点不存在,就要销毁掉旧节点,因为视图不再需要它了;

  2. 判断旧节点是否存在,如果不存在说明是自定义组件的初次渲染;

  3. 新旧节点都有,判断不是元素说明就是两颗虚拟 DOM 树了,此时调用 patchVnode 进行比对并 patch

今天我们正式进入DOM diff 的核心方法 patchVnode

二、patchVnode 被调用

patchVnodepatch 方法的一个分支流程,当满足新旧节点都存在 && 旧元素类型不是元素节点 && 新旧节点是同一个,此时就需要调用 patchVnode 方法进行 diff 过程了,这也就是 DOM diff 的过程,当然除了 diff 还要 patch

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

  if (isUndef(vnode)) {}

  let isInitialPatch = false
  const insertedVnodeQueue = []

  if (isUndef(oldVnode)) {
  
    // 这里的 comp 组件初次渲染时就会走这里
  } else {
    // 判断 oldVnode 是否是真实元素
    const isRealElement = isDef(oldVnode.nodeType)
    if (!isRealElement && sameVnode(oldVnode, vnode)) {
      // 不是真实元素,但是老节点和新节点是同一个节点,
      // 则是更新阶段,执行 patch 更新节点
   
      patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
    } else {
     
    }
}

三、patchVnode 方法

方法位置:src/core/vdom/patch.js -> function createPatchFunction -> function patchVnode

方法参数:

  1. oldVnode, 旧节点(虚拟 DOM
  2. vnode, 新节点(虚拟 DOM
  3. insertedVnode,待插入节点队列
  4. index, 索引值
  5. removeOnly, 标识符,仅移除节点

方法作用:用于更新节点,在该方法具体做了以下几项工作

  1. 如果新旧节点对象完全相同,则这个过程直接退出不再 diff
  2. 如果是异步占位符节点,直接退出不再 diff
  3. 静态树的重用,注意仅当节点是被克隆的才会重用,如果不是克隆节点则表示节点的 render 函数 被热重载重置过了,需要重新渲染;
  4. 执行组件的 data.hook.prepatch 钩子;
  5. 全量更新属性,这些属性的更新时执行的逻辑都是提前在 modules 中预置好的;调用 data.hook.update 钩子;
  6. 处理新节点不为文本的情况:
    • 6.1 如果新旧节点都存在子节点,调用 udpateChildren 进行递归 diff
    • 6.2 如果新节点有子节点,但是老节点没有这些子节点,调用 addVnodes 新增这些子节点;
    • 6.3 如果旧节点有子节点,但新节点没有这些子节点,调用 removeVnodes 移除旧节点的子节点;
    • 6.4 如果旧节点是文本,则置空文本内容
  7. 如果新节点是文本节点则更新文本节点内容;
  8. 最后执行 vnode.data.hook.postpatch 钩子;
function patchVnode (
  oldVnode,
  vnode,
  insertedVnodeQueue,
  ownerArray,
  index,
  removeOnly
) {
  // 新旧节点相同,直接退出过程
  if (oldVnode === vnode) {
    return
  }

  if (isDef(vnode.elm) && isDef(ownerArray)) {
    // 克隆复用节点
    vnode = ownerArray[index] = cloneVNode(vnode)
  }

  const elm = vnode.elm = oldVnode.elm

  // 异步占位符节点直接退出
  if (isTrue(oldVnode.isAsyncPlaceholder)) {
    if (isDef(vnode.asyncFactory.resolved)) {
      hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
    } else {
      vnode.isAsyncPlaceholder = true
    }
    return
  }

  // 静态树重用,注意仅当节点是被克隆的才会重用
  // 如果节点不是克隆节点,这意味着 render 函数被 hot-reolad-api 重置,我们需要重新渲染
  if (isTrue(vnode.isStatic) &&
    isTrue(oldVnode.isStatic) &&
    vnode.key === oldVnode.key &&
    (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
  ) {
    // 新旧节点都是静态且两节点 key 一样,
    // 且新节点被 clone 了 或新节点有 v-once 指令,则重用这部分节点
    vnode.componentInstance = oldVnode.componentInstance
    return
  }

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

  // 旧节点的孩子
  const oldCh = oldVnode.children

  // 新节点的孩子
  const ch = vnode.children

  if (isDef(data) && isPatchable(vnode)) {
    // 执行新节点所有的属性更新
    for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
    // 执行自定义组件的 update 钩子
    if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
  }
  
  // 新节点不是文本
  if (isUndef(vnode.text)) {
    if (isDef(oldCh) && isDef(ch)) {
      // 新旧节点都有孩子,则递归 diff 
      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(oldCh, 0, oldCh.length - 1)
    } else if (isDef(oldVnode.text)) {
      // 旧节点是文本节点,则将文本内容置空
      nodeOps.setTextContent(elm, '')
    }
  } else if (oldVnode.text !== vnode.text) {
    // 新节点是文本节点,则更新文本节点
    nodeOps.setTextContent(elm, vnode.text)
  }
  if (isDef(data)) {
    if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
  }
}

3.1 updateChildren

方法位置:src/core/vdom/patch.js -> function createPatchFunction -> function updateChildren

方法参数:

  1. prarentElm, 父元素
  2. oldCh, 旧节点的子节点
  3. newCh, 新节点的子节点
  4. insertedVnodeQueue, 待插入的节点列表
  5. removeOnly, 执行删除操作标识符,给 transitions-group 用的

方法作用:主要遍历新旧两个子节点列表进行 diffdiff 过程做了四种预判,之所以做预判是为了减少遍历,提升性能。详细如下:

  1. 假设旧节点的起始节点和新的起始节点是同一个,则调用 patchVnode 进行递归 patch
  2. 假设旧节点列表的结束节点和新的节点列表的结束节点是同一个,调用 patchVnode 递归 patch
  3. 旧节点的起始节点和新节点列表的结束节点是同一个,调用 patchVnode 递归 patch
  4. 旧节点的结束节点和新节点列表的起始节点是同一个,调用 patchVnode 递归 patch
  5. 上面四种预判都没命中时,尝试通过 key 找到新列表的起始节点在旧的节点列表中的索引位置;
    • 5.1 如果没有找到这个索引,说明这个其实节点是个新节点,走新节点;
    • 5.2 否则就是找到了这个索引,说明在旧节点列表中找到新列表的起始节点,从就列表中用索引取出该节点和新列表起始节点比对是否为同一节点,若是则执行 patchVnode 递归 patch;若比对结果发现不是同一节点,则视为新节点重新创建这个节点;
  6. 遍历新旧两个列表过程中不管新旧哪个先达到终点就停止遍历,这是因为剩下的节点都是应该新建或删除掉的:
    • 6.1 如果 oldStartIdx > oldEndIdx 说明旧节点先遍历完了,如果新节点列表还有剩余,调用 addVnodes 新增这些节点;
    • 6.2 如果 newStartIdx > newEndIdx 说明新节点先遍历完了,如果旧节点有剩余,这些剩余都是应该删除掉的,调用 removeVnodes 移除剩余节点;
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 是一个特殊标志,仅由 <transition-group> 使用,以确保被移除的元素在离开转换期间保持在正确的相对位置
  // 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') {
    // 检查新节点的 key 是否重复
    checkDuplicateKeys(newCh)
  }

  // 遍历新旧两组节点,只要有一组遍历完成(开始索引超过结束索引)则跳出循环
  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)) {
      // 旧开始节点和新开始节点是同一个节点,则执行 patch
      patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)

      // patch 结束后新旧开始索引累加 1
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (sameVnode(oldEndVnode, newEndVnode)) {
      // 旧结束和新结束时同一节点,执行 patch
      patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)

      // patch 结束后,新旧结束索引递减 1
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
      // 旧开始和新结束是同一节点,执行 patch
      patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)

      // 处理 transition-group 包裹的组件时使用
      canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))

      // patch 结束后旧开始索引加 1,新结束索引 1
      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 {
      // 如果上面四种假设都不成立,则需要通过遍历找到新开始节点在旧节点中的位置索引

      // 找到旧节点中每个节点 key 和 索引之间的关系映射 => oldKeyToIdx = { key1: idx1, .... }
      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)) {
          // 如果这两个节点是同一个,则执行 patch
          patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)

          // patch 结束后将该旧节点设为 undefined
          oldCh[idxInOld] = undefined
          canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
        } else {
          // 发现两个节点不是同一个节点,则视为新元素,执行创建
        
          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)
  }
}

四、总结

哈哈哈哈,终于写完了,DOM diff 讲完了,这一篇也是这个系列也会终章~

本篇主要讨论了 Vue 进行子节点列表 DOM diff 的细节部分,Vuepatch 阶段和 React 不太一样,React 是先 diffpatch,而 Vue 是边 diffpatch,是一起完成的;

它的 diff 过程设计也很值得回味:

  1. 首先做了四种预判,新旧节点列表的首尾是同一节点的请情况,通过这四种预判减少后面的遍历,以此提升性能;
  2. 如果没能命中上面四种预判,那就遍历尝试旧节点列表中找到新列表的起始节点,如果找到就执行 patchVnode
  3. 新旧列表不管哪个遍历完就退出遍历,而就列表先遍历完说明新列表剩下的都是新增节点;
  4. 如果新列表先遍历完,旧列表剩下的都是应该删除的节点;

至此,因为响应式数据发生变化触发重新渲染,也就是我们常说的 patch 阶段也就完成了。

五、致谢

刚和我弟弟讨论,他今天也完成了一件大事,他感叹不敢想象,我和他有同感,我回复了一句:凡事只要有开头,只要坚持就会有个结尾,如果从不开始,永远也不会有结尾!

感谢我老婆默默的支持,她照顾我们的小宝宝,每晚睡不好,每天忙不停!

感谢我的芾宝宝,他的到来,让我们全家充满希望!

感谢掘金大大平台及平台各位读者,各位点赞阅读给了我写下去的勇气!

与诸位共勉,祝各位早日称为尊贵的奥迪车主!

于 2022.05.14, 北京·昌平