JavaScript性能优化:减少重绘和回流

张玥 2026年9月21日 阅读时间 60分钟
性能优化 重绘 回流 渲染管线 合成层 最佳实践
前端开发技巧之JavaScript性能优化:减少重绘和回流

“性能优化”这四个字,几乎每个前端都说过,但真正能把它落到实处的并不多。很多团队的做法是:页面卡了,加个 will-change;动画抖了,换成 transform;列表长了,上虚拟滚动——这些都没错,但它们都是症状级别的止痛药。真正的性能问题往往出在一个更底层的地方:你没有搞清楚浏览器在一帧 16.7 毫秒里到底做了什么。而“重绘”和“回流”这两个词,正是打开这扇门的钥匙。本篇 60 分钟长文,从浏览器渲染流水线讲起,把重绘与回流的触发条件、代价差异、测量方法逐一拆开,再用七个交互实验台让你亲手感受 left 与 transform 的差距、强制同步布局有多贵、合成层到底在做什么,最后给出十二条可落地的优化手段、一份属性代价速查表,以及一场从 200 行降到 30 行的完整实战重构。读完之后,你未必会记住所有 API,但你一定能建立一种新的直觉:每一次改样式、每一次读尺寸,都要先问一句——它会让浏览器重新算一遍布局吗?

一、为什么“重绘和回流”值得单独讲

在讨论技术细节之前,先建立一个尺度感。用户对界面流畅度的感知,大致遵循这样几条经验线:

100ms 低于这个时间的操作,用户会觉得是“瞬间完成”的
1s 超过 1 秒,用户会开始注意到等待,但思路还没断
16.7ms 60fps 下每帧的预算,超出就会掉帧
50ms 超过这个时间的长任务,会明显阻塞输入响应

16.7 毫秒听起来很宽松——毕竟是千分之十六秒。但问题在于,这一帧里浏览器要做的事情远比想象中多:执行你的 JavaScript、计算样式、重新布局、把元素画成像素、把多个图层合成为最终画面,还要留出时间给浏览器自身的内务、给其他标签页、给输入事件。

而在这些环节里,布局(Layout,也就是常说的“回流”)通常是单次代价最高的一个。原因很简单:它是一棵树的遍历。你的 DOM 有多深、节点有多少、有没有表格、有没有绝对定位、有没有文字换行,都会影响这一趟遍历的成本。更糟的是,一次“读尺寸”操作就可能强迫浏览器立刻中断当前工作、把布局算完——这就是所谓的“强制同步布局”,是前端性能问题里最隐蔽、也最致命的一类。

性能问题的三种典型表现

现象 用户感受 底层原因
滚动时页面发抖、掉帧 “这网站好卡” 滚动中触发大量回流,帧预算被击穿
点击按钮后要等一秒才有反应 “是不是没点上?” 主线程被长任务占满,输入事件排在后面
动画一卡一卡的,像幻灯片 “这个动画做得真差” 动画属性触发了逐帧回流,没有走合成
页面加载完成后突然“跳一下” “我刚才点到哪了?” 图片或字体撑开布局,引发累积布局偏移
页面开着开着越来越卡 “得刷新一下了” 监听器泄漏、DOM 节点只增不减、内存上涨

这五类现象里,前四类都直接或间接与重绘、回流有关。所以把它们单独拿出来讲,并不是学院派的理论洁癖,而是因为它处在绝大多数前端卡顿问题的因果链上游。

一个先建立的直觉

在继续往下之前,请先记住一句话,它会贯穿全文:

便宜的修改
改变一个元素的位置、大小、透明度,如果不影响其他元素的布局,浏览器可以跳过布局阶段,甚至只更新一小块像素。
昂贵的修改
改变一个元素的宽度、字体、内容,浏览器必须重新计算它以及所有受影响元素的几何位置,然后重画,再重新合成。

性能优化的很大一部分工作,本质上就是把昂贵的修改,想办法变成便宜的修改。这句话听起来朴素,但它解释了本文后面几乎所有技巧的动机。

二、一帧之内:浏览器渲染流水线

要理解重绘和回流,必须先知道它们在流水线的哪一环。现代浏览器把“把 HTML 变成屏幕上的像素”这件事拆成了几个阶段,点击下面的卡片可以展开每个阶段的具体职责:

1
样式计算
Style
把 CSS 规则匹配到 DOM 节点上,计算出每个元素的最终样式值。选择器越复杂、规则越多,这一步越慢。修改 class 会触发这一步。
2
布局
Layout / 回流
根据样式值计算每个元素的几何位置和尺寸:x、y、宽、高。这是一个自顶向下的树遍历,父元素的尺寸变化会连锁影响所有子元素。这一步最贵。
3
绘制
Paint / 重绘
把每个元素画成像素,填充到一个或多个图层里。颜色、背景、阴影、边框都在这步处理。位置没变但外观变了,只会走到这一步。
4
合成
Composite
把多个图层按正确顺序叠在一起,交给 GPU 输出到屏幕。如果只改了 transform 或 opacity,浏览器可以跳过前三步,直接在合成阶段完成。

这四步的关系是递进的,而不是并列的:

  • 如果只改 opacity、transform:只走 ④合成,最便宜。
  • 如果改 color、background-color、box-shadow:走 ③绘制 → ④合成,这就是重绘。
  • 如果改 width、height、margin、font-size、增删节点:走 ②布局 → ③绘制 → ④合成,这就是回流。
  • 如果改的是会引发样式重算的 class:还要多走 ①样式计算。

帧预算到底有多紧张

把 16.7 毫秒拆开看,你会发现留给“布局 + 绘制”的空间其实非常有限:

一帧 16.7ms 的典型分配 60fps
JS 执行 22%
样式 12%
布局 20%
绘制 18%
合成与余量 28%
这只是一个示意。真实项目里,JS 执行往往会吃掉 50% 以上,而布局阶段一旦因为强制同步布局被反复触发,单帧的成本可以轻松突破 50ms——那一刻,页面就真的“卡”了。红色部分代表必须留出的余量,用于浏览器内务、输入响应和意外开销。

