K 的一隅

浏览器 浏览器核心机制

我只是改了一个 class,浏览器为什么突然变卡:DOM 更新、重排与重绘

从改造展板引入 DOM、CSSOM、渲染树、布局、绘制、合成与布局抖动,理解页面卡顿从哪里来。

5 分钟阅读 更新于 2026-07-24
本文目录

商场要把一块“今日促销”的展板改成红色。看起来只是换一层油漆,现场却可能要重新测量展板位置、移动后面的货架、调整射灯,最后再把整面墙拍进新的照片。浏览器更新页面也有类似的成本层级:有时改一个颜色就够,有时一个尺寸变化会让周围所有元素重新计算。

这里的生活类比不是说浏览器真的拿着尺子和刷子工作,而是提醒我们:DOM 修改、布局计算、像素绘制和合成不是同一件事。理解它们各自负责什么,才知道“优化一次 class 切换”到底在优化哪一步。

先猜结果:改颜色和改宽度一样贵吗

html
<button id="toggle">切换</button>
<div id="card" class="card">一张卡片</div>
<script>
  toggle.addEventListener('click', () => {
    card.classList.toggle('highlight')
  })
</script>

如果 .highlight 只改变 background-color,浏览器通常不需要重新计算所有盒子的几何位置;如果它改变 widthfont-size 或边距,后面的元素可能都要重新布局。两段代码看起来一样短,渲染影响却取决于 CSS 属性和页面结构。

不要把“改 class”当作成本单位。class 只是触发样式重新匹配的入口,真正成本由最终计算出的样式变化决定。

DOM、CSSOM 与渲染树

改动越靠左,越可能让后面的阶段重新执行;改动只影响合成层时,成本通常更低。

DOM(文档对象模型)描述节点和父子关系;CSSOM(CSS Object Model,CSS 对象模型)描述浏览器解析出的样式规则。浏览器把两者结合,再形成用于绘制的 render tree(渲染树)。渲染树通常只包含需要显示的内容,display: none 的节点不会以普通盒子参与布局。

js
const title = document.querySelector('h1')
title.textContent = '新的标题'
title.style.color = 'tomato'

第一行改变 DOM 文本,第二行改变样式。浏览器不会为每一行立即从头画一遍页面;它可以先记录变化,在合适的时机批量计算。但如果脚本主动读取布局信息,可能迫使浏览器提前把积累的工作做完。

layout、paint、compositing 分别在做什么

layout(布局,也常被叫 reflow,重排)回答“每个盒子在哪里、多大、如何占位”。一个元素宽度变化,可能影响父元素、兄弟元素和后代,因此布局影响范围可能很大。

paint(绘制,也常被叫 repaint,重绘)把背景、文字、边框、阴影等视觉内容画到图层或位图上。只改变颜色时,几何位置可能不变,但像素仍需要更新。

compositing(合成)把已经准备好的图层按顺序组合成最终画面。某些 transform、opacity 动画可以主要在合成阶段变化,减少布局和绘制压力,但“加了 will-change 就一定更快”并不成立:图层过多会消耗内存和合成成本。

这三个词是工程上的观察层,不是 JavaScript 规范里的函数调用顺序。不同浏览器、设备和属性会采用不同优化路径,不能用一张简化图替代 Performance 面板的真实证据。

强制同步布局为什么会出现

浏览器希望把多次 DOM 写入合并,但脚本有时会马上询问布局:

js
for (const item of items) {
  item.classList.add('wide')
  console.log(item.offsetHeight)
}

每次读取 offsetHeight,浏览器都必须给出最新布局结果。前一行写入可能让旧布局失效,于是浏览器被迫先计算,再返回高度;循环就可能变成“写一点、算一次、再写一点、再算一次”。这不是因为 offsetHeight 本身邪恶,而是读写交错让浏览器失去批量合并的机会。

更稳的结构是先集中读取,再集中写入:

js
const heights = items.map((item) => item.offsetHeight)
for (const item of items) item.classList.add('wide')

真实代码还要考虑读取结果是否必须在写入后立即得到。不要为了遵循口诀而把所有读取都挪走;先用 Performance 证明布局计算是瓶颈,再调整顺序。

批量修改和离线构建

创建一个文档片段并一次插入,可以减少中间状态被页面观察的次数:

js
const fragment = document.createDocumentFragment()
for (const name of names) {
  const li = document.createElement('li')
  li.textContent = name
  fragment.append(li)
}
list.replaceChildren(fragment)

这段代码不是魔法加速器,但它把“构造很多节点”和“把结果交给页面”分开。replaceChildren 也会触发一次较大的更新,是否更快取决于节点数量、样式复杂度和后续布局范围。

批量切换 class 也常常比逐个改 style 更容易维护:

js
panel.classList.toggle('expanded', shouldExpand)

CSS 负责描述状态,JavaScript 负责切换状态。这样不代表所有 CSS 属性都能合成,也不代表 DOM 改动没有成本;只是让浏览器和开发者都能看到一个清晰的状态边界。

不是所有动画都要用 transform

transform: translateopacity 常被用于动画,因为它们通常不需要重新安排文档流;但动画对象仍可能触发绘制,阴影、滤镜、复杂文字和低端设备会改变成本。改变 topleftwidth 可能引起布局,改变 background-color 可能引起绘制。

更重要的是动画是否真的需要每一帧由 JavaScript 计算。纯状态切换可以用 CSS transition;需要根据滚动位置或指针坐标连续控制时,再考虑 requestAnimationFrame,并把读写分成清晰阶段。

容易踩的坑

重排不一定总比重绘贵,关键看影响范围和内容复杂度。transform 也不是免卡金牌:合成层过多、主线程长任务、图片解码都能让动画掉帧。浏览器通常不会“改一行 DOM 就立刻重排”,而是合并更新;只有你强制读布局信息时才可能提前触发。给所有元素加 will-change 更像反优化——它提前占资源,只留给短期真要动的元素。