从挂载到更新:patchElement 与 patchChildren
前几节我们完成了渲染器的挂载(mount)和卸载(unmount)逻辑。这一节进入更新环节——当响应式数据变化后,虚拟节点需要重新渲染,如何高效地对比新旧节点并更新真实 DOM。
patchElement:更新节点属性
patchElement 负责更新同一个元素节点的属性和子节点:
function patchElement(n1: VNode, n2: VNode) {
// 1. 让新节点复用旧节点的真实 DOM 引用
const el = (n2.el = n1.el)
const oldProps = n1.props
const newProps = n2.props
// 2. 第一步:更新 props —— 遍历新 props,与旧值对比
for (const key in newProps) {
if (newProps[key] !== oldProps[key]) {
patchProps(el, key, oldProps[key], newProps[key])
}
}
// 3. 第二步:删除已经不存在的旧 props
for (const key in oldProps) {
if (!(key in newProps)) {
patchProps(el, key, oldProps[key], null)
}
}
// 4. 第三步:更新 children
patchChildren(n1, n2, el)
}
typescript
这里的关键思路是:先更新属性,再更新子节点。属性更新分两轮——第一轮处理新增和修改,第二轮处理删除。
patchChildren:子节点更新的九种组合
子节点的类型有三种:空(null)、文本(string)、数组(VNode)。新旧子节点的排列组合共有 3x3 = 9 种情况:
新子节点
null string VNode[]
┌────────┬─────────┬──────────┐
null │ 无操作 │ 设文本 │ 逐个挂载 │
旧 ├────────┼─────────┼──────────┤
子 │string │ 清文本 │ 改文本 │ 新增节点 │
节 ├────────┼─────────┼──────────┤
点 │VNode[] │ 逐个卸载 │ 卸载+设文 │ Diff算法 │
└────────┴─────────┴──────────┘
text
代码实现如下:
function patchChildren(n1: VNode, n2: VNode, container: HTMLElement) {
const oldChildren = n1.children
const newChildren = n2.children
if (typeof newChildren === 'string') {
// 新子节点是文本
if (Array.isArray(oldChildren)) {
// 旧的是数组 → 逐个卸载
oldChildren.forEach(c => unmount(c))
}
// 设置文本内容
setElementText(container, newChildren)
} else if (Array.isArray(newChildren)) {
// 新子节点是数组
if (Array.isArray(oldChildren)) {
// 新旧都是数组 → Diff 算法(后续章节深入)
// diff(oldChildren, newChildren, container)
// 暂时用粗暴方式:全卸载 + 全挂载
oldChildren.forEach(c => unmount(c))
newChildren.forEach(c => patch(null, c, container))
} else {
// 旧的是文本或空 → 清空后逐个挂载
setElementText(container, '')
newChildren.forEach(c => patch(null, c, container))
}
} else {
// 新子节点不存在(null)
if (Array.isArray(oldChildren)) {
oldChildren.forEach(c => unmount(c))
} else if (typeof oldChildren === 'string') {
setElementText(container, '')
}
}
}
typescript
其中最复杂的场景是新旧都是数组,这正是 Diff 算法要解决的核心问题。在引入 Diff 算法之前,我们先用"全卸载再全挂载"的粗暴方式。
patch 方法的完整重写
更新逻辑引入后,patch 方法需要处理更多节点类型:
function patch(n1: VNode | null, n2: VNode, container: HTMLElement, anchor?: Node) {
// n1 不存在 → 挂载;n1 存在但类型不同 → 卸载旧的再挂载新的
if (n1 == null || n1.type !== n2.type) {
if (n1) unmount(n1)
mountElement(n2, container, anchor)
return
}
const { type } = n2
if (typeof type === 'string') {
// 普通标签元素 → 更新属性和子节点
patchElement(n1, n2)
} else if (type === Text) {
// 文本节点
if (!n1) {
// 挂载文本
const el = (n2.el = createText(n2.children as string))
insert(el, container)
} else {
// 更新文本内容
const el = (n2.el = n1.el)
if (n2.children !== n1.children) {
;(el as Text).nodeValue = n2.children as string
}
}
} else if (type === Fragment) {
// Fragment 只更新子节点
if (!n1) {
n2.children.forEach(c => patch(null, c, container))
} else {
patchChildren(n1, n2, container)
}
}
}
typescript
Vue3 扩展的节点类型
Vue3 在传统元素和文本之外,新增了几个 Symbol 类型的虚拟节点:
| 类型 | 值 | 用途 |
|---|---|---|
Text | Symbol() | 纯文本节点 |
Comment | Symbol() | 注释节点 |
Fragment | Symbol() | 多根节点容器 |
Fragment 是 Vue3 相比 Vue2 的重要改进。Vue2 的模板要求只有一个根节点,而 Vue3 通过 Fragment 支持多根节点:
<!-- Vue2:必须有一个根节点包裹 -->
<template>
<div>
<p>hello</p>
<p>world</p>
</div>
</template>
<!-- Vue3:支持多根节点 -->
<template>
<p>hello</p>
<p>world</p>
</template>
vue
编译后,Vue3 会将多个根节点包裹在一个 Fragment 类型的 vnode 中,Fragment 本身不产生真实 DOM,只渲染其子节点。
跨平台设计:createText 方法
文本节点的创建也需要通过 options 对象暴露,保证渲染器的跨平台能力:
const options = {
// ... 其他方法
createText(text: string) {
return document.createTextNode(text)
},
}
function createRenderer(options) {
const { createText, insert, /* ... */ } = options
function patch(n1, n2, container, anchor?) {
// ...
if (type === Text) {
if (!n1) {
const el = (n2.el = createText(n2.children))
insert(el, container, anchor)
} else {
// 更新已有文本节点
}
}
}
}
typescript
这样在非浏览器环境中(如 Canvas、Native),只需提供不同的 createText 实现即可。
事件冒泡补充
Vue3 的事件系统中,事件冒泡和触发顺序通过**时间戳(timestamp)**机制来约束。在元素(el)上绑定一个时间戳,invoker 也携带时间戳,通过比较两者的大小来决定事件的触发顺序,确保冒泡行为与原生 DOM 一致。
本节要点
- patchElement 负责属性更新,分两轮处理:修改/新增和删除
- patchChildren 处理子节点的九种组合情况,核心是判断新旧子节点的类型
- patch 方法新增 Text 和 Fragment 类型支持,实现完整的节点更新
- Fragment 让 Vue3 支持多根节点,不产生额外 DOM 层级
- 所有 DOM 操作通过
options暴露,保持渲染器的跨平台能力 - 新旧子节点都是数组的情况暂时用"全卸载+全挂载"处理,后续引入 Diff 算法优化
↑