JS 也会阻塞渲染

很多人以为“渲染是浏览器的事,和我的 JS 无关”。恰恰相反:主线程只有一个,你的 JavaScript 和浏览器的样式计算、布局、绘制是排队共享这一个线程的。一段跑了 200ms 的循环,会让这一帧里的布局和绘制完全没法执行,页面表现为“僵住”。

// 一个非常典型的"阻塞渲染"写法
function handleClick() {
  const start = performance.now();
  let sum = 0;
  for (let i = 0; i < 1e8; i++) {
    sum += Math.sqrt(i);
  }
  // 这 300~800ms 里,页面完全无法响应
  console.log(performance.now() - start);
}

// 改进方向一:切片,让出主线程
async function handleClickAsync() {
  let sum = 0;
  for (let i = 0; i < 1e8; i += 1e5) {
    const end = Math.min(i + 1e5, 1e8);
    for (let j = i; j < end; j++) sum += Math.sqrt(j);
    // 让出主线程,浏览器有机会插入渲染帧
    await scheduler.yield?.() ?? new Promise((r) => setTimeout(r, 0));
  }
  console.log(sum);
}

// 改进方向二:丢给 Worker,主线程完全不参与
const worker = new Worker('./heavy.js');
worker.postMessage({ data });
worker.onmessage = (e) => render(e.data);

scheduler.yield() 是目前最优雅的让出方式:它会把控制权交还浏览器,让浏览器有机会处理渲染和输入,然后在同一个任务优先级上恢复执行,而不是像 setTimeout(0) 那样至少带来 4ms 的延迟。在不支持它的浏览器上,回退到 setTimeout 或 MessageChannel 即可。

三、重绘与回流:一字之差的成本鸿沟

现在可以把这两个概念精确定义一下了。

// 回流(Layout / Reflow)
// 元素的几何属性发生变化,浏览器必须
// 重新计算所有受影响元素的尺寸和位置

el.style.width = '200px';
el.style.height = '100px';
el.style.padding = '10px';
el.style.margin = '20px';
el.style.fontSize = '18px';
parent.appendChild(newNode);
el.classList.add('hidden'); // display: none

// 代价:布局 → 绘制 → 合成
// 重绘(Repaint)
// 元素的几何属性没变,只是外观变了
// 浏览器只需重新画一遍像素

el.style.color = '#e11d48';
el.style.background = '#fef3c7';
el.style.visibility = 'hidden';
el.style.outline = '2px solid red';
el.style.boxShadow = '0 4px 12px rgba(0,0,0,.3)';
el.style.borderColor = '#2563eb';

// 代价:绘制 → 合成

两者最关键的区别是:回流一定伴随重绘,但重绘不一定伴随回流。因为几何位置变了,画面当然要重画;而只是换个颜色,位置没动,就不需要重新计算布局。

回流的范围:比你想的要大

很多人的直觉是“我改了某个元素的宽度,那就只重算那个元素”。事实并非如此。浏览器的布局是一个自顶向下的整体计算,一个元素的尺寸变化,可能引发连锁反应:

  • 父元素如果是 width: auto 或 fit-content,会被子元素撑开,于是父元素也要重算。
  • 父元素变宽了,它的兄弟元素如果用了 flex: 1,也会跟着变。
  • 兄弟元素变窄了,里面的文字可能从一行变成两行,高度随之变化。
  • 高度变了,后面的元素全部要往下挪。
  • 滚动条可能出现或消失,视口宽度又变了……

所以在最坏的情况下,修改一个叶子节点的宽度,可能导致整棵布局树重算。这不是浏览器的实现缺陷,而是布局本身的语义决定的——CSS 是一种“相互依赖的约束系统”,没有哪一处的变化是真正孤立的。

局部回流 脱离文档流的元素(position: absolute/fixed)改动尺寸,影响范围较小
区域回流 普通流中某个容器内部的改动,通常限制在该容器的子树内
全局回流 改动 body 尺寸、字体、视口大小、根节点字号,整棵树都要重算

属性代价速查表

下面这张表建议直接收藏。它回答的是同一个问题:改这个属性,浏览器要重跑到流水线的哪一步?

修改的内容 流水线阶段 代价
transform / opacity / filter 合成 低
color / background-color / border-color 绘制 + 合成 中
visibility / outline / border-radius 绘制 + 合成 中
box-shadow / background-image 绘制 + 合成 中偏高
width / height / padding / margin 布局 + 绘制 + 合成 高
top / left / right / bottom 布局 + 绘制 + 合成 高
font-size / line-height / font-family 布局 + 绘制 + 合成 极高
增删 DOM 节点、修改文本内容 布局 + 绘制 + 合成 高
display: none ↔ block 布局 + 绘制 + 合成 高
表格内容变化(<td> 宽度自适应) 整表重新布局 极高
读取 offsetTop / getBoundingClientRect() 可能触发强制同步布局 看上下文

注意最后一行。读取布局属性本身不应该有代价,但如果前面刚写过样式,浏览器就必须立刻把布局算完才能回答你——这就是下一节要重点讨论的“强制同步布局”。

四、性能头号杀手:强制同步布局

浏览器为了性能,会把样式的修改攒起来批量处理:你连续改十次 style.width,它只会在这一帧结束时算一次布局。这个“攒”的过程叫布局的惰性求值。

但这个优化有一个前提:你不能在中途问它“现在多宽?”。一旦你读取了任何布局相关的属性,浏览器就必须立刻中止攒批、把当前的样式全部应用、把布局算完,才能给你一个准确的答案。这次被迫的、同步的布局计算,就是强制同步布局(Forced Synchronous Layout),也叫布局抖动(Layout Thrashing)。

亲手感受一下:读写交替 vs 先读后写

下面的实验会在 200 个格子上做同样的事情——改宽度、读高度。区别只在于顺序。点击按钮,看看两次耗时差多少(数字因设备而异,但倍数关系很稳定):

