为什么 DOM 操作比普通 JavaScript 运算贵?

JavaScript1周前发布 admin
133 0

写过前端的人大概率都听过这句话——”少操作 DOM“。面试时被问到性能优化,十有八九也会提到”减少 DOM 操作次数”。但这句话背后的原理是什么?为什么同样是”执行一行代码”,操作 DOM 就要比普通的 JS 运算慢上几个数量级?

这篇文章从浏览器的架构出发,把这个问题讲清楚。

一、两个世界:JS 引擎与渲染引擎

要理解这个问题,首先要明白一个关键事实:JavaScript 和 DOM 其实活在两个不同的”世界”里

  • JavaScript 代码是由 JS 引擎执行的(比如 Chrome 的 V8、Firefox 的 SpiderMonkey)。
  • DOM 是浏览器渲染引擎(Rendering Engine,比如 Blink、WebKit)用来描述页面结构的一套数据模型,它服务于排版、绘制这些渲染相关的工作。

这两者原本是各自独立的模块,通过一层**桥接层(binding)**连接起来。当你在 JS 里写 document.getElementById(...) 或者修改某个元素的 style,本质上是通过这层桥接,跨越到渲染引擎的地盘里,去操作一套完全不同的数据结构。

这一次”跨界”的通信,就是性能开销的第一个来源。

二、开销从哪里来

1. 跨语言边界的调用成本

普通的 JS 运算,比如 let a = 1 + 2,自始至终都在 JS 引擎内部完成:变量分配、类型判断、数值计算,全部由高度优化的解释器或 JIT 编译后的机器码执行,不需要离开 JS 引擎的”地盘”。

而 DOM 操作不同。以 element.style.color = 'red' 为例,这行代码需要:

  1. JS 引擎识别出 element 是一个宿主对象(host object),而不是普通的 JS 对象;
  2. 通过绑定层,把这次属性赋值转发给渲染引擎;
  3. 渲染引擎接收请求,更新其内部维护的节点属性。

这个跨越引擎边界的调用过程本身就比一次纯粹的内存读写要昂贵得多,因为它涉及参数的封送(marshalling)、上下文切换等额外工作。

2. DOM 节点是”重”对象

DOM 节点不是普通的 JS 对象。每个 DOM 节点除了包含开发者能访问到的属性(比如 classNameinnerHTML),还携带了大量渲染引擎内部使用的信息——盒模型的几何数据、层叠上下文、样式计算的中间结果等等。这些信息的规模远超一个普通 JS 对象,创建、读取和修改的成本自然也更高。

3. 触发回流与重绘

这是 DOM 操作开销中最容易被忽视,但影响最大的部分。

浏览器渲染一个页面大致要经过这几个阶段:

样式计算(Style)→ 布局/回流(Layout/Reflow)→ 绘制(Paint)→ 合成(Composite)

当你修改的属性会影响元素的几何尺寸或位置(比如 widthheightmargin,或者插入/删除节点),浏览器需要重新计算受影响元素乃至整个页面的布局,这个过程叫回流(Reflow)。回流之后,通常还需要重绘(Repaint),把新的视觉效果绘制出来。

回流的代价很高,原因在于它往往不是”局部”的。比如你改变了一个 <div> 的宽度,浏览器可能需要重新计算它所有子元素、兄弟元素乃至祖先元素的布局,因为 CSS 布局本身就是一个相互依赖的计算过程。在复杂页面中,一次回流可能牵涉成千上万个节点的重新计算。

相比之下,一次普通的 JS 数值运算,时间复杂度是常数级的,根本不会牵扯到其他任何模块。

4. 浏览器的批量优化与被打断的代价

现代浏览器其实并不傻——它不会你改一次属性就立刻触发一次回流,而是会把多次修改放进一个队列里,等到某个时机(比如当前宏任务结束、或者下一次渲染帧)再统一处理,这叫做批量更新(batching)

但这套优化机制有一个前提:你不能在中途”强行”去读取那些依赖布局信息的属性,比如 offsetHeightgetComputedStyle()getBoundingClientRect() 等。一旦读取这些属性,浏览器为了给你返回准确的值,不得不提前把队列中积压的所有变更立刻执行一次回流,这种现象叫做强制同步布局(Forced Synchronous Layout),也常被称为布局抖动(Layout Thrashing)

如果在一个循环里反复”写样式、读布局、写样式、读布局”,浏览器的批量优化机制完全失效,每一次迭代都要单独触发一次回流,性能会呈数量级地下降。这也是为什么”频繁交替读写 DOM”是前端性能优化里被反复强调的一个坑。

三、一个对比:虚拟 DOM 为什么有效

理解了上面的原理,就不难理解 React 等框架里”虚拟 DOM”设计的意义。

虚拟 DOM 本质上是用普通的 JS 对象去描述页面结构。在数据变化时,框架先在 JS 世界里进行 diff 计算,找出真正需要变更的部分,最后再把这些变更一次性、批量地提交给真实 DOM。

它并没有让”单次 DOM 操作”本身变得更便宜,而是通过减少”JS 世界与渲染世界之间来回穿梭的次数”,把开销降到最低。这也印证了前面的结论:真正昂贵的不是某一次赋值动作,而是跨越引擎边界、触发布局计算这个过程本身。

四、实践中的启发

基于以上原理,几条常见的优化建议就有了扎实的解释依据:

  • 合并多次 DOM 修改,比如用 DocumentFragment 先在内存中构建好节点树,再一次性插入页面,避免多次触发回流。
  • 避免在循环中交替读写会触发布局的属性,读操作尽量集中在写操作之前,防止强制同步布局。
  • 优先修改不影响布局的属性(如 transformopacity),这类属性的变化通常只需要合成阶段的开销,可以跳过布局甚至绘制阶段,代价远低于修改 widthtop 这类几何属性。
  • 减少不必要的 DOM 节点数量,节点越多,一次回流波及的计算量往往越大。

小结

DOM 操作之所以比普通的 JS 运算贵,根源在于二者运行在两套不同的系统里:JS 运算始终留在 JS 引擎内部,是纯粹的内存计算;而 DOM 操作需要跨越到渲染引擎,不仅要承受跨边界调用的开销,还可能触发牵一发动全身的布局重新计算。理解这一点,才能在写代码时真正知道”贵”在哪里,而不是机械地记住”少操作 DOM”这条经验法则。

© 版权声明

相关文章

暂无评论

none
暂无评论...