Snabbdom 简介

实战:手写一个迷你虚拟 DOM

综合运用整门课程的知识,分五个小步骤手写一个迷你虚拟 DOM,每一步都能立即看到效果。

🎯 引言

这是本课程的收官实战。我们将亲手实现一个迷你版 Snabbdom。别紧张——我们会把它拆成五个小步骤,每步只写十几行代码,写完一步就运行一次,马上能看到效果。全部完成后,虚拟 DOM 对你来说将不再是黑盒。


🧱 目标与设计

先认识一下要写的四个"角色":

部分职责类比
h把描述打包成虚拟节点对象画图纸
createElm把虚拟节点变成真实 DOM按图施工
sameVnode判断两个节点是否相同核对门牌号
patch首次渲染 + 对比更新总指挥官

为了聚焦主干,做两个简化:选择器只支持纯标签名(不解析 #id.class);子节点更新采用"清空重建",不做精细 Diff。

在项目中新建 mini-vdom.js,开工。


🛠 第一步:h(约 8 行)

h 只做一件事:把你写的描述打包成一个对象

mini-vdom.js
// 创建虚拟节点:只支持 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 };
}

对照第二篇:返回的对象里 seltextchildren 你都认识,没有任何新东西。


🛠 第二步:createElm(约 15 行)

createElm 负责把对象变成真实 DOM。逻辑就三条:创建元素、记住它、处理内容。

mini-vdom.js
// 把虚拟节点变成真实 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,马上把页面跑起来:

mini-vdom.js
// 渲染(第一版:只管首次渲染)
export function patch(oldVnode, newVnode) {
    const elm = createElm(newVnode);
    oldVnode.parentNode.replaceChild(elm, oldVnode);
    return newVnode;
}

三行代码对应第四篇的知识:replaceChild 就是"用 vnode 生成的元素替换容器本身"。

立刻测试一下。main.js 里写:

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 不是容器。加上"相同节点"的判断和更新分支:

mini-vdom.js
// 判断两个节点是否相同
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 改成:

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; 这行下面加上:

mini-vdom.js
// 绑定事件
if (vnode.data.on) {
    for (const name in vnode.data.on) {
        elm.addEventListener(name, vnode.data.on[name]);
    }
}

最终测试:计数器。main.js 改成:

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', '简写文本') 验证渲染。

mini-vdom.js
export function h(sel, data, params) {
    // 请在这里编写代码(提示:先判断第二个参数是否为字符串)
}

🎉 恭喜你已经完成了 Snabbdom 的全部课程!从虚拟 DOM 的概念,到 h、模块、patch、key、Diff,再到亲手实现迷你虚拟 DOM——你已经打通了虚拟 DOM 的任督二脉。这份功力,会在你阅读 Vue 源码、优化渲染性能时持续发光发热!