// 等待运行…
// ❌ 反例:每一轮循环都强迫浏览器算一次布局
for (let i = 0; i < items.length; i++) {
  items[i].style.width = widths[i] + 'px'; // 写
  const h = items[i].offsetHeight; // 读 → 强制布局!
  items[i].textContent = h;
}
// 200 个元素 → 最坏情况 200 次完整的布局计算

// ✅ 正解:把所有"读"集中到所有"写"之前
const 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.width = widths[i] + 'px'; // 再全部写
  items[i].textContent = heights[i];
}
// 只在最后触发一次布局

哪些属性会触发强制同步布局

只要读取下列任意一个属性,都可能强迫浏览器立刻完成布局:

// 几何尺寸
offsetTop / offsetLeft
offsetWidth / offsetHeight
clientTop / clientLeft
clientWidth / clientHeight
scrollTop / scrollLeft
scrollWidth / scrollHeight
// 计算样式与布局信息
getBoundingClientRect()
getClientRects()
getComputedStyle(el).width
element.focus()
element.scrollIntoView()
range.getBoundingClientRect()
window.getSelection().toString()

注意 getComputedStyle:如果你读的是 color 这种不依赖布局的属性,通常不会触发;但读 width、height、top 这些,就一定会。

批量读写的三种工程化方案

方案一:手动分区。最朴素也最有效。在写代码时,刻意把函数分成“只读”和“只写”两部分,中间用一个变量传递数据。上面实验里的正解就是这个思路。

方案二:用 rAF 把写操作推迟到下一帧。读操作在事件回调里完成,写操作排队到 requestAnimationFrame 里统一执行——因为 rAF 的回调正好在浏览器准备渲染之前执行,天然就是一个“批量写入窗口”。

const writeQueue = [];
let scheduled = false;

function scheduleWrite(fn) {
  writeQueue.push(fn);
  if (scheduled) return;
  scheduled = true;

  requestAnimationFrame(() => {
    scheduled = false;
    // 一次性执行所有写操作,只触发一次布局
    const tasks = writeQueue.splice(0);
    tasks.forEach((task) => task());
  });
}

// 用起来很顺手
scheduleWrite(() => { box.style.width = '320px'; });
scheduleWrite(() => { box.style.height = '180px'; });

方案三:使用 FastDOM 之类的读写调度库。它的核心思想就是把所有 DOM 操作分成 measure 和 mutate 两类,每一帧先统一执行所有 measure,再统一执行所有 mutate,从框架层面杜绝读写交替。FastDOM 的源码只有几百行,非常值得一读。

// FastDOM 风格
fastdom.measure(() => {
  const width = element.offsetWidth;
  fastdom.mutate(() => {
    element.style.width = width / 2 + 'px';
  });
});

五、实验台:不同动画属性的真实差距

理论说完了,来做一个能亲手玩的实验。下面的实验台会同时驱动一批方块来回摆动,你可以自由切换驱动它们的属性,并实时观察帧率。注意看帧率计的颜色变化——低于 50fps 时会变红。

建议按这个顺序玩一遍:先用默认的 transform 跑十秒,记下帧率;然后切到 left,观察帧率掉到什么程度;再切到 background-color,看它介于两者之间。最后把方块数量拉到 120,再切回 transform——你会发现帧率几乎不掉。这就是合成层的威力。

为什么 transform 这么快

因为 transform 和 opacity 的变化,不会影响其他元素的布局。一个元素平移或者透明了,它旁边的元素该在哪还在哪,文字该换行还是换行。浏览器因此可以跳过样式计算、布局、绘制,直接在合成阶段把这个图层挪个位置——这个操作可以由 GPU 并行完成,比 CPU 逐像素重画快一个数量级。

用这些做动画
  • transform: translate() 代替 top/left
  • transform: scale() 代替 width/height
  • opacity 代替 visibility 的淡入淡出
  • filter: blur() 通常也能走合成
避免逐帧改这些
  • left / top / right / bottom
  • width / height / padding / margin
  • font-size / line-height
  • 任何触发 display 切换的操作

一个必须知道的副作用

transform: scale() 虽然在渲染上很便宜,但它缩放的是绘制结果,而不是布局尺寸。也就是说,一个 100×100 的盒子 scale(2) 之后,视觉上是 200×200,但它在布局树里仍然只占 100×100 的位置,周围的元素不会给它让路,文字也会被一起拉伸变形。如果目的是“真的变大”,还是得改 width/height;如果只是视觉上的强调(比如按钮按下的回弹),scale 才是正解。

六、十二条可落地的优化手段

下面这些手段,按“投入产出比”从高到低排列。前五条几乎适用于所有项目,后面的按需取用。

6.1 用 transform 和 opacity 做动画

前面已经反复强调过,这里只补充一个细节:如果动画元素在动画开始前就声明 will-change: transform,浏览器可以提前把它提升为独立图层,避免动画第一帧出现“突然加速”的抖动。但不要给所有元素都加 will-change——每个合成层都要占用显存,图层过多反而会让合成阶段变慢。

/* ❌ 全局滥用 will-change,图层数量爆炸 */
* { will-change: transform; }

/* ✅ 只给真正会动的元素,并且在动画结束后移除 */
.card {
  transition: transform 0.3s ease;
}
.card:hover {
  will-change: transform;
  transform: translateY(-4px);
}

/* ✅ 更现代的做法:CSS 动画自带图层管理 */
@keyframes float {
  from { transform: translateY(0); }
  to { transform: translateY(-10px); }
}

6.2 批量操作 DOM

增删节点是回流的重灾区。如果一次要插入 1000 个节点,逐个 appendChild 会让浏览器反复重算布局。正确做法有两种:

// 方案一:DocumentFragment
const frag = document.createDocumentFragment();

data.forEach((item) => {
  const li = document.createElement('li');
  li.textContent = item.name;
  frag.appendChild(li); // 不在文档树中
});

// 只触发一次回流
list.appendChild(frag);
// 方案二:先移出文档流再操作
const parent = list.parentNode;
const next = list.nextSibling;

parent.removeChild(list); // 脱离文档流

// 这时候怎么改都不会触发回流
data.forEach((item) => {
  list.appendChild(buildItem(item));
});

