从原生 DOM 到去 VDOM 的演进史与核心痛点

Published 2026-02-24 19:49 2119 words 11 min read

smile丶snow avatar

smile丶snow

大三/前端开发/百合汉化组成员/百合/偶尔也会做些动态壁纸

2026.03 - 至今
快手
前端开发实习生
2025.12 - 2026.03
北京蓝色光标数字传媒
前端开发实习生
This post is not yet available in English. Showing the original.
在前端面试和日常学习中,我们经常背诵关于 Virtual DOM (VDOM)、Diff 算法的八股文。但如果不结合技术演进的背景去思考"为什么会有它"以及"它的代价是什么",理解永远只能停留在表面。本文将从第一性原理出发,带你重走一遍前端渲染架构的演进之路。

一、纯手工时代
VDOM 时,我们怎么操作 DOM?

在原生 JS 或 jQuery 时代,前端采用的是命令式编程。状态(数据)和 UI 是割裂的,开发者需要像”微观管理者”一样,亲力亲为地指挥浏览器去修改真实的 DOM 节点。

💻 代码示例

// 1. 维护状态
let list = ["苹果", "香蕉"];
const ul = document.getElementById("list-container");
const btn = document.getElementById("add-btn");

// 2. 手动创建并插入 DOM
btn.addEventListener("click", () => {
  const newItem = "橘子";
  list.push(newItem);

  // 【痛点】:必须手动操作真实 DOM API
  const li = document.createElement("li");
  li.innerText = newItem;
  ul.appendChild(li);
});

⚠️ 缺点分析

  1. 心智负担极重
    ,还要时刻惦记着去精准查找和修改对应的 DOM。一旦交互复杂,代码极易膨胀成难以维护的”面条代码”。
  2. 极易引发性能灾难
    DOM 对象极其庞大。频繁、无序地操作真实 DOM,会不断触发浏览器的重排(Reflow)和重绘(Repaint),导致页面卡顿。

二、VDOM 时代
,代价是什么?

为了解放开发者,React/Vue 等框架引入了 VDOM。前端迈入声明式编程时代。现在的开发者只需要关注数据状态(Data),比如简单地执行一句 arr.push(item),框架会在背后默默帮你把 UI 更新好。

💻 代码示例 (Vue / React 思想)

// 开发者只需要关注数据本身,UI 的更新交给框架的"魔法"
const list = reactive(["苹果", "香蕉"]);

function addItem() {
  list.push("橘子"); // 就这么简单!
}

⚖️ 优缺点与底层的”计算税”

  • 优点(开发极爽)
    UI = f(state)。开发者完全脱离了繁琐的 DOM 操作,开发效率和代码可维护性得到质的飞跃。
  • 代价(底层开销)
    ,是因为它在背后运行了 VDOM 树的生成 和 Diff 算法。这也是框架带来的额外性能开销。
    • React
      ,React 会重新运行组件函数,生成一棵全新的 VDOM 树进行全局比对。如果不手动做性能优化(如 React.memo),极易导致大量不必要的渲染。
    • Vue
      (Proxy 收集依赖),Vue 能精确知道哪个组件的数据变了,Diff 范围被限制在组件内部,不会像 React 那样严重,但生成和比对 VDOM 对象的开销依然存在。

三、去 VDOM 时代

让我们思考一个极端的场景

10000 条数据,现在我通过 arr.push(item) 在末尾新增了 1 条。

  • VDOM 框架的笨拙
    10001 个 VDOM 节点,然后和旧的 10000 个节点进行 Diff 遍历比对。费了九牛二虎之力,最后发现”哦,原来只是在末尾加了一个节点”。
  • 原生 JS 的降维打击
    DOM 操作下,我们只需要简单的 ul.appendChild(newLi) 即可,没有任何额外的 Diff 比较开销,时间复杂度是 O(1)。

既然原生 API 性能这么高,能不能既保留 arr.push(item) 的极佳开发体验,又抛弃 VDOM 的额外性能开销呢?

随着现代前端编译技术的发展(如 SolidJSVue 3 Vapor Mode),去 VDOM 化正在成为未来的主流。

这些新一代框架利用强大的编译器,在代码构建阶段进行深度静态分析,将声明式的状态改变,直接编译成精确的、类似原生 JS 的底层 DOM 操作指令。彻底干掉了 VDOM 这个”中间商赚差价”,实现了性能与开发体验的完美统一。


