vue3.x patch

21 阅读9分钟

在 Vue 3 中,patch 主要负责将虚拟 DOM(VNode)转换为真实 DOM,并在后续更新时高效地比较新旧 VNode 的差异,从而最小化 DOM 操作。

只对同层比较

image.png

TEXT 文本节点

  /**
   * 处理文本节点
   * @param n1 旧节点
   * @param n2 新节点
   * @param container 容器元素
   * @param anchor 锚点元素
   */
  const processText: ProcessTextOrCommentFn = (n1, n2, container, anchor) => {
    if (n1 == null) {
      // 将文本节点插入到容器的指定位置
      hostInsert(
        // 创建 DOM 文本节点
        (n2.el = hostCreateText(n2.children as string)),
        container,
        anchor,
      )
    } else {
      const el = (n2.el = n1.el!)
      if (n2.children !== n1.children) {
        // 文本内容发生变化,更新 DOM 文本节点
        hostSetText(el, n2.children as string)
      }
    }
  }
  // 创建文本节点
  createText: text => doc.createTextNode(text),
  setText: (node, text) => {
    node.nodeValue = text // 设置文本节点的文本内容
  },

示例

<template>
  <div>
    这里是文本节点
    <div>{{ message }}</div>
    <button @click="handleClick">点击</button>
  </div>
</template>

<script lang="ts" setup>
import { ref } from "vue";
const message = ref("Hello");

const handleClick = () => {
  message.value = "Hello Vue 3!";
};
</script>

image.png

Comment 注释节点

  const processCommentNode: ProcessTextOrCommentFn = (
    n1,
    n2,
    container,
    anchor,
  ) => {
    if (n1 == null) {
      // 将注释节点插入到指定位置
      hostInsert(
        // 创建 DOM 注释节点
        (n2.el = hostCreateComment((n2.children as string) || '')),
        container,
        anchor,
      )
    } else {
      // there's no support for dynamic comments
      // 复用旧节点:直接将旧节点的 DOM 引用赋值给新节点
      // 注释节点不支持动态更新
      n2.el = n1.el
    }
  }
  // 创建注释节点
  createComment: text => doc.createComment(text),

示例

<template>
  <div>{{ message }}</div>
  <!-- 点击按钮 -->
  <button @click="handleClick">点击</button>
</template>

<script lang="ts" setup>
import { ref } from "vue";
const message = ref("Hello");

const handleClick = () => {
  message.value = "Hello Vue 3!";
};
</script>

image.png

Static 静态节点

  const mountStaticNode = (
    n2: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    namespace: ElementNamespace,
  ) => {
    // static nodes are only present when used with compiler-dom/runtime-dom
    // which guarantees presence of hostInsertStaticContent.
    // [0]:静态内容的第一个 DOM 节点(el)
    // [1]:静态内容的最后一个 DOM 节点(anchor)
    ;[n2.el, n2.anchor] = hostInsertStaticContent!(
      // 静态 VNode 的 children 属性存储的是序列化后的 HTML 字符串
      n2.children as string,
      container,
      anchor,
      namespace,
      n2.el,
      n2.anchor,
    )
  }
  insertStaticContent(content, parent, anchor, namespace, start, end) {
  
    // 为了后面计算首尾节点
    const before = anchor ? anchor.previousSibling : parent.lastChild
    
    if (start && (start === end || start.nextSibling)) {
      
      while (true) {
        parent.insertBefore(start!.cloneNode(true), anchor)
        if (start === end || !(start = start!.nextSibling)) break
      }
    } else {
      // HTML 内容模板(<template>)元素是一种用于保存客户端内容机制,该内容在加载页面时不会呈现到页面上
      // fresh insert
      templateContainer.innerHTML = unsafeToTrustedHTML(
        namespace === 'svg'
          ? `<svg>${content}</svg>`
          : namespace === 'mathml'
            ? `<math>${content}</math>`
            : content,
      ) as string

      const template = templateContainer.content // 获取模板内容
      if (namespace === 'svg' || namespace === 'mathml') {
        // remove outer svg/math wrapper
        const wrapper = template.firstChild!
        while (wrapper.firstChild) {
          template.appendChild(wrapper.firstChild)
        }
        template.removeChild(wrapper)
      }
      // 插入模板内容到父节点中
      parent.insertBefore(template, anchor)
    }
    return [
      // first
      before ? before.nextSibling! : parent.firstChild!,
      // last
      anchor ? anchor.previousSibling! : parent.lastChild!,
    ]
  },
  const patchStaticNode = (
    n1: VNode,
    n2: VNode,
    container: RendererElement,
    namespace: ElementNamespace,
  ) => {
    // static nodes are only patched during dev for HMR
    // 比较新旧静态内容是否相同。静态内容存储在 children 属性中(HTML 字符串)
    if (n2.children !== n1.children) {
      // 获取旧静态内容的下一个兄弟节点(作为新内容的插入锚点)
      // n1.anchor 是旧静态内容的最后一个 DOM 节点
      const anchor = hostNextSibling(n1.anchor!)
      // remove existing
      // 移除静态内容占用的所有 DOM 节点(从 n1.el 到 n1.anchor 之间的所有节点)
      removeStaticNode(n1)
      // insert new
      // 插入新的静态内容
      ;[n2.el, n2.anchor] = hostInsertStaticContent!(
        n2.children as string,
        container,
        anchor,
        namespace,
      )
    } else {
      n2.el = n1.el
      n2.anchor = n1.anchor
    }
  }
  remove: child => {
    const parent = child.parentNode
    if (parent) {
      // 从父节点中移除子节点ß
      parent.removeChild(child)
    }
  },