parent.insertBefore(list, next); // 一次回流

另一个常见场景是“先清空再填充”:与其循环 removeChild,不如直接 innerHTML = '',或者用 replaceChildren() 一次性替换全部子节点。后者是近年新增的 DOM API,语义清晰且性能很好。

// ❌ 逐个删除
while (list.firstChild) list.removeChild(list.firstChild);

// ✅ 一次性替换(清空)
list.replaceChildren();

// ✅ 一次性替换(填充,接受多个节点或字符串)
list.replaceChildren(...newNodes);

6.3 用 class 切换代替逐条改 style

每改一次 style.xxx,都可能让浏览器重新计算样式。如果一次要改五个属性,那就是五次潜在的重算。把它们打包成一个 class,只改一次 className,浏览器只需要重算一次。

// ❌ 五次样式写入,可能触发多次样式重算
el.style.width = '240px';
el.style.height = '160px';
el.style.background = '#eff6ff';
el.style.borderRadius = '12px';
el.style.boxShadow = '0 8px 24px rgba(37,99,235,.2)';

// ✅ 一次切换,一步到位
el.classList.add('is-highlighted');

/* CSS */
.is-highlighted {
  width: 240px;
  height: 160px;
  background: #eff6ff;
  border-radius: 12px;
  box-shadow: 0 8px 24px rgba(37, 99, 235, 0.2);
}

顺便提一句:el.style.cssText = '...' 也能一次性写入多条样式,但它会覆盖掉原有的所有内联样式,使用时要小心。

6.4 缓存布局属性的读取结果

这是最容易犯、也最容易改的一个错误。同一个尺寸在一个函数里读了三遍,就读了三次——如果中间有写操作,其中两次可能是强制同步布局。

// ❌ 读了三次,中间还有写操作
function layout() {
  if (box.offsetWidth > 600) {
    box.style.width = '600px';
    if (box.offsetWidth > 500) { // 又强制布局一次
      box.classList.add('compact');
      console.log(box.offsetHeight); // 再强制布局一次
    }
  }
}

// ✅ 读一次,用变量传递
function layout() {
  const w = box.offsetWidth; // 唯一的读
  if (w > 600) {
    box.style.width = '600px'; // 之后的写
    box.classList.add('compact');
  }
}

6.5 用 contain 和 content-visibility 限制影响范围

前面说过,布局是一个整体计算。CSS Containment 规范提供了一组属性,让你主动告诉浏览器“这个子树是独立的”,从而把布局、绘制、样式计算的影响范围收窄。

属性 告诉浏览器什么 适用场景
contain: layout 外部不会影响内部布局,内部也不会影响外部 卡片列表、独立小组件
contain: paint 超出边界的部分不会画出来 固定尺寸的容器
contain: size 子元素大小不影响自身尺寸(需自己设尺寸) 尺寸固定的网格单元
contain: strict 等于 size layout paint style 完全独立的组件
content-visibility: auto 不在视口内时,跳过整棵子树的渲染 长页面、长列表
/* 卡片列表:每张卡片都是一个独立的布局孤岛 */
.card {
  contain: layout paint;
}

/* 长页面的分节:不可见时完全不渲染 */
.article-section {
  content-visibility: auto;
  /* 关键:提供一个占位高度,避免滚动条跳动 */
  contain-intrinsic-size: 0 600px;
}

/* 长列表的每一项 */
.list-item {
  content-visibility: auto;
  contain-intrinsic-size: 0 64px;
}

content-visibility: auto 是近年来性价比最高的性能属性之一。在长文档页面上,它可以让初始渲染时间下降 30%~50%,因为屏幕外的内容根本不会被布局和绘制。但它必须搭配 contain-intrinsic-size 使用——否则浏览器不知道屏幕外的元素有多大,滚动条会随着内容进入视口而不断跳动,体验反而更差。

6.6 虚拟列表:只渲染看得见的部分

如果列表有一万条数据,即使每条的 DOM 再简单,光是节点数量就足以让布局变慢。虚拟列表的思路是:只渲染视口内可见的十几条,加上上下各几条缓冲,其余用空白占位。

const ITEM_HEIGHT = 56;
const BUFFER = 5;

function render(scrollTop, viewportHeight, data) {
  // 计算可见区间
  const start = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER);
  const visibleCount = Math.ceil(viewportHeight / ITEM_HEIGHT) + BUFFER * 2;
  const end = Math.min(data.length, start + visibleCount);

  // 用撑高的占位容器保持滚动条正确
  spacer.style.height = data.length * ITEM_HEIGHT + 'px';

  // 只更新可视区域,用 transform 平移而不是改 top
  content.style.transform = `translateY(${start * ITEM_HEIGHT}px)`;

  const frag = document.createDocumentFragment();
  for (let i = start; i < end; i++) {
    frag.appendChild(buildRow(data[i]));
  }
  content.replaceChildren(frag);
}

虚拟列表把 DOM 节点数量从 O(n) 降到 O(1),是从根本上解决问题。它的代价是实现复杂度:需要处理不定高、需要处理滚动时的白屏、需要处理键盘导航和可访问性。如果项目里已经有成熟的库(比如 TanStack Virtual、react-window),直接用就好。

6.7 滚动与拖拽:用 passive 和 rAF 节流

scroll、touchmove、wheel 这些事件的触发频率可以达到每秒上百次。如果监听器里做了任何布局读取,帧率会瞬间崩塌。

// ❌ 每次滚动都同步读取 + 写入
window.addEventListener('scroll', () => {
  const y = window.scrollY; // 读
  header.style.top = -y + 'px'; // 写 → 回流
});

// ✅ 用 rAF 合并,一帧最多处理一次
let ticking = false;

window.addEventListener('scroll', () => {
  if (ticking) return;
  ticking = true;
  requestAnimationFrame(() => {
    const y = window.scrollY;
    header.style.transform = `translateY(${-y}px)`;
    ticking = false;
  });
}, { passive: true });

// ✅ 更好的方案:用 CSS 的 position: sticky,一行 JS 都不用写
// .header { position: sticky; top: 0; }