四、附带高频思考题

❓ 思考一
Vue/React 的列表渲染中,绝对不能用 index 作为 key?

很多初学者认为 key 只要不报错就行,但 key 实际上是 VDOM Diff 算法判断”新旧节点是否为同一个”的唯一核心凭证。使用数组的 index 作为 key,在遇到列表顺序改变(如头部插入、倒序、中间删除)时,会引发灾难性的后果。

🎬 场景重现
”蒙蔽”的 Diff 算法

假设我们渲染一个留言列表,每条留言旁都有一个独立的输入框(带有内部状态):

  • 位置 0
    A (key: 0) -> DOM 节点 1 [输入框状态:“回复 A”]
  • 位置 1
    B (key: 1) -> DOM 节点 2 [输入框状态:“回复 B”]

现在我们在头部插入一条新留言 Z,理想情况是 A 和 B 整体下移。但当数据变成 [Z, A, B] 时,由于使用了 index,新状态变成了:

  • 位置 0
    Z (key: 0)
  • 位置 1
    A (key: 1)
  • 位置 2
    B (key: 2)

💥 灾难发生

此时 Diff 算法拿着新树去和老树比对,它的”脑回路”是这样的:

  1. 对比 key: 0
    A,新树是 Z。算法认为:“哦!DOM 没变,只是里面的文字从 A 变 Z 了。“于是强行把 DOM 节点 1 的文本改成了 Z。结果
    莫名其妙继承了”回复 A”的输入框状态!
  2. 对比 key: 1
    ,算法把 DOM 节点 2 的文本从 B 改成 A。结果
    继承了 B 的输入框!
  3. 对比 key: 2
    key,在底部新建一个空 DOM 给 B。

💡 核心结论(两宗罪)

  1. 状态张冠李戴(极其致命)
    (如 Input 内容、Checkbox 选中态)会因为 DOM 节点的错误复用,导致状态严重错乱。
  2. 性能极度劣化
    ,结果变成了所有节点全部被迫进行无意义的强制更新(打补丁),并在尾部新建节点,完全违背了 VDOM 尽量复用真实 DOM 的初衷。

、绝对不会发生顺序改变的列表中,使用 index 才是安全的。


❓ 思考二
——如果必须渲染 10 万条数据,怎么解决?

无论 VDOM 的 Diff 算法优化的多好,它都面临着物理极限

16.6ms 渲染帧DOM 节点的内存占用。如果真的在页面上画出 10 万个真实 DOM,不论是 React 还是 Vue,都会导致浏览器内存瞬间溢出或页面彻底卡死。

面对海量数据,工程界的终极解决方案是跳出 VDOM 的思维框架,使用”障眼法”——虚拟列表(Virtual List / Windowing)

🪄 虚拟列表的核心魔法

无论数据有 1 万条还是 10 万条,用户的屏幕一次最多只能看到几十条。虚拟列表的实现分为三步:

  1. 撑开高度欺骗浏览器
    单项高度 × 总条数,在外层放置一个看不见的占位元素,撑开真实的滚动条,让用户感觉下面还有很多内容。
  2. 计算可视窗口
    (比如 list.slice(startIndex, endIndex))交给框架去生成 VDOM。
  3. 动态替换
    ,实时计算最新的 startIndex,框架只需按需更新这几十个 VDOM 节点的内容,并利用 CSS transform 调整它们在屏幕上的绝对位置。

⚙️ 底层监听技术的演进

  • 传统方案(scroll 事件 + scrollTop): 监听外层容器的滚动事件。缺点

    事件触发频率极高(极易掉帧),且频繁读取 scrollTop 等布局属性会引发浏览器的同步强制重排(Reflow),占用主线程的大量开销。

  • 现代方案(Intersection Observer 新型交叉观察器): 这是目前主流的降维打击方案。放弃传统的滚动监听,利用浏览器原生提供的 API,在列表的顶部和底部放置”哨兵(Sentinel)“节点。当哨兵节点进入可视区域时,浏览器底层会异步、极其高效地触发回调,通知代码加载上一页或下一页的数据。它彻底解脱了 JS 主线程的滚动计算压力,性能极佳。

© 2024 - 2026 smile丶snow @YukiBloom
Powered by theme astro-koharu · Inspired by Shoka