实战:手写一个迷你虚拟 DOM
🎯 引言
这是本课程的收官实战。我们将亲手实现一个迷你版 Snabbdom。别紧张——我们会把它拆成五个小步骤,每步只写十几行代码,写完一步就运行一次,马上能看到效果。全部完成后,虚拟 DOM 对你来说将不再是黑盒。
🧱 目标与设计
先认识一下要写的四个"角色":
| 部分 | 职责 | 类比 |
|---|---|---|
h | 把描述打包成虚拟节点对象 | 画图纸 |
createElm | 把虚拟节点变成真实 DOM | 按图施工 |
sameVnode | 判断两个节点是否相同 | 核对门牌号 |
patch | 首次渲染 + 对比更新 | 总指挥官 |
为了聚焦主干,做两个简化:选择器只支持纯标签名(不解析 #id 和 .class);子节点更新采用"清空重建",不做精细 Diff。
在项目中新建 mini-vdom.js,开工。
🛠 第一步:h(约 8 行)
h 只做一件事:把你写的描述打包成一个对象。
// 创建虚拟节点:只支持 h(sel, data, 文本) 和 h(sel, data, [子节点])
export function h(sel, data, params) {
if (typeof params === 'string') {
// h(sel, data, 文本)
return { sel, data, text: params };
}
// h(sel, data, [子节点])
return { sel, data, children: params };
}
对照第二篇:返回的对象里 sel、text、children 你都认识,没有任何新东西。
🛠 第二步:createElm(约 15 行)
createElm 负责把对象变成真实 DOM。逻辑就三条:创建元素、记住它、处理内容。
// 把虚拟节点变成真实 DOM
function createElm(vnode) {
const elm = document.createElement(vnode.sel);
vnode.elm = elm; // 记住对应的真实元素
if (vnode.text !== undefined) {
elm.textContent = vnode.text;
} else if (vnode.children) {
for (const child of vnode.children) {
elm.appendChild(createElm(child)); // 子节点也交给它处理
}
}
return elm;
}
vnode.elm = elm 这一行最关键,它就是第二篇说的"虚拟节点与真实 DOM 一一对应"的桥梁。子节点交给 createElm 自己递归处理,所以任意层级的嵌套都支持。
🛠 第三步:patch 第一版(只管渲染,6 行)
先写一个只会"首次渲染"的 patch,马上把页面跑起来:
// 渲染(第一版:只管首次渲染)
export function patch(oldVnode, newVnode) {
const elm = createElm(newVnode);
oldVnode.parentNode.replaceChild(elm, oldVnode);
return newVnode;
}
三行代码对应第四篇的知识:replaceChild 就是"用 vnode 生成的元素替换容器本身"。
立刻测试一下。 在 main.js 里写:
import { h, patch } from './mini-vdom.js';
const vnode = h('div', {}, [
h('h2', {}, '迷你虚拟 DOM'),
h('p', {}, '我们自己渲染出来的页面')
]);
patch(document.getElementById('app'), vnode);
打开浏览器,标题和段落出现了——我们自己的虚拟 DOM 已经能渲染页面了!
🛠 第四步:给 patch 加上更新能力
目前的 patch 更新时会报错,因为旧 vnode 不是容器。加上"相同节点"的判断和更新分支:
// 判断两个节点是否相同
function sameVnode(a, b) {
return a.sel === b.sel && a.data.key === b.data.key;
}
// 渲染与更新(完整版)
export function patch(oldVnode, newVnode) {
// 首次渲染:oldVnode 是容器
if (oldVnode.sel === undefined) {
const elm = createElm(newVnode);
oldVnode.parentNode.replaceChild(elm, oldVnode);
return newVnode;
}
if (sameVnode(oldVnode, newVnode)) {
// 相同节点:复用元素,只更新内容
newVnode.elm = oldVnode.elm;
if (newVnode.text !== undefined) {
newVnode.elm.textContent = newVnode.text;
} else {
// 简化:清空后重建子节点
newVnode.elm.innerHTML = '';
for (const child of newVnode.children) {
newVnode.elm.appendChild(createElm(child));
}
}
} else {
// 不同节点:直接替换
const elm = createElm(newVnode);
oldVnode.elm.parentNode.replaceChild(elm, oldVnode.elm);
}
return newVnode;
}
这段代码就是第六篇 Diff 规则的直白翻译:sameVnode 判断、文本直接覆盖、不同节点整体替换,一条不少。
再测试一次。 把 main.js 改成:
import { h, patch } from './mini-vdom.js';
let vnode = h('p', {}, '2 秒后我会变');
vnode = patch(document.getElementById('app'), vnode);
setTimeout(() => {
vnode = patch(vnode, h('p', {}, '我变了!'));
}, 2000);
刷新页面,文字 2 秒后自动变化——更新能力也有了。
🛠 第五步:补上事件绑定(4 行)
最后给 createElm 加一个小能力:识别 data 里的 on,绑定事件。在 vnode.elm = elm; 这行下面加上:
// 绑定事件
if (vnode.data.on) {
for (const name in vnode.data.on) {
elm.addEventListener(name, vnode.data.on[name]);
}
}
最终测试:计数器。 把 main.js 改成:
import { h, patch } from './mini-vdom.js';
let count = 0;
let vnode = render();
function render() {
return h('div', {}, [
h('p', {}, '当前计数:' + count),
h('button', { on: { click: add } }, '点我 +1')
]);
}
function add() {
count++;
vnode = patch(vnode, render()); // 数据变了 → 画新图纸 → 对比更新
}
vnode = patch(document.getElementById('app'), vnode);
点击按钮,计数正常累加——我们复刻了虚拟 DOM 的核心流程:状态 → 虚拟节点树 → patch → 真实 DOM。Vue 的数据驱动,本质就是这个循环。
⚖️ 与真实 Snabbdom 的差距
我们的迷你版是"教学骨架",真实 Snabbdom 还多做了这些事:
- 选择器解析(
#id、.class),以及 SVG 命名空间的处理。 - 模块机制:class、style、props、事件等都通过模块扩展。
updateChildren双端指针精细对比,子节点按 key 复用移动,而不是清空重建。- 完整的生命周期钩子和删除动画支持。
迷你版的价值在于:主干思想与真实实现完全一致,剩下的都是锦上添花。
🧾 小节总结
- 迷你虚拟 DOM 由四部分组成:
h建节点、createElm建 DOM、sameVnode判相同、patch做渲染与更新。 vnode.elm是虚拟节点与真实元素之间的桥梁。- 更新时 sameVnode 复用元素、文本直接覆盖、不同节点整体替换,与 Diff 原理一一对应。
- 计数器案例验证了"数据 → 虚拟节点 → patch → 视图"的驱动循环。
❓ 知识问答
Q1:为什么 patch 的返回值要赋回给 vnode?
A:因为后续更新必须以"最新一次的虚拟节点"作为旧树,赋回 vnode 才能让下一次 patch 拿到正确的对比基准。
Q2:清空重建子节点会把事件监听器也销毁吗?
A:会,但重建时 createElm 会重新绑定,所以功能不受影响。代价是性能浪费——这正是真实 Snabbdom 用 key 精细复用的原因。
Q3:sameVnode 里直接读 a.data.key,data 里没有 key 会报错吗?
A:不会。读取对象上不存在的属性只会得到 undefined,两个 undefined 相等,符合预期。
Q4:这个迷你版能用在生产环境吗?
A:不能。它没有模块机制和精细 Diff,仅用于学习。真实项目请使用 Snabbdom 或成熟的框架。
🧪 小练习
给迷你版升级一个能力:让 h 支持 h(sel, 文本) 的简写(第二个参数直接传字符串,不用写 data)。改完后用 h('p', '简写文本') 验证渲染。
export function h(sel, data, params) {
// 请在这里编写代码(提示:先判断第二个参数是否为字符串)
}
🎉 恭喜你已经完成了 Snabbdom 的全部课程!从虚拟 DOM 的概念,到 h、模块、patch、key、Diff,再到亲手实现迷你虚拟 DOM——你已经打通了虚拟 DOM 的任督二脉。这份功力,会在你阅读 Vue 源码、优化渲染性能时持续发光发热!