注意 { passive: true }。它告诉浏览器“这个监听器不会调用 preventDefault()”,浏览器因此可以在滚动线程上立即开始滚动,不必等待主线程的 JavaScript 执行完。对于 touchstart / touchmove / wheel,这个标记能显著改善移动端的滚动跟手感。

而 position: sticky 更进一步——它是纯 CSS 的实现,滚动时完全不需要 JS 参与,也就完全不会产生回流。

6.8 图片与字体:避免布局偏移

图片和字体是“延迟到达的资源”,它们加载完成的瞬间会改变布局,导致页面跳一下。这个现象有个专门的指标叫 CLS(Cumulative Layout Shift,累积布局偏移)。

<!-- ❌ 没有尺寸,加载完就撑开 -->
<img src="photo.jpg" alt="照片">

<!-- ✅ 预留宽高比,浏览器提前占位 -->
<img src="photo.jpg"
     width="800" height="600"
     alt="照片"
     loading="lazy"
     decoding="async">
/* ✅ CSS 宽高比也可以 */
.thumb {
  aspect-ratio: 4 / 3;
  width: 100%;
  background: #e2e8f0;
}

/* ✅ 字体:用 font-display 控制阻塞行为 */
@font-face {
  font-family: 'MyFont';
  src: url('font.woff2') format('woff2');
  font-display: swap;
  /* 先用系统字体渲染,加载完再替换 */
  /* 代价:会有一瞬间的字形跳变 */
}

如果想做得更极致,可以用 size-adjust 和 ascent-override 等属性,让回退字体和自定义字体的字宽、行高尽量接近,从而把字体替换时的跳动降到最低。

6.9 优先用 CSS 而不是 JS

很多原本需要 JS 反复读写布局的效果,其实用纯 CSS 就能实现,而且性能好得多:

JS 实现 CSS 替代 收益
监听 scroll 改 header 位置 position: sticky 完全不触发回流
监听 scroll 显示/隐藏元素 animation-timeline: view() 滚动线程驱动,不占用主线程
监听 resize 改字号 clamp() / vw 单位 零 JS,零抖动
JS 计算元素高度做折叠 grid-template-rows: 0fr → 1fr 无需读取布局,纯 CSS 过渡
JS 判断悬停改样式 :hover / :focus-visible 样式层面直接命中

特别是折叠动画:以前大家要么写死高度,要么用 max-height 加一个大值来“糊弄”。现在用 grid 的 0fr → 1fr 过渡可以做到完全精确:

/* 精确的自动高度折叠动画,无需 JS */
.collapse {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.3s ease;
}
.collapse.open {
  grid-template-rows: 1fr;
}
.collapse > div {
  overflow: hidden;
  min-height: 0; /* 关键:grid 子项默认 min-height: auto */
}

6.10 用 IntersectionObserver 代替滚动计算

“元素进入视口时做点什么”是极常见的需求。传统做法是在 scroll 里读 getBoundingClientRect(),每滚一像素就算一次。而 IntersectionObserver 把这件事交给了浏览器内部实现,主线程完全不用参与。

// ❌ 每滚动一次就要读一次布局
window.addEventListener('scroll', () => {
  document.querySelectorAll('.lazy').forEach((el) => {
    const rect = el.getBoundingClientRect(); // 强制布局
    if (rect.top < window.innerHeight) loadImage(el);
  });
});

// ✅ 交给浏览器,零主线程开销
const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (!entry.isIntersecting) return;
    loadImage(entry.target);
    observer.unobserve(entry.target); // 加载完就注销
  });
}, { rootMargin: '200px' }); // 提前 200px 开始加载

document.querySelectorAll('.lazy').forEach((el) => observer.observe(el));

6.11 减少选择器复杂度与避免强制同步布局的 CSS

样式计算阶段也并非免费。一个典型的反例是这样的选择器:

/* ❌ 从右向左匹配,代价高 */
.container > ul.list li.item > a.link[data-type="primary"] span {
  color: blue;
}

/* ✅ 加一个类,直接命中 */
.list-link-primary {
  color: blue;
}

另外要避免会引发“级联布局”的写法,比如在 flex 或 grid 容器里使用 width: auto 的表格、嵌套的 min-content 约束等。这类结构在内容变化时可能引发多轮布局计算,成本远高于普通布局。

6.12 用 requestAnimationFrame 组织动画循环

不要用 setInterval 或 setTimeout 驱动动画。requestAnimationFrame 会在浏览器准备渲染之前调用你的回调,时机刚好,而且页面不可见时会自动暂停,节省电量。

// ❌ setInterval 与显示器刷新率不同步,容易撕裂和抖动
setInterval(() => {
  x += 2;
  box.style.transform = `translateX(${x}px)`;
}, 16);

// ✅ rAF:与刷新率同步,页面隐藏时自动暂停
let x = 0;
let last = 0;

function tick(now) {
  // 用时间差计算位移,保证不同刷新率下速度一致
  const delta = now - last;
  last = now;
  x += 0.12 * delta;
  box.style.transform = `translateX(${x}px)`;
  requestAnimationFrame(tick);
}

requestAnimationFrame((now) => {
  last = now;
  requestAnimationFrame(tick);
});

注意 0.12 * delta 这个写法。如果每帧固定加 2px,在 120Hz 的屏幕上动画速度会比 60Hz 快一倍。用时间差计算位移,才能保证在任何刷新率下速度一致——这是专业动画实现的基本功。

七、深入理解合成层

前面反复提到“合成阶段”和“独立图层”,但一直没有展开。这一节把它讲清楚,因为它是理解现代前端动画性能的关键。

图层是什么

浏览器在绘制阶段,并不是把整页画到一张画布上,而是把它拆成若干个图层(Layer),每个图层独立绘制,最后由合成器按顺序叠起来。这么做的好处是:如果只有一个图层变了,其他图层可以原样复用,不需要重画。

图层 A(背景)
图层 B(卡片)
图层 C(动画元素)
合成 每帧把各图层按 z 序叠加,GPU 并行完成
优势 动画元素独立成层后,移动它不影响其他层
代价 每个图层占用显存,图层过多会拖慢合成
判断 只有真正频繁动画的元素才值得提升

