01|从手写 DOM 到虚拟 DOM:把 UI 从“操作”变成“描述”

对应代码:lessons/00-domlessons/01-vdom

mini-react 仓库地址

很多人第一次听到"虚拟 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:例如 classNameonClick
  • children:其余所有位置参数,可以是元素、文本或嵌套数组。

这里还有两次重要的规范化。

第一,字符串和数字会被包装为 TEXT_ELEMENT。浏览器创建元素节点要用 document.createElement(),创建文本节点要用 document.createTextNode();统一包装之后,渲染器可以让文本也走同一套递归过程。

第二,nullundefined 和布尔值会被忽略。这使下面的条件渲染自然成立:

js 复制代码
createElement("section", null, isReady && createElement("p", null, "完成"))

isReadyfalse 时,孩子数组中不会产生一个可见节点。数字 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);
}

它做了四件事:

  1. 根据 type 创建元素节点或文本节点;
  2. 把事件 prop 转换成 addEventListener()
  3. 把其他 prop 写到 DOM property;
  4. 递归渲染孩子,最后把当前节点放入容器。

假设输入是 section → h2 → "标题",递归过程如下:

text 复制代码
render(section, #root)
  创建 <section>
  render(h2, section)
    创建 <h2>
    render(TEXT_ELEMENT, h2)
      创建文本节点
      追加到 <h2>
    追加 <h2> 到 <section>
  追加 <section> 到 #root

递归调用中的 container 会不断变化,因此文本进入 h2h2 进入 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 章,并完成两个实验。

  1. 在第 00 章的 render() 中加入一个 input。先点击输入框,再点击计数按钮,观察焦点是否保留。原因不是输入值变量改变,而是 replaceChildren() 换掉了节点对象。
  2. 在第 01 章的 main.js 中加入 ul 和三个 li。先查看页面中的 DOM 树,再查看页面下方打印的元素对象树,找出元素节点和 TEXT_ELEMENT 的对应关系。

你还可以在 Console 中保存一个元素对象并检查:它没有 nodeTypeparentNode 等 DOM 属性,也不会出现在 Elements 面板里。

小结

  • DOM 是浏览器维护的可变对象树;元素对象只是 UI 描述。
  • createElement 负责规范化描述,render 负责把描述翻译成 DOM。
  • JSX 通常只是创建元素函数的一种语法形式。
  • 引入虚拟 DOM 并不自动减少 DOM 操作;当前版本甚至还不会更新。

下一篇会让 type 不只接受字符串,还可以接受函数。到那时,UI 描述就能跨过"单个元素",进入"组件组合"。

相关推荐
喜欢睡觉42 分钟前
单词管理系统
前端·后端
YHL1 小时前
向量数据库入门(二):Embedding → 语义搜索 → RAG 日记助手
前端·架构
码视野1 小时前
基于 Vue3 + Element Plus 的【青少年心理健康智能测评与 AI 情绪树洞陪护干预系统】设计与实现(含PRD/三端源码/大屏)
前端·vue.js·人工智能·vue3
专业抄代码选手1 小时前
03|React 为什么需要协调:比较两棵树,而不是重建页面
前端·javascript·react.js
灯澜忆梦1 小时前
【基于GO的Web开发11】gin获取URL‑Path 路径参数
前端·后端·golang·html·gin
王霸天1 小时前
GLB 文件太大怎么办?我把它从 47MB 压到 8MB,全程没碰命令行
前端·javascript·程序员
半个落月1 小时前
Next.js 16 笔记应用实战:Redis 数据链路与组件两版拆分详解
前端·redis·next.js
YIAN1 小时前
告别等后端!React+Vite 前端独立开发全流程:接口封装 + Mock 方案 + 状态管理
前端·react.js·vite
PedroQue991 小时前
@meng-xi/vite-plugin v1.3.0:generateUni 一键流水线
前端·vite