Skip to content

01|虚拟 DOM 与渲染器 ​

第七章 / 01。目标:把“状态变了”拆成渲染函数重跑、产生新 VNode、patch 旧树、提交宿主操作四步。

VNode 不是 DOM 快照 ​

VNode 是对“这次希望界面长什么样”的描述。它至少携带节点类型、props、children 和 key;DOM 节点则是浏览器中的有状态对象,保存焦点、输入值、滚动位置等宿主状态。两者不能混为一谈。

一次组件更新经过下面的链路:

  1. 模板预先编译成 render function;render 读取响应式依赖并返回新 VNode 树。
  2. 首次运行是 mount:渲染器创建元素、设置属性、插入容器。
  3. 依赖触发组件更新后,render 重新执行;patch 比较旧、新 VNode。
  4. 相同类型且相同 key 的节点进入复用分支,只提交必要的文本、属性或子节点变化。
  5. 类型或 key 不同则卸载旧节点并挂载新节点,旧 DOM 身份与其局部状态一起结束。

响应式系统决定“何时需要更新组件”,渲染器决定“如何把新描述落实到平台”。浏览器之后还可能进行样式计算、布局、绘制;这些成本不是 VNode diff 本身。

最小可重放实验:直接观察 DOM 身份 ​

在一个已安装 Vue 3 的 Vite 项目中新建 src/renderer-lab.ts,把 src/main.ts 临时改为只导入 ./renderer-lab,不要同时 createApp 挂载同一容器:

ts
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:

ts
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 题。

用心记录每一步成长