什么情况下会创建新图层

  • 3D 变换:transform: translate3d()、translateZ(0)、rotate3d()
  • will-change: transform / opacity(显式声明)
  • video、canvas、iframe 等替换元素
  • position: fixed(部分浏览器)
  • opacity 小于 1 且有动画(部分情况下)
  • filter、backdrop-filter、mix-blend-mode

注意最后几项:它们都是隐式创建图层的。这意味着你无意中给一个元素加了 filter: blur(0),可能就多出了一个图层,而这个图层会一直占着显存。所以在做性能审计时,一定要打开 DevTools 的 Layers 面板看看实际有多少层。

Layers 面板怎么用

// 打开方式:DevTools → 更多工具 → Layers

// 你会看到:
// 1. 三维视图,每个方块是一个合成层
// 2. 右侧列出该层的"提升原因"
// 3. 底部显示内存占用(Compositing Reasons / Memory)

// 常见的"提升原因"文字:
// "Has a will-change hint" → 你显式声明的
// "Has a 3D transform" → translateZ / translate3d
// "Overlaps other composited content" → 与其他层重叠
// "Has a filter" → filter 属性导致

一个健康页面的图层数量通常在 10~30 之间。如果一个简单页面上出现了上百个图层,那基本可以确定 will-change 或者 translateZ(0) 被滥用了。

提升图层的正确姿势

推荐
  • 只给真正高频动画的元素提升
  • 用 will-change 而不是 translateZ(0),语义更清晰
  • 动画结束后移除 will-change
  • 用 Layers 面板定期检查图层数量
不推荐
  • 全局 * { will-change: transform; }
  • 给长列表的每一行都加 translateZ(0)
  • 在 :hover 之外的地方长期保留 will-change
  • 用 transform: translateZ(0) 来"修复"模糊文字(副作用大)

八、怎么测量:从感觉到数据

性能优化最怕的就是“凭感觉”。你改了一行代码,页面好像快了——其实可能只是心理作用。下面这套方法可以帮你把“感觉”变成“数据”。

8.1 Performance 面板:最权威的工具

Chrome DevTools 的 Performance 面板能记录一整段时间内的所有活动,包括:

  • Main 轨道:主线程上执行的所有任务,能看到每个函数的耗时。
  • Frames:每一帧的截图缩略图,红色边框表示掉帧。
  • Layout:所有布局事件,紫色块。数量多或者单个块很大,都是警告信号。
  • Paint:绿色块,表示绘制活动。
  • Composite Layers:合成活动,通常很小。

使用的关键技巧是:录制一个典型操作,然后在 Layout 轨道上找“特别高”或者“特别密集”的紫色块。悬停在上面,DevTools 会显示这次布局的耗时和触发它的代码位置。如果看到一串密集的小紫块,那基本就是布局抖动了。

8.2 Rendering 面板:实时可视化

在 DevTools 里按 Ctrl+Shift+P(Mac 上是 Cmd+Shift+P),输入 “Rendering” 打开渲染面板。几个非常实用的选项:

选项 作用
Paint flashing 把发生重绘的区域用绿色高亮,一眼看出哪些地方在不停重画
Layout Shift Regions 蓝色高亮发生布局偏移的区域,用于排查 CLS 问题
Layer borders 用橙色边框标出所有合成层,检查图层数量
FPS meter 实时显示帧率,包含 GPU 内存占用
Scrolling performance issues 自动标记可能拖慢滚动的元素

其中 Paint flashing 是最直观的一个。开启后,你会看到页面上不停地有绿色方块闪烁——那些就是正在重绘的区域。如果一片区域在你做某个操作时疯狂闪烁,那它就是优化的目标。

8.3 用代码测量真实数据

DevTools 适合手动排查,但如果你想做自动化监控,就需要用代码采集。以下是几个最有用的 API:

// 1. 测量一段同步代码的耗时
const t0 = performance.now();
doSomething();
const t1 = performance.now();
console.log(`耗时 ${(t1 - t0).toFixed(2)}ms`);

// 2. 用 mark / measure 打点,会出现在 Performance 面板上
performance.mark('render-start');
renderList();
performance.mark('render-end');
performance.measure('renderList', 'render-start', 'render-end');

// 3. 监听长任务(超过 50ms 的任务)
new PerformanceObserver((list) => {
  list.getEntries().forEach((entry) => {
    console.warn(`长任务 ${entry.duration.toFixed(0)}ms`, entry);
  });
}).observe({ entryTypes: ['longtask'] });

// 4. 监听布局偏移(CLS)
let cls = 0;
new PerformanceObserver((list) => {
  list.getEntries().forEach((entry) => {
    // 只统计没有用户交互的偏移
    if (!entry.hadRecentInput) cls += entry.value;
  });
}).observe({ type: 'layout-shift', buffered: true });

// 5. 测量绘制与布局(需要 flag,兼容性一般)
new PerformanceObserver((list) => {
  list.getEntries().forEach((entry) => {
    console.log(entry.name, entry.duration, entry.startTime);
  });
}).observe({ entryTypes: ['layout', 'paint'] });

8.4 一套可落地的性能预算

光有工具还不够,你需要给团队定一个“及格线”。下面这套预算适用于大多数中后台系统:

首屏 LCP
< 2.5s
交互 INP
< 200ms
布局偏移 CLS
< 0.1
单帧布局耗时
< 4ms
长任务占比
< 15%
合成层数量
< 40

把这些指标接到 CI 里,每次构建自动跑一遍 Lighthouse,超标就报警。这样性能才不会在几个月的迭代里悄悄退化。

九、六个高频陷阱

陷阱一:以为改了 class 就一定比改 style 便宜

大多数情况下是的,但如果这个 class 的定义里包含大量选择器,或者它匹配的规则需要浏览器重新匹配整棵子树,那也未必更快。真正的判断标准是:这次改动影响了多少个元素的最终样式。改一个 class 让一个元素换颜色,和改一个 class 让 500 个后代元素都变样,成本天差地别。

