在 Vue 3 中,patch 主要负责将虚拟 DOM(VNode)转换为真实 DOM,并在后续更新时高效地比较新旧 VNode 的差异,从而最小化 DOM 操作。
只对同层比较
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>
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>
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>
ELEMENT DOM元素
mountElement
mountElement 是 Vue 3 渲染器中元素挂载的核心实现,其主要功能包括:
- DOM 元素创建:调用
hostCreateElement创建真实 DOM 元素 - 子节点挂载:优先挂载子节点(文本或数组)
- 属性设置:处理 props,特殊处理
value属性 - 钩子调用:按顺序调用指令和 VNode 钩子
- 过渡效果:处理
transition.beforeEnter和transition.enter - DOM 插入:将元素插入容器,并处理后置渲染效果
patchElement
patchElement 是 Vue 3 虚拟 DOM 元素更新的核心实现,其主要功能包括:
- DOM 复用:复用旧 VNode 的 DOM 元素,避免不必要的 DOM 创建
- 钩子调用:按顺序调用指令
beforeUpdate和updated钩子 - 子节点更新:根据
dynamicChildren选择优化路径或完整 diff - Props 更新:基于
patchFlag高效更新动态属性 - 递归控制:在
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
新节点 n2.children
新节点 n2.dynamicChildren
多根节点更新 processFragment
有动态节点 进入 patchBlockChildren
元素节点 processElement
更新节点 进入 patchElement
当编译器检测到元素只有动态文本子节点时,会生成 PatchFlags.TEXT 标志,运行时直接更新文本内容,无需进行完整的子节点 diff。
修改 DOM 节点的 textContent
Fragment 片段
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 的执行流程可分为三个阶段:
- 初始化:获取新旧子节点和标志位
- 快速路径:通过
patchFlag判断,选择编译时优化策略 - 完整路径:通过
shapeFlag判断,处理所有类型转换场景
patchKeyedChildren
Vue 的 patchKeyedChildren 采用双端预处理 + 中间乱序部分最长递增子序列的策略,共分 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>
旧节点有 6个 children、新节点有 7 个children
步骤 1:从头部开始同步(sync from start)
头部同步遍历。它从数组头部开始,依次比较新旧节点,只要类型相同就继续更新,遇到不同类型的节点立即停止。
步骤 2:从尾部开始同步(sync from end)
尾部同步遍历。它从数组末尾开始,向前依次比较新旧节点,只要类型相同就继续更新,遇到不同类型的节点立即停止。
步骤 3:处理未知顺序的中间部分(unknown sequence)
跳过 挂载剩余新节点(common sequence + mount)条件 i > e1 表示旧子节点已经全部处理完(旧列表已空)。
跳过 卸载多余旧节点(common sequence + unmount)条件 i > e2 标识旧节点列表还有剩余节点未处理。
1、构建新子节点的 key → index 映射(keyToNewIndexMap)
keyToNewIndexMap
keyE => 4
keyC => 2
keyD => 3
keyG => 5
2、遍历旧列表中间部分,尝试 patch 匹配节点
- 遍历旧数组中间部分,查找每个旧节点在新数组中的对应位置
- 构建新旧节点索引映射(
newIndexToOldIndexMap) - 检测节点是否需要移动(通过
maxNewIndexSoFar) - 递归更新匹配的节点(调用
patch) - 卸载多余的旧节点
newIndexToOldIndexMap 新节点的中间序列数组,其索引对应的元素是 在旧节点中的索引。
3、移动 & 挂载剩余节点(最长递增子序列优化)
- 计算 LIS:通过
getSequence确定不需要移动的节点 - 倒序遍历:从后往前处理新数组中间部分
- 挂载新增节点:处理
newIndexToOldIndexMap[i] === 0的节点 - 移动需要重排的节点:处理不在 LIS 中的节点
通过 getSequence 确定不需要移动的节点,increasingNewIndexSequence存储索引。说明 节点 C、节点 D 不需要移动。
newIndexToOldIndexMap = [ 5, 3, 4, 0] ==> incrasingNewIndexSequence = [1, 2]
倒序遍历,如果是 newIndexToOldIndexMap[i] === 0 的节点,则调用 patch 进行挂载。
节点E 不在 最长递增子序列中,需要移动。
patchUnkeyedChildren
patchUnkeyedChildren 的执行流程分为三个阶段:
- 初始化:兜底处理并计算长度
- 顺序匹配更新:按索引逐一比较并更新节点
- 处理长度差异:删除多余节点或挂载新增节点
最长递增子序列
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 组件
TELEPORT
Vue3.x 内置组件(KeepAlive、Suspense、Teleport) 与 异步组件
SUSPENSE
Vue3.x 内置组件(KeepAlive、Suspense、Teleport) 与 异步组件