示例

<template>
  <div>
    这里是文本节点
    <p>段落1</p>
    <p>段落2</p>
    <p>段落3</p>
    <p>段落4</p>
    <p>段落5</p>
    <p>段落6</p>
    <p>段落7</p>
    <p>段落8</p>
    <p>段落9</p>
    <p>段落10</p>
    <p>段落11</p>
  </div>
</template>

image.png

ELEMENT DOM元素

mountElement

mountElement 是 Vue 3 渲染器中元素挂载的核心实现,其主要功能包括:

  1. DOM 元素创建:调用 hostCreateElement 创建真实 DOM 元素
  2. 子节点挂载:优先挂载子节点(文本或数组)
  3. 属性设置:处理 props,特殊处理 value 属性
  4. 钩子调用:按顺序调用指令和 VNode 钩子
  5. 过渡效果:处理 transition.beforeEnter 和 transition.enter
  6. DOM 插入:将元素插入容器,并处理后置渲染效果

patchElement

patchElement 是 Vue 3 虚拟 DOM 元素更新的核心实现,其主要功能包括:

  1. DOM 复用:复用旧 VNode 的 DOM 元素,避免不必要的 DOM 创建
  2. 钩子调用:按顺序调用指令 beforeUpdate 和 updated 钩子
  3. 子节点更新:根据 dynamicChildren 选择优化路径或完整 diff
  4. Props 更新:基于 patchFlag 高效更新动态属性
  5. 递归控制:在 beforeUpdate 期间禁用递归更新

示例 修改插值

<template>
  <div>{{ message }}</div>
  <button @click="handleClick">点击</button>
</template>

<script lang="ts" setup>
import { ref } from "vue";
const message = ref("Hello");

const handleClick = () => {
  message.value = "Hello Vue 3!";
};
</script>

点击按钮修改 message

image.png

新节点 n2.children

image.png

新节点 n2.dynamicChildren

image.png

多根节点更新 processFragment

image.png

有动态节点 进入 patchBlockChildren

image.png

元素节点 processElement

image.png

更新节点 进入 patchElement

当编译器检测到元素只有动态文本子节点时,会生成 PatchFlags.TEXT 标志,运行时直接更新文本内容,无需进行完整的子节点 diff。

image.png

修改 DOM 节点的 textContent

image.png

Fragment 片段

image.png

patchBlockChildren