陷阱二:在循环里读布局属性

// ❌ 每一次迭代都强制一次布局
items.forEach((el) => {
  if (el.getBoundingClientRect().top < 0) {
    el.classList.add('sticky');
  }
});

// ✅ 先收集,再处理
const tops = items.map((el) => el.getBoundingClientRect().top);
items.forEach((el, i) => {
  if (tops[i] < 0) el.classList.add('sticky');
});

陷阱三:以为 visibility: hidden 比 display: none 快很多

确实,visibility: hidden 只触发重绘(元素还在布局树里,只是不画),display: none 会触发回流(元素从布局树里移除)。但两者的差距没有想象中那么大,而且 visibility 保留占位,往往不是你想要的视觉效果。如果一个元素长期不需要显示,用 display: none 或者干脆从 DOM 里移除都是可以接受的,不要为了“省一次回流”而牺牲语义。

陷阱四:给所有元素加 will-change

这是最常见的“优化反而变慢”的案例。每增加一个合成层,浏览器就需要额外的显存来存储它,合成阶段也要多处理一个图层。当图层数量从 20 涨到 200,合成本身的耗时可能就超过了原来节省下来的布局时间。

/* ❌ 长列表每一行都提升为独立图层 */
.row {
  will-change: transform;
}

/* ✅ 只给需要悬停动画的那一个 */
.row:hover {
  will-change: transform;
  transform: translateX(4px);
}

陷阱五:在动画里读取布局

这是最致命的一种。在 requestAnimationFrame 的回调里读 offsetWidth,会让每一帧都被迫同步布局,动画帧率直接腰斩。

// ❌ 动画循环里读布局
function tick() {
  const w = box.offsetWidth; // 每帧强制布局
  box.style.transform = `translateX(${w * progress}px)`;
  requestAnimationFrame(tick);
}

// ✅ 尺寸只在动画开始时读一次
const w = box.offsetWidth; // 一次性读取
function tick() {
  box.style.transform = `translateX(${w * progress}px)`;
  requestAnimationFrame(tick);
}

陷阱六:只优化第一次渲染,忽略了持续交互

很多团队把性能优化的全部精力放在首屏加载上——懒加载、代码分割、SSR、图片压缩。这些当然重要,但用户真正每天感受到的,是使用过程中的流畅度:滚动列表卡不卡、打开抽屉顺不顺、拖拽跟不跟手。首屏从 3 秒优化到 1.5 秒是一次性的收益,而消除滚动时的布局抖动,是每一次使用都在受益。

所以在分配优化精力时,一个合理的比例是:首屏占四成,持续交互占六成。

十、实战重构:一个滚动监听组件的优化过程

下面是一个真实项目里常见的“吸顶导航 + 滚动进度”组件。它要监听滚动、计算进度、切换吸顶状态,还要处理窗口尺寸变化。点击按钮对比优化前后的实现。

class StickyHeader {
  constructor(el) {
    this.el = el;
    this.sections = document.querySelectorAll('.section');
    this.links = document.querySelectorAll('.nav a');

    // 滚动监听:没有 passive,没有节流
    window.addEventListener('scroll', this.onScroll.bind(this));
    window.addEventListener('resize', this.onResize.bind(this));
  }

  onScroll() {
    // 每次滚动都读一次布局
    const scrollY = window.scrollY;
    const docHeight = document.body.scrollHeight;
    const winHeight = window.innerHeight;

    // 写:直接改 top,触发回流
    this.el.style.top = Math.max(0, 80 - scrollY) + 'px';

    // 进度条:改 width,又是一次回流
    const ratio = scrollY / (docHeight - winHeight);
    document.querySelector('.progress').style.width = (ratio * 100) + '%';

    // 高亮当前章节:又一次读写交替
    this.sections.forEach((section) => {
      const rect = section.getBoundingClientRect(); // 读
      if (rect.top <= 100 && rect.bottom > 100) {
        section.classList.add('current'); // 写
      } else {
        section.classList.remove('current');
      }
    });
  }

  onResize() {
    // 每次 resize 都重新查询 DOM
    this.sections = document.querySelectorAll('.section');
  }
}

// 问题清单:
// 1. scroll 没有 passive,阻塞滚动线程
// 2. 没有节流,每秒可能执行上百次
// 3. 每次滚动都读 scrollHeight(强制布局)
// 4. 改 top 和 width,逐帧触发回流
// 5. forEach 里读写交替,章节越多越慢
// 6. 每次滚动都调用 querySelector
// 7. resize 时重新查询 DOM,但不清理旧监听

重构带来的收益

零回流 滚动过程中只改 transform,全部走合成阶段
零强制布局 读取集中在 measure(),滚动路径上没有任何布局读取
调用频率 从每秒上百次降到最多每秒 60 次,且大部分时候是空转
DOM 操作 章节高亮从每次滚动都改,降到只在切换时改一次
性能实测 滚动时的 Layout 事件从数百次降到个位数

这个重构用到的核心思路,其实只有两条:把读取和写入彻底分开,以及用 transform 代替会触发回流的属性。剩下的都是围绕这两条的工程化包装。

十一、感知与体验:性能的最终裁判

技术指标是手段,用户体验才是目的。有些时候,一个“技术上更快”的方案,用户反而觉得更慢。所以在收尾之前,有必要谈谈感知性能。

进度反馈比真实速度更重要

一个需要 800ms 的请求,如果立刻显示骨架屏,用户会觉得“很快”;如果什么都不显示,800ms 后突然出现内容,用户会觉得“卡了一下”。这就是感知性能的核心:让用户知道系统正在工作。

骨架屏 提前占位,避免内容出现时的布局跳动
乐观更新 先改 UI,再发请求,失败时回滚
过渡动画 让状态切换看起来是连续的,而不是突变的
错峰入场 列表项依次淡入,比一次性全部出现更有节奏感

下面是错峰入场的演示。点击按钮,观察这些小标签依次出现的节奏——它们总耗时是一样的,但分批出现会让整个过程显得更快、更有质感:

