对应代码:
lessons/00-dom、lessons/01-vdom
很多人第一次听到"虚拟 DOM",会把它理解成浏览器 DOM 的轻量复制品,甚至直接把它等同于"性能更快"。这两个理解都不准确。
这一篇不讨论 Diff,也不讨论 Fiber。我们只完成一个更基础的转变:先用普通 JavaScript 对象描述页面,再由渲染器把描述翻译成真实 DOM。读完后,就应该可以明白三个问题:
- 元素对象是什么
createElement做了什么- 以及为什么"有了虚拟 DOM"并不代表已经优化了 DOM 更新。
先看浏览器里的真实 DOM
浏览器解析 HTML 后,会维护一棵可变的 DOM 树。JavaScript 可以创建节点、修改属性、监听事件,也可以删除和移动节点。
lessons/00-dom/main.js 用这些 API 写了一个计数器。核心逻辑可以缩成下面几行:
js
let count = 0;
function render() {
const value = document.createElement("p");
value.textContent = String(count);
const button = document.createElement("button");
button.textContent = "+1";
button.addEventListener("click", () => {
count += 1;
render();
});
root.replaceChildren(value, button);
}
这里已经有了一条完整的 UI 更新链路:
text
用户点击 → count 改变 → 再次执行 render → DOM 改变
问题不在于原生 DOM "不能用",而在于业务代码同时承担了两类职责:它既要说明页面应该显示什么,又要亲自决定创建、替换和绑定哪些节点。
当前实现每次都调用 replaceChildren(),所以旧子树会被整棵替换。假如页面里有一个正在输入的文本框,替换后的输入框虽然看起来相同,却已经不是同一个 DOM 对象;焦点、选区和某些由浏览器保存的节点状态都可能丢失。
当然,我们也可以手工只更新 value.textContent。但随着条件分支和交互增多,开发者必须保证每条状态变化路径都正确地修改所有相关 DOM。我们真正想分离的是:
- 业务代码负责描述"状态对应什么页面";
- 渲染器负责决定"如何把页面实现出来"。
用普通对象描述 UI
先不考虑如何更新,下面这个对象已经足以描述一个按钮:
js
{
type: "button",
props: {
className: "primary",
onClick: handleClick,
children: [
{
type: "TEXT_ELEMENT",
props: { nodeValue: "保存", children: [] }
}
]
}
}
它不是 DOM 节点,调用 appendChild() 也无法把它放进页面。它只是一个普通 JavaScript 对象,是 UI 的一份声明式描述。这个项目把这样的对象称为 element(元素对象)。
元素对象只保留渲染器关心的信息:节点类型、属性和孩子。真实 DOM 上还有布局、焦点、滚动位置等大量浏览器状态,它们不会被复制进这个对象。
createElement 统一对象形状
手写嵌套对象很麻烦,因此 lessons/01-vdom/mini-react.js 提供了 createElement:
js
export function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.flat(Infinity)
.filter((child) =>
child !== null
&& child !== undefined
&& typeof child !== "boolean"
)
.map((child) =>
typeof child === "object" ? child : createTextElement(child)
),
},
};
}
三个参数分别表达:
type:例如"section"、"button";props:例如className、onClick;children:其余所有位置参数,可以是元素、文本或嵌套数组。
这里还有两次重要的规范化。
第一,字符串和数字会被包装为 TEXT_ELEMENT。浏览器创建元素节点要用 document.createElement(),创建文本节点要用 document.createTextNode();统一包装之后,渲染器可以让文本也走同一套递归过程。
第二,null、undefined 和布尔值会被忽略。这使下面的条件渲染自然成立:
js
createElement("section", null, isReady && createElement("p", null, "完成"))
当 isReady 为 false 时,孩子数组中不会产生一个可见节点。数字 0 则不能被过滤,因为它可能正是需要显示的内容。
JSX 与元素对象是什么关系
JSX 不是浏览器原生语法,也不是虚拟 DOM 本身。构建工具会把 JSX 转换为创建元素的函数调用。概念上:
jsx
<button className="primary">保存</button>
会变成类似下面的代码:
js
createElement("button", { className: "primary" }, "保存")
函数调用的返回值才是元素对象。本项目不引入 Babel,而是直接调用 createElement,这样我们能清楚看到中间结果。
render 把描述翻译成 DOM
有了元素对象,还需要渲染器创建真实节点:
js
export function render(element, container) {
const dom = element.type === "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(element.type);
for (const [name, value] of Object.entries(element.props)) {
if (name === "children") continue;
if (name.startsWith("on") && typeof value === "function") {
dom.addEventListener(name.slice(2).toLowerCase(), value);
} else {
dom[name] = value;
}
}
for (const child of element.props.children) render(child, dom);
container.appendChild(dom);
}
它做了四件事:
- 根据
type创建元素节点或文本节点; - 把事件 prop 转换成
addEventListener(); - 把其他 prop 写到 DOM property;
- 递归渲染孩子,最后把当前节点放入容器。
假设输入是 section → h2 → "标题",递归过程如下:
text
render(section, #root)
创建 <section>
render(h2, section)
创建 <h2>
render(TEXT_ELEMENT, h2)
创建文本节点
追加到 <h2>
追加 <h2> 到 <section>
追加 <section> 到 #root
递归调用中的 container 会不断变化,因此文本进入 h2,h2 进入 section,而不是所有节点都直接进入根容器。
声明式已经出现,更新优化还没有
现在,使用者可以只描述最终结果:
js
const element = createElement(
"section",
{ className: "demo" },
createElement("h2", null, "虚拟 DOM 是普通对象"),
createElement("button", { onClick: handleClick }, "点我"),
);
创建 DOM、设置属性和递归挂载都集中到了渲染器中。这就是声明式 UI 的起点。
但这一章的 render() 只会创建并追加新节点。它没有保存上一次的元素树,也没有比较新旧内容;如果对同一个容器调用两次,第二棵树会继续追加在后面,而不是智能更新第一棵树。
因此,虚拟 DOM 的直接价值是提供一份可计算、可比较的 UI 描述。至于能否减少 DOM 操作,要看下一步有没有协调算法,不能从"用了对象"直接推出"更快"。
动手验证
启动项目:
bash
npm run dev
打开课程首页后依次进入第 00、01 章,并完成两个实验。
- 在第 00 章的
render()中加入一个input。先点击输入框,再点击计数按钮,观察焦点是否保留。原因不是输入值变量改变,而是replaceChildren()换掉了节点对象。 - 在第 01 章的
main.js中加入ul和三个li。先查看页面中的 DOM 树,再查看页面下方打印的元素对象树,找出元素节点和TEXT_ELEMENT的对应关系。
你还可以在 Console 中保存一个元素对象并检查:它没有 nodeType、parentNode 等 DOM 属性,也不会出现在 Elements 面板里。
小结
- DOM 是浏览器维护的可变对象树;元素对象只是 UI 描述。
createElement负责规范化描述,render负责把描述翻译成 DOM。- JSX 通常只是创建元素函数的一种语法形式。
- 引入虚拟 DOM 并不自动减少 DOM 操作;当前版本甚至还不会更新。
下一篇会让 type 不只接受字符串,还可以接受函数。到那时,UI 描述就能跨过"单个元素",进入"组件组合"。