一、纯手工时代 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);
});
⚠️ 缺点分析
- 心智负担极重,还要时刻惦记着去精准查找和修改对应的 DOM。一旦交互复杂,代码极易膨胀成难以维护的”面条代码”。
- 极易引发性能灾难 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 对象的开销依然存在。
- 在 React 中,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 的额外性能开销呢?
随着现代前端编译技术的发展(如 SolidJS 和 Vue 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 算法拿着新树去和老树比对,它的”脑回路”是这样的:
- 对比 key: 0 A,新树是 Z。算法认为:“哦!DOM 没变,只是里面的文字从 A 变 Z 了。“于是强行把 DOM 节点 1 的文本改成了 Z。结果 莫名其妙继承了”回复 A”的输入框状态!
- 对比 key: 1,算法把 DOM 节点 2 的文本从 B 改成 A。结果 继承了 B 的输入框!
- 对比 key: 2 key,在底部新建一个空 DOM 给 B。
💡 核心结论(两宗罪)
- 状态张冠李戴(极其致命)(如 Input 内容、Checkbox 选中态)会因为 DOM 节点的错误复用,导致状态严重错乱。
- 性能极度劣化,结果变成了所有节点全部被迫进行无意义的强制更新(打补丁),并在尾部新建节点,完全违背了 VDOM 尽量复用真实 DOM 的初衷。
注
、绝对不会发生顺序改变的列表中,使用 index 才是安全的。
❓ 思考二——如果必须渲染 10 万条数据,怎么解决?
无论 VDOM 的 Diff 算法优化的多好,它都面临着物理极限
16.6ms 渲染帧 和 DOM 节点的内存占用。如果真的在页面上画出 10 万个真实 DOM,不论是 React 还是 Vue,都会导致浏览器内存瞬间溢出或页面彻底卡死。面对海量数据,工程界的终极解决方案是跳出 VDOM 的思维框架,使用”障眼法”——虚拟列表(Virtual List / Windowing)。
🪄 虚拟列表的核心魔法
无论数据有 1 万条还是 10 万条,用户的屏幕一次最多只能看到几十条。虚拟列表的实现分为三步:
- 撑开高度欺骗浏览器
单项高度 × 总条数,在外层放置一个看不见的占位元素,撑开真实的滚动条,让用户感觉下面还有很多内容。 - 计算可视窗口(比如
list.slice(startIndex, endIndex))交给框架去生成 VDOM。 - 动态替换,实时计算最新的
startIndex,框架只需按需更新这几十个 VDOM 节点的内容,并利用 CSStransform调整它们在屏幕上的绝对位置。
⚙️ 底层监听技术的演进
-
传统方案(scroll 事件 + scrollTop): 监听外层容器的滚动事件。缺点
事件触发频率极高(极易掉帧),且频繁读取scrollTop等布局属性会引发浏览器的同步强制重排(Reflow),占用主线程的大量开销。 -
现代方案(Intersection Observer 新型交叉观察器): 这是目前主流的降维打击方案。放弃传统的滚动监听,利用浏览器原生提供的 API,在列表的顶部和底部放置”哨兵(Sentinel)“节点。当哨兵节点进入可视区域时,浏览器底层会异步、极其高效地触发回调,通知代码加载上一页或下一页的数据。它彻底解脱了 JS 主线程的滚动计算压力,性能极佳。