性能预算 渲染管线 布局抖动 合成层 被动监听 虚拟列表

实现方式只是给每个元素加一个递增的 animation-delay,但视觉效果上的提升是显著的。要注意的是,错峰动画的每一项都应该是 transform 或 opacity,否则几十个元素同时做布局动画,反而会带来卡顿。

/* 用 CSS 变量控制延迟,JS 只负责加类 */
.chip {
  opacity: 0;
  transform: translateY(14px);
}

.chip.play {
  animation: staggerIn 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
  animation-delay: calc(var(--i) * 70ms);
}

// JS 只需要设一次变量
chips.forEach((chip, i) => {
  chip.style.setProperty('--i', i);
  chip.classList.add('play');
});

// 更好的做法:直接在内联样式里写死延迟,
// 避免读取 index 带来的额外工作

什么时候“慢一点”反而更好

有几个场景,刻意放慢反而是正确的选择:

  • 加载态闪烁:如果请求经常在 50ms 内完成,一闪而过的 loading 反而让人眼晕。可以延迟 150ms 再显示 loading。
  • 搜索结果实时更新:用户打字时不必每个字符都触发一次渲染,等 200ms 静默期更稳。
  • 动画时长:小于 100ms 的动画几乎感知不到,大于 400ms 又会显得拖沓,200~300ms 通常最舒服。
  • 滚动加载:提前加载太多反而浪费带宽,不如在距离底部 300px 时再触发。

十二、速查表与落地清单

最后把全文浓缩成一份可以贴在工位上的速查表。左边是“你遇到什么问题”,右边是“先看哪一条”。

你遇到的问题 先看这里 关键动作
动画一卡一卡的 第六章 6.1 换成 transform / opacity 驱动
列表滚动时掉帧 第六章 6.6 / 6.7 虚拟列表 + passive 监听
批量插入节点后页面卡住 第六章 6.2 DocumentFragment 或 replaceChildren
一次操作后要等很久才有反应 第二章 排查长任务,考虑切片或 Worker
页面加载完突然跳一下 第六章 6.8 给图片设宽高,给动态内容留占位
不知道优化有没有效果 第八章 用 Performance 面板录制对比
想找出哪里在疯狂重绘 第八章 8.2 打开 Rendering 面板的 Paint flashing
图层数量失控 第七章 打开 Layers 面板,清掉多余的 will-change
滚动事件处理函数很慢 第六章 6.7 / 6.10 rAF 节流 + IntersectionObserver
长页面初始渲染慢 第六章 6.5 content-visibility: auto
循环里读 DOM 属性 第四章 先读后写,或读写分区
改动样式后整页都在重算 第六章 6.5 用 contain 限制影响范围

落地清单

  • 先测量,再优化。没有 Performance 面板数据的优化,都是猜测。
  • 动画只用 transform 和 opacity。这一条能解决 80% 的动画卡顿。
  • 读写分离。一个函数里要么只读布局,要么只写样式,不要交替。
  • 缓存布局读取结果。同一个尺寸在一个函数里读两次,就一定有问题。
  • 批量操作 DOM。用 DocumentFragment,或者先脱离文档流。
  • 滚动监听必加 passive。这是零成本的性能提升。
  • 用 rAF 合并高频事件。一帧最多处理一次。
  • will-change 要节制。用完就移除,不要留在 CSS 里常驻。
  • 长列表上虚拟滚动或 content-visibility。不要让 DOM 节点数无限增长。
  • 图片和广告位要预留尺寸。这是控制 CLS 最有效的办法。
  • 能交给 CSS 的就别用 JS。sticky、clamp、grid 折叠动画都比 JS 实现快。
  • 把性能预算写进 CI。否则优化成果会在几个月里慢慢退化。
// 一份可以常备的"性能工具箱"骨架

// 1. rAF 节流 —— 高频事件的标配
function rafThrottle(fn) {
  let ticking = false;
  return function (...args) {
    if (ticking) return;
    ticking = true;
    requestAnimationFrame(() => {
      ticking = false;
      fn.apply(this, args);
    });
  };
}

// 2. 读写分区 —— 杜绝强制同步布局
const dom = {
  reads: [],
  writes: [],
  read(fn) { this.reads.push(fn); },
  write(fn) { this.writes.push(fn); },
  flush() {
    const r = this.reads.splice(0);
    const w = this.writes.splice(0);
    r.forEach((fn) => fn());
    w.forEach((fn) => fn());
  },
};

// 3. 批量写入 —— 一帧一次
function batch(updates, onEach) {
  const frag = document.createDocumentFragment();
  updates.forEach((item) => frag.appendChild(onEach(item)));
  return frag;
}

// 4. 动画 —— 只碰 transform 和 opacity
const animate = (el, from, to, dur) => {
  const t0 = performance.now();
  function step(now) {
    const p = Math.min(1, (now - t0) / dur);
    el.style.transform = `translateX(${from + (to - from) * p}px)`;
    if (p < 1) requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
};

// 5. 惰性测量 —— 只在需要时读取
function lazyRect(el) {
  let cached = null;
  return () => cached ??= el.getBoundingClientRect();
}

// 6. 性能监控 —— 长任务预警
new PerformanceObserver((list) => {
  list.getEntries().forEach((e) => {
    if (e.duration > 100) {
      console.warn(`长任务:${e.duration.toFixed(0)}ms`);
    }
  });
}).observe({ entryTypes: ['longtask'] });

性能优化这件事,最反直觉的地方在于:它的大部分收益,来自于少数几个习惯,而不是少数几个技巧。

你不需要记住所有属性的代价表,只需要养成一个习惯:每次要改样式或者读尺寸的时候,先停下来半秒钟,问自己一句——这一步会触发回流吗?如果会,有没有办法绕开它?

当你把这个问题问了上百遍之后,它就会变成肌肉记忆。到那时,你写出来的代码天然就是流畅的,而不是等出了问题再回来打补丁。

最后引用一句在性能圈流传很广的话:最快的代码,是从来没有执行的代码;最快的布局,是从来没有发生的布局。