patchBlockChildren 是 Vue 3 渲染器中 Block Tree(块树)优化 的核心函数。当更新一个“块”(如带有 dynamicChildren 的组件根、v-for 生成的稳定片段或普通元素块)时,新旧 VNode 各自带有一个 dynamicChildren 数组,该数组按顺序收集了所有可能变化的子节点(静态节点被排除)。

  const patchBlockChildren: PatchBlockChildrenFn = (
    oldChildren,
    newChildren,
    fallbackContainer,
    parentComponent,
    parentSuspense,
    namespace: ElementNamespace,
    slotScopeIds,
  ) => {
    for (let i = 0; i < newChildren.length; i++) {
      const oldVNode = oldChildren[i]
      const newVNode = newChildren[i]
      
      const container =
        oldVNode.el &&
        (oldVNode.type === Fragment ||
          !isSameVNodeType(oldVNode, newVNode) ||
          oldVNode.shapeFlag &
            (ShapeFlags.COMPONENT | ShapeFlags.TELEPORT | ShapeFlags.SUSPENSE))
          ? hostParentNode(oldVNode.el)!
          : 
            fallbackContainer
      patch(
        oldVNode,
        newVNode,
        container,
        null,
        parentComponent,
        parentSuspense,
        namespace,
        slotScopeIds,
        true,
      )
    }
  }

确定容器?

1、Fragment 节点本身没有对应的包裹 DOM 元素,其 el 存储的是 起始锚点文本节点。Fragment 的子节点的真实父容器是 锚点所在的共同容器

2、新旧节点类型不同。新旧节点类型或 key 不同,说明旧节点将要被完全替换。在替换过程中,新节点必须插入到旧节点的 同级位置,因此必须知道旧节点 实际所在的父节点 以及其兄弟节点作为锚点。

3、组件:组件根节点可能渲染出任意类型的 DOM 结构(包括 Fragment 或 Teleport),其真实 DOM 父节点不一定是当前块容器。直接使用块容器可能导致组件更新时父节点引用错误。

4、Teleport:Teleport 的内容会被挂载到 to 指定的容器中,父节点显然不是当前块容器,必须动态获取。

5、Suspense:异步组件在 fallback 和 resolved 之间切换时,DOM 节点可能被移动到 Suspense 内部生成的占位符或包装元素下,父节点同样需要动态查询。

patchChildren

patchChildren 的执行流程可分为三个阶段:

  1. 初始化:获取新旧子节点和标志位
  2. 快速路径:通过 patchFlag 判断,选择编译时优化策略
  3. 完整路径:通过 shapeFlag 判断,处理所有类型转换场景

image.png

patchKeyedChildren

Vue 的 patchKeyedChildren 采用双端预处理 + 中间乱序部分最长递增子序列的策略,共分 5 个主要步骤:

  1. 从头开始同步:处理从头部开始相同类型的节点。
  2. 从尾开始同步:处理从尾部开始相同类型的节点。
  3. 若旧节点已处理完,则挂载剩余新节点
  4. 若新节点已处理完,则卸载剩余旧节点
  5. 处理未知顺序的中间部分:使用 key 映射 + 最长递增子序列优化移动/挂载。

整个过程中会复用已存在的 DOM 元素,并尽可能少地执行移动操作。

示例
<template>
  <div>
    <h3>列表 Diff 演示 (最长递增子序列优化)</h3>
    <div v-for="item in list" :key="item.id" class="list-item">
      {{ item.name }}
    </div>

    <div>
      <button @click="changeToNew">切换到新顺序 [A, B, E, C, D, G, F]</button>
      <br />
      <button @click="resetToOld">重置回旧顺序 [A, B, C, D, E, F]</button>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { ref } from "vue";

// 原始数据(旧顺序)
const oldList = [
  { id: "A", name: "A" },
  { id: "B", name: "B" },
  { id: "C", name: "C" },
  { id: "D", name: "D" },
  { id: "E", name: "E" },
  { id: "F", name: "F" },
];

// 新顺序 (A, B, E, C, D, G, F)
const newList = [
  { id: "A", name: "A" },
  { id: "B", name: "B" },
  { id: "E", name: "E" },
  { id: "C", name: "C" },
  { id: "D", name: "D" },
  { id: "G", name: "G" },
  { id: "F", name: "F" },
];

const list = ref([...oldList]);

const changeToNew = () => {
  list.value = [...newList];
};

const resetToOld = () => {
  list.value = [...oldList];
};
</script>

image.png

旧节点有 6个 children、新节点有 7 个children

image.png

步骤 1:从头部开始同步(sync from start)

头部同步遍历。它从数组头部开始,依次比较新旧节点,只要类型相同就继续更新,遇到不同类型的节点立即停止。

image.png

