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

对应代码:lessons/00-dom、lessons/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:例如 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);
}

它做了四件事:

  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 会不断变化,因此文本进入 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 章,并完成两个实验。

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

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

小结

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

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

相关推荐
panxianren6 小时前
Bun 1.4 的 bun test --parallel 将 4 秒的测试套件缩短到约 1 秒
java·javascript
陈随易8 小时前
bm2,Node.js 与 Bun 项目部署新选择
前端·后端·程序员
可乐鸡翅yeah_9 小时前
FFmpeg 生成 HLS 独立分片 independent‑segments 参数到底有什么用
javascript·ffmpeg·音视频·safari·m3u8
福兮说9 小时前
canvas 旋转图片的六个坑:四角被裁、Math.ceil 多出 1px 黑边、JPG 角发黑、翻转方向反了
前端·javascript·图像处理·canvas
Ai-_Man11 小时前
您您这可以把Grok的多个会话比如说。左侧的多个会话一次性导出吗?不是单条会话里面的多次会对话。AI导出鸭
javascript·人工智能·ai·小程序·电脑
传人once11 小时前
多图片上传预览功能如何写
前端·css·html·jquery·html5
Eric.4611 小时前
Wan‑Animate+MiniMax-H3 本地部署 AI 漫剧流水线:8G 显存动作迁移与动态分镜工程实战
前端·人工智能·comfyui·ai漫剧
liangshanbo121511 小时前
JavaScript 异步核心主线
javascript·事件循环·异步操作
传人once13 小时前
悬停旋转放大和位移效果如何写
javascript·css·动画
库拉镜像AI牛牛13 小时前
短剧内容自动化生产:知漫剧工作室落地教程
大数据·服务器·前端·人工智能·语音识别