01|虚拟 DOM 与渲染器
第七章 / 01。目标:把“状态变了”拆成渲染函数重跑、产生新 VNode、patch 旧树、提交宿主操作四步。
VNode 不是 DOM 快照
VNode 是对“这次希望界面长什么样”的描述。它至少携带节点类型、props、children 和 key;DOM 节点则是浏览器中的有状态对象,保存焦点、输入值、滚动位置等宿主状态。两者不能混为一谈。
一次组件更新经过下面的链路:
- 模板预先编译成 render function;render 读取响应式依赖并返回新 VNode 树。
- 首次运行是 mount:渲染器创建元素、设置属性、插入容器。
- 依赖触发组件更新后,render 重新执行;patch 比较旧、新 VNode。
- 相同类型且相同 key 的节点进入复用分支,只提交必要的文本、属性或子节点变化。
- 类型或 key 不同则卸载旧节点并挂载新节点,旧 DOM 身份与其局部状态一起结束。
响应式系统决定“何时需要更新组件”,渲染器决定“如何把新描述落实到平台”。浏览器之后还可能进行样式计算、布局、绘制;这些成本不是 VNode diff 本身。
最小可重放实验:直接观察 DOM 身份
在一个已安装 Vue 3 的 Vite 项目中新建 src/renderer-lab.ts,把 src/main.ts 临时改为只导入 ./renderer-lab,不要同时 createApp 挂载同一容器:
import { h, render } from 'vue'
const host = document.querySelector<HTMLDivElement>('#app')!
render(h('button', { key: 'save', title: 'v1' }, '保存'), host)
const first = host.firstElementChild
render(h('button', { key: 'save', title: 'v2' }, '已保存'), host)
const patched = host.firstElementChild
render(h('button', { key: 'retry', title: 'v3' }, '重试'), host)
const replaced = host.firstElementChild
console.table({
sameTypeAndKey: first === patched,
changedKey: patched === replaced,
finalText: replaced?.textContent,
finalTitle: replaced?.getAttribute('title')
})
render(null, host)
console.log('after unmount:', host.childElementCount)应观察到:前两次都是 button + save,DOM 身份相同但文本和 title 被 patch;第三次 key 变为 retry,旧节点被替换;render(null, host) 后容器为空。不要把这里的具体耗时当作结论,实验只证明身份和宿主操作边界。
直接调用 render() 时没有组件响应式副作用,改变一个 ref 不会自动生成新 VNode;组件 render 之所以会自动重跑,是因为 Vue 在挂载组件时把它注册成了响应式 effect。
同一个 VNode 为什么不能放两次
VNode 在 patch 期间会关联宿主节点和组件实例等内部状态。把同一个 VNode 对象重复放入树中,会让“一份描述对应哪个宿主节点”失去唯一性。需要重复结构时,应调用工厂创建两个 VNode:
const makeBadge = () => h('span', { class: 'badge' }, 'new')
const row = h('div', [makeBadge(), makeBadge()])业务界面优先使用模板。模板更接近 HTML,也让编译器有机会标记静态节点和动态绑定;渲染函数适合结构高度动态、无渲染组件或自定义渲染器,不应为了“更底层”而默认使用。
变化条件与边界
| 改变 | patch 结果 | 还要检查什么 |
|---|---|---|
| 只改文本或已知 prop | 复用元素,更新对应字段 | 浏览器布局/绘制可能仍昂贵 |
| 节点类型改变 | 卸载旧树,挂载新树 | 焦点和非受控输入会丢失 |
| key 改变 | 强制进入替换身份的路径 | key 是否来自稳定业务标识 |
| 组件卸载 | 停止组件 effect,清理生命周期资源 | 自建监听器和计时器是否清理 |
练习:记录一条 patch 轨迹
实现 tracePatch(host):依次渲染 button(key=a, text=保存)、button(key=a, text=已保存)、button(key=b, text=重试),返回 { reused, replaced, finalText, afterUnmount }。输入容器开始时必须为空;最后调用 render(null, host)。断言前两次身份复用、第三次身份替换、最终文本为 重试、卸载后子节点数为 0。
参考答案:第 7 章第 01 题。