“性能优化”这四个字,几乎每个前端都说过,但真正能把它落到实处的并不多。很多团队的做法是:页面卡了,加个 will-change;动画抖了,换成 transform;列表长了,上虚拟滚动——这些都没错,但它们都是症状级别的止痛药。真正的性能问题往往出在一个更底层的地方:你没有搞清楚浏览器在一帧 16.7 毫秒里到底做了什么。而“重绘”和“回流”这两个词,正是打开这扇门的钥匙。本篇 60 分钟长文,从浏览器渲染流水线讲起,把重绘与回流的触发条件、代价差异、测量方法逐一拆开,再用七个交互实验台让你亲手感受 left 与 transform 的差距、强制同步布局有多贵、合成层到底在做什么,最后给出十二条可落地的优化手段、一份属性代价速查表,以及一场从 200 行降到 30 行的完整实战重构。读完之后,你未必会记住所有 API,但你一定能建立一种新的直觉:每一次改样式、每一次读尺寸,都要先问一句——它会让浏览器重新算一遍布局吗?
一、为什么“重绘和回流”值得单独讲
在讨论技术细节之前,先建立一个尺度感。用户对界面流畅度的感知,大致遵循这样几条经验线:
16.7 毫秒听起来很宽松——毕竟是千分之十六秒。但问题在于,这一帧里浏览器要做的事情远比想象中多:执行你的 JavaScript、计算样式、重新布局、把元素画成像素、把多个图层合成为最终画面,还要留出时间给浏览器自身的内务、给其他标签页、给输入事件。
而在这些环节里,布局(Layout,也就是常说的“回流”)通常是单次代价最高的一个。原因很简单:它是一棵树的遍历。你的 DOM 有多深、节点有多少、有没有表格、有没有绝对定位、有没有文字换行,都会影响这一趟遍历的成本。更糟的是,一次“读尺寸”操作就可能强迫浏览器立刻中断当前工作、把布局算完——这就是所谓的“强制同步布局”,是前端性能问题里最隐蔽、也最致命的一类。
性能问题的三种典型表现
| 现象 | 用户感受 | 底层原因 |
|---|---|---|
| 滚动时页面发抖、掉帧 | “这网站好卡” | 滚动中触发大量回流,帧预算被击穿 |
| 点击按钮后要等一秒才有反应 | “是不是没点上?” | 主线程被长任务占满,输入事件排在后面 |
| 动画一卡一卡的,像幻灯片 | “这个动画做得真差” | 动画属性触发了逐帧回流,没有走合成 |
| 页面加载完成后突然“跳一下” | “我刚才点到哪了?” | 图片或字体撑开布局,引发累积布局偏移 |
| 页面开着开着越来越卡 | “得刷新一下了” | 监听器泄漏、DOM 节点只增不减、内存上涨 |
这五类现象里,前四类都直接或间接与重绘、回流有关。所以把它们单独拿出来讲,并不是学院派的理论洁癖,而是因为它处在绝大多数前端卡顿问题的因果链上游。
一个先建立的直觉
在继续往下之前,请先记住一句话,它会贯穿全文:
性能优化的很大一部分工作,本质上就是把昂贵的修改,想办法变成便宜的修改。这句话听起来朴素,但它解释了本文后面几乎所有技巧的动机。
二、一帧之内:浏览器渲染流水线
要理解重绘和回流,必须先知道它们在流水线的哪一环。现代浏览器把“把 HTML 变成屏幕上的像素”这件事拆成了几个阶段,点击下面的卡片可以展开每个阶段的具体职责:
这四步的关系是递进的,而不是并列的:
- 如果只改
opacity、transform:只走 ④合成,最便宜。 - 如果改
color、background-color、box-shadow:走 ③绘制 → ④合成,这就是重绘。 - 如果改
width、height、margin、font-size、增删节点:走 ②布局 → ③绘制 → ④合成,这就是回流。 - 如果改的是会引发样式重算的
class:还要多走 ①样式计算。
帧预算到底有多紧张
把 16.7 毫秒拆开看,你会发现留给“布局 + 绘制”的空间其实非常有限:
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 即可。
三、重绘与回流:一字之差的成本鸿沟
现在可以把这两个概念精确定义一下了。
// 元素的几何属性发生变化,浏览器必须
// 重新计算所有受影响元素的尺寸和位置
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
// 代价:布局 → 绘制 → 合成
// 元素的几何属性没变,只是外观变了
// 浏览器只需重新画一遍像素
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 的回调正好在浏览器准备渲染之前执行,天然就是一个“批量写入窗口”。
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.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/lefttransform: scale()代替width/heightopacity代替visibility的淡入淡出filter: blur()通常也能走合成
left / top / right / bottomwidth / height / padding / marginfont-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: 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 会让浏览器反复重算布局。正确做法有两种:
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 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">
.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 过渡可以做到完全精确:
.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(() => {
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),每个图层独立绘制,最后由合成器按顺序叠起来。这么做的好处是:如果只有一个图层变了,其他图层可以原样复用,不需要重画。
什么情况下会创建新图层
- 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 面板怎么用
// 你会看到:
// 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:
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 一套可落地的性能预算
光有工具还不够,你需要给团队定一个“及格线”。下面这套预算适用于大多数中后台系统:
把这些指标接到 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 秒是一次性的收益,而消除滚动时的布局抖动,是每一次使用都在受益。
所以在分配优化精力时,一个合理的比例是:首屏占四成,持续交互占六成。
十、实战重构:一个滚动监听组件的优化过程
下面是一个真实项目里常见的“吸顶导航 + 滚动进度”组件。它要监听滚动、计算进度、切换吸顶状态,还要处理窗口尺寸变化。点击按钮对比优化前后的实现。
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 代替会触发回流的属性。剩下的都是围绕这两条的工程化包装。
十一、感知与体验:性能的最终裁判
技术指标是手段,用户体验才是目的。有些时候,一个“技术上更快”的方案,用户反而觉得更慢。所以在收尾之前,有必要谈谈感知性能。
进度反馈比真实速度更重要
一个需要 800ms 的请求,如果立刻显示骨架屏,用户会觉得“很快”;如果什么都不显示,800ms 后突然出现内容,用户会觉得“卡了一下”。这就是感知性能的核心:让用户知道系统正在工作。
下面是错峰入场的演示。点击按钮,观察这些小标签依次出现的节奏——它们总耗时是一样的,但分批出现会让整个过程显得更快、更有质感:
实现方式只是给每个元素加一个递增的 animation-delay,但视觉效果上的提升是显著的。要注意的是,错峰动画的每一项都应该是 transform 或 opacity,否则几十个元素同时做布局动画,反而会带来卡顿。
.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'] });
性能优化这件事,最反直觉的地方在于:它的大部分收益,来自于少数几个习惯,而不是少数几个技巧。
你不需要记住所有属性的代价表,只需要养成一个习惯:每次要改样式或者读尺寸的时候,先停下来半秒钟,问自己一句——这一步会触发回流吗?如果会,有没有办法绕开它?
当你把这个问题问了上百遍之后,它就会变成肌肉记忆。到那时,你写出来的代码天然就是流畅的,而不是等出了问题再回来打补丁。
最后引用一句在性能圈流传很广的话:最快的代码,是从来没有执行的代码;最快的布局,是从来没有发生的布局。