步骤 2:从尾部开始同步(sync from end)

尾部同步遍历。它从数组末尾开始,向前依次比较新旧节点,只要类型相同就继续更新,遇到不同类型的节点立即停止。

image.png

步骤 3:处理未知顺序的中间部分(unknown sequence)

跳过 挂载剩余新节点(common sequence + mount)条件 i > e1 表示旧子节点已经全部处理完(旧列表已空)。

跳过 卸载多余旧节点(common sequence + unmount)条件 i > e2 标识旧节点列表还有剩余节点未处理。

image.png

image.png

1、构建新子节点的 key → index 映射(keyToNewIndexMap)

image.png

keyToNewIndexMap 
keyE => 4 
keyC => 2 
keyD => 3 
keyG => 5

image.png

2、遍历旧列表中间部分,尝试 patch 匹配节点

  1. 遍历旧数组中间部分,查找每个旧节点在新数组中的对应位置
  2. 构建新旧节点索引映射newIndexToOldIndexMap
  3. 检测节点是否需要移动(通过 maxNewIndexSoFar
  4. 递归更新匹配的节点(调用 patch
  5. 卸载多余的旧节点

image.png

newIndexToOldIndexMap 新节点的中间序列数组,其索引对应的元素是 在旧节点中的索引。

image.png

image.png

3、移动 & 挂载剩余节点(最长递增子序列优化)

  1. 计算 LIS:通过 getSequence 确定不需要移动的节点
  2. 倒序遍历:从后往前处理新数组中间部分
  3. 挂载新增节点:处理 newIndexToOldIndexMap[i] === 0 的节点
  4. 移动需要重排的节点:处理不在 LIS 中的节点

通过 getSequence 确定不需要移动的节点,increasingNewIndexSequence存储索引。说明 节点 C节点 D 不需要移动。

newIndexToOldIndexMap = [ 5, 3, 4, 0] ==> incrasingNewIndexSequence = [1, 2]

image.png

倒序遍历,如果是 newIndexToOldIndexMap[i] === 0 的节点,则调用 patch 进行挂载。

image.png

image.png

节点E 不在 最长递增子序列中,需要移动。

image.png

image.png

image.png

patchUnkeyedChildren

patchUnkeyedChildren 的执行流程分为三个阶段:

  1. 初始化:兜底处理并计算长度
  2. 顺序匹配更新:按索引逐一比较并更新节点
  3. 处理长度差异:删除多余节点或挂载新增节点

最长递增子序列


function getSequence(arr) {
  const p = arr.slice() // 前驱数组:记录每个元素在 LIS 中的前一个元素索引
  const result = [0] // 初始 LIS,第一个元素索引为 0
  let i, j, u, v, c
  const len = arr.length

  // 遍历数组,构建 LIS
  for (i = 0; i < len; i++) {
    const arrI = arr[i]

     // 跳过值为 0 的元素(Vue 中表示未挂载的节点)
    if (arrI !== 0) {
      j = result[result.length - 1] // 当前 LIS 的最后一个元素索引

      // 情况1:当前元素大于 LIS 末尾元素 → 直接加入
      if (arr[j] < arrI) {
        p[i] = j
        result.push(i)
        continue
      }
      // 情况2:当前元素不大于末尾元素 → 二分查找替换位置
      u = 0
      v = result.length - 1
      while (u < v) {
        c = (u + v) >> 1 // 等价于 Math.floor((u + v) / 2)
        if (arr[result[c]] < arrI) {
          u = c + 1 // 在右半部分查找
        } else {
          v = c // 在左半部分查找
        }
      }
      // 如果找到的位置可以被替换
      if (arrI < arr[result[u]]) {
        if (u > 0) {
          p[i] = result[u - 1]
        }
        result[u] = i
      }
    }
  }

  // 回溯构造完整序列
  u = result.length
  v = result[u - 1] // LIS 的最后一个元素索引
  while (u-- > 0) {
    result[u] = v
    v = p[v]
  }
  return result
}

COMPONENT 组件

vue组件渲染

TELEPORT

Vue3.x 内置组件(KeepAlive、Suspense、Teleport) 与 异步组件

SUSPENSE

Vue3.x 内置组件(KeepAlive、Suspense、Teleport) 与 异步组件

最后