3. 重排与重绘:浏览器渲染流水线深度解析,如何避免性能杀手

页面切换卡顿,很多时候不是网络慢,也不是 JS 执行慢。真正拖后腿的,是浏览器在背后默默做的那些「重排」和「重绘」。

我刚开始做前端那会儿,总觉得页面卡顿是框架的问题。后来有一次,一个列表页在滚动时疯狂掉帧,我用 Performance 面板一查,发现 Layout 和 Paint 占了 80% 的耗时。嗯,从那以后,我才真正开始重视渲染流水线。

3.1 浏览器渲染流水线:从 HTML 到像素

浏览器把代码变成屏幕上的像素,不是一步到位的。它走了一条流水线,每一步都可能成为性能瓶颈。

核心流水线(5 步走):

  1. DOM 树构建 — 解析 HTML,生成节点树
  2. CSSOM 树构建 — 解析 CSS,生成样式规则树
  3. Render Tree(渲染树) — 合并 DOM + CSSOM,只保留可见节点
  4. Layout(布局/重排) — 计算每个节点的几何位置(宽、高、x、y)
  5. Paint(绘制/重绘) — 把像素画到屏幕上

说白了,前两步是准备阶段,后两步才是真正的「渲染干活」。而重排和重绘,就是第 4 步和第 5 步的重复执行。

浏览器渲染流水线 DOM 树 CSSOM 树 Render Tree Layout (重排) Paint (重绘) 关键点: 修改几何属性(宽高、边距、位置)→ 触发重排 → 必然触发重绘。 只改外观属性(颜色、背景、阴影)→ 只触发重绘,不触发重排。

3.2 重排(Reflow)—— 性能杀手之王

重排,也叫 Layout。只要元素的几何属性变了,浏览器就得重新计算所有相关元素的位置和大小。

你想想看,一个元素宽度变了,它兄弟元素、父元素、甚至整个文档流都可能受影响。这就是重排的「连锁反应」。

⚠️ 哪些操作会触发重排?

  • 修改 width、height、padding、margin、border
  • 修改 top、left、position(脱离文档流)
  • 修改 display: none → block(隐藏/显示)
  • 读取 offsetTop、offsetLeft、scrollTop、clientWidth 等属性
  • 添加或删除 DOM 节点
  • 改变窗口大小(resize)

我记得有一次,一个同事在循环里反复读取 offsetHeight,然后修改元素高度。每次读取都会强制浏览器「提前」执行一次 Layout 队列。这就是典型的「强制同步布局」—— 性能毒药。

💡 避坑指南:

我曾经在优化一个表格组件时,发现每次排序都卡 2 秒。一查,排序函数里每比较一次就读一次 offsetTop。解决办法很简单:先把所有测量值缓存到变量里,操作完再统一写回。

3.3 重绘(Repaint)—— 没那么重,但也不能忽视

重绘只改变元素的外观,不改变几何尺寸。比如改个颜色、背景图、阴影。

重绘的代价比重排小,但也不是免费的。尤其是大面积区域重绘,比如整个页面背景色变化,或者一个覆盖全屏的弹窗出现,都会触发大量 Paint 操作。

操作 触发重排 触发重绘 性能影响
修改 width ✅ 是 ✅ 是 高
修改 color ❌ 否 ✅ 是 中
修改 transform ❌ 否 ❌ 否 低(合成层)
修改 opacity ❌ 否 ❌ 否 低(合成层)

3.4 如何避免重排与重绘?实战策略

光知道概念没用,得能落地。我总结了 6 条实战策略,每一条都是我在项目里踩过坑之后才真正理解的。

3.4.1 批量修改样式,别一条一条改

最蠢的做法:

// ❌ 糟糕:触发 3 次重排
element.style.width = '100px';
element.style.height = '200px';
element.style.margin = '10px';

正确做法:

// ✅ 推荐:用 class 一次性修改
element.classList.add('new-style');

// 或者用 cssText 批量写入
element.style.cssText = 'width:100px;height:200px;margin:10px;';

3.4.2 读写分离,避免强制同步布局

浏览器有个「渲染队列」机制。你连续改多个样式,它会攒一波再统一执行 Layout。但如果你在中间插了一个「读取」操作,队列就被清空了。

// ❌ 糟糕:读写交替,强制同步布局
for (let i = 0; i < items.length; i++) {
  let h = items[i].offsetHeight;  // 读 → 强制清空队列
  items[i].style.height = h + 10 + 'px';  // 写
}

// ✅ 推荐:先统一读,再统一写
let heights = [];
for (let i = 0; i < items.length; i++) {
  heights.push(items[i].offsetHeight);
}
for (let i = 0; i < items.length; i++) {
  items[i].style.height = heights[i] + 10 + 'px';
}

3.4.3 使用 transform 和 opacity 做动画

这两个属性不会触发重排和重绘。它们由 GPU 单独处理,走的是「合成层」。说白了,就是白嫖 GPU 的算力。

// ❌ 糟糕:用 left 做动画 → 每帧都重排
element.style.left = '100px';

// ✅ 推荐:用 transform → 只合成,不重排
element.style.transform = 'translateX(100px)';

🎯 核心原则:

能用 transform 和 opacity 解决的动画,绝对不要用 left、top、width、height。

3.4.4 脱离文档流,减少连锁反应

如果一个元素用 position: fixed 或 absolute,它的变化不会影响其他元素的位置。重排范围被限制在它自己身上。

我建议:对于频繁变化的元素(比如拖拽、弹窗、加载动画),尽量用 fixed 或 absolute 定位。

3.4.5 使用 will-change 提前告知浏览器

will-change 属性告诉浏览器:「这个元素接下来要变了,你先准备好合成层。」

/* 提前告知浏览器,这个元素要频繁变化 */
.element {
  will-change: transform, opacity;
}

⚠️ 注意:不要滥用 will-change。用得太多,浏览器会创建大量合成层,反而消耗内存。只在确实需要动画的元素上用。

3.4.6 避免频繁操作 DOM

DOM 操作本身就是昂贵的。频繁增删节点,每次都会触发重排。

// ❌ 糟糕:每次循环都插入 DOM
for (let i = 0; i < 1000; i++) {
  document.body.appendChild(createItem(i));
}

// ✅ 推荐:用 DocumentFragment 批量插入
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
  fragment.appendChild(createItem(i));
}
document.body.appendChild(fragment);

3.5 总结:一张表记住所有优化点

优化策略 核心原理 优先级
批量修改样式 利用渲染队列,减少 Layout 次数 ⭐⭐⭐
读写分离 避免强制同步布局 ⭐⭐⭐
transform / opacity 走合成层,不触发重排重绘 ⭐⭐⭐
脱离文档流 限制重排范围 ⭐⭐
will-change 提前创建合成层 ⭐⭐
DocumentFragment 批量 DOM 操作 ⭐⭐

说实话,重排和重绘是前端性能优化里最容易被忽视的环节。很多人只盯着 JS 执行时间,却不知道浏览器在 Layout 和 Paint 上浪费了多少帧。你想想看,一个 60fps 的动画,每帧只有 16.6ms。一次重排如果吃掉 10ms,那留给 JS 和绘制的时间就只剩 6ms 了。卡顿,就是这么来的。

嗯,这一章的内容就到这里。记住一句话:减少重排,就是减少浏览器的无效劳动。