GPU 合成与 60fps
"卡"这个字,用户说得含糊,但它在技术上有一个精确到小数点的定义:你只有 16.7 毫秒来准备好每一帧,超时就丢帧,丢帧就是卡。这一节把这笔时间账算清楚,讲明白 CPU 和 GPU 各干什么、图层提升到底提升了什么、为什么同样的动画换个写法就从卡成幻灯片变成丝滑,以及怎么用浏览器自带的工具当场抓到凶手。
你手里有一本翻页动画本,每秒要翻过 60 页,才能让小人跑得自然流畅。
问题是:每一页都得有人现场画出来,而且必须在你翻到之前画好。
也就是说画师只有 1/60 秒 ≈ 16.7 毫秒 画一页。
如果某一页他画了 30 毫秒——你翻过去时那页还是白的,只能把上一页多举一会儿。小人就"顿"了一下。
如果他每页都画 30 毫秒,实际帧率就掉到 30fps,小人一路都在抽搐。
聪明的画师会怎么做?把不动的背景单独画在一张纸上,只在透明片上画会动的小人——工作量瞬间降到十分之一。
为什么偏偏是 60fps
60 这个数字不是设计师拍脑袋定的,它来自硬件:绝大多数显示器的刷新率是每秒 60 次,即每 16.7 毫秒把屏幕内容整体更新一遍。屏幕就像那本翻页本,它按固定节奏翻,不会等你。
再往上追一层,是人眼的特性。大约 10~12fps 时人开始感知到"这是连续动作"而不是一张张图;24fps 是电影标准,够看但快速运动时会有拖影;到 60fps,视觉上基本感觉不到离散感,手指拖动界面时的跟手感也才成立。所以 60fps 是"硬件能给、人眼够用"的交汇点。
这条时间预算算下来更让人紧张:16.7 毫秒里,浏览器自己的内务(合成、上屏、和系统交互)要占掉几毫秒,真正留给你的 JS 逻辑、样式计算、布局和绘制的时间,大约只有 10 毫秒。而现在的手机上,一次全页布局就可能吃掉 5 毫秒以上。
| 帧率 | 每帧预算 | 主观感受 |
|---|---|---|
| 120 fps | 8.3 ms | 高刷设备,拖动极其跟手 |
| 60 fps | 16.7 ms | 流畅,行业基线 |
| 30 fps | 33.3 ms | 明显不顺,滑动时能感到台阶感 |
| 24 fps | 41.7 ms | 电影可以,交互界面已经难受 |
| < 15 fps | > 66 ms | 用户会认为"这个应用坏了" |
还有一个容易忽略的点:帧率的稳定性比绝对值更重要。稳定的 30fps 比在 60 和 25 之间反复跳动的画面感觉更好,因为后者的节奏不可预测,大脑会持续感到别扭。所以优化目标不是"平均帧率高",而是"没有超时的长帧"。
CPU 与 GPU 的分工
要理解为什么某些动画快得离谱,必须先理解这两个处理器的性格差异。
- CPU · 少数全能选手核心数少(几个到几十个),但每个都极其聪明,擅长复杂的逻辑判断、分支跳转、串行推理。上一节讲的解析、布局、绘制指令生成,全是 CPU 的活。
- GPU · 海量专才核心数成千上万,每个都很"笨",只会做简单的数学运算,但能同时对海量数据做同一件事。它天生就是为"把这几百万个像素统一乘上一个矩阵"这类任务设计的。
要解一道复杂的证明题——找教授(CPU),一千个小学生也解不出来。
要算一万道"两位数乘法"——找一千个小学生(GPU),每人十道,一分钟搞定;教授一个人算得抓狂。
界面渲染刚好两种活都有:"这段文字该在哪折行、这个 flex 容器怎么分配空间" 是证明题,交给 CPU;"把这张 300 万像素的贴图整体缩放 1.2 倍再叠上去" 是一万道乘法题,交给 GPU。
性能优化的本质,就是尽量把活从教授桌上挪到小学生那边去。
具体的分工边界是这样的:CPU 负责跑 JavaScript、计算样式、执行布局、生成绘制指令、把纹理数据上传给 GPU;GPU 负责栅格化部分工作、以及最关键的——把所有图层按变换矩阵和透明度叠成最终画面。合成这件事对 GPU 来说轻如鸿毛,因为它就是干这个的。
图层提升:给会动的东西一张独立的纸
上一节提到过图层。这里把它和性能直接挂钩:一个元素被提升为独立图层后,它的移动、缩放、淡入淡出就不再需要重画任何内容,只需要 GPU 用新的矩阵重新叠一次。
怎么触发提升?有两种主流写法:
/* 写法一:声明意图(推荐) */
.card {
will-change: transform;
}
/* 写法二:老技巧,用 3D 变换"骗"出图层 */
.card {
transform: translateZ(0);
/* 或 transform: translate3d(0, 0, 0); */
}
will-change 是标准做法,它的语义是"我接下来要改这个属性,你提前准备"。translateZ(0) 是 will-change 出现之前的 hack——因为涉及 3D 变换的元素必须走 GPU,浏览器只好给它单独开一层。两者效果相近,但语义清晰的前者更值得用。
关键纪律:will-change 不能到处撒。它是一张"预约显存"的申请单,每张独立图层都要在显存里存一份完整位图。给列表的 200 个卡片全加上 will-change: transform,等于一次性申请几百 MB 显存,结果往往是整页更卡甚至标签页崩溃。正确姿势是只在真正要动的元素上加,动画结束后撤掉:
/* ✓ 只在交互即将发生时提升 */
.card:hover { will-change: transform; }
.card { transition: transform .25s ease; }
.card:hover { transform: translateY(-4px); }
/* ✓ JS 控制的动画,结束后主动撤销 */
el.style.willChange = 'transform';
el.addEventListener('transitionend', () => {
el.style.willChange = 'auto'; // 释放图层
}, { once: true });
为什么 transform + opacity 就是快
现在可以把答案说透了。对比两种写法在每一帧里发生的事:
| 做法 | 解析 | 布局 | 绘制 | 合成 | 每帧代价 |
|---|---|---|---|---|---|
left: 0 → 200px | — | ✔ CPU | ✔ CPU | ✔ GPU | 可能 5~15 ms |
margin-top 动画 | — | ✔ CPU | ✔ CPU | ✔ GPU | 同上,且影响兄弟元素 |
background-color 渐变 | — | — | ✔ CPU | ✔ GPU | 约 1~5 ms |
transform | — | — | — | ✔ GPU | < 1 ms |
opacity | — | — | — | ✔ GPU | < 1 ms |
道理其实很朴素:left 改的是元素在文档流里的真实位置,所以浏览器必须重新算所有相关元素的几何关系,然后重画,然后合成——整条流水线走一遍,而且大部分在 CPU 上。而 transform 改的只是这一层贴图在屏幕上的呈现方式,元素在文档流里根本没动,兄弟元素毫不知情。前者改的是"事实",后者改的是"看起来"——而画面这件事,看起来就够了。
opacity 同理:图层内容一个像素都没变,只是叠加时乘了一个系数。这正好是 GPU 最擅长的那类乘法题。
由此得出一个可以背下来的替换表:
- 位移动画别用
left/top/margin,用transform: translate(x, y)。 - 缩放动画别用
width/height,用transform: scale(n)。 - 显隐动画别用
display(它根本没法过渡),用opacity配合visibility或pointer-events。 - 旋转动画用
transform: rotate(),本来就只走合成,放心用。 - 列表重排动画用 FLIP 思路:先读取首末位置,用
transform把元素"假装"放回原处,再让它过渡到零——全程只走合成。
常见卡顿的四类原因
知道了预算和分工,卡顿的诊断就有了框架。绝大多数卡顿逃不出这四类。
1. 长任务阻塞主线程
JavaScript 和渲染共用一个主线程。一段跑了 200 毫秒的 JS(大数组循环、复杂 JSON 解析、同步的加密计算)执行期间,浏览器完全无法渲染任何东西,连点击都不响应,这叫"掉帧"甚至"假死"。任何超过 50 毫秒的任务都算长任务。解法:拆成小片用 requestIdleCallback / setTimeout 分批执行,或挪到 Web Worker 里去跑。
2. 频繁回流 / 布局抖动
在滚动或 resize 回调里读 getBoundingClientRect()、循环里读写交替、给动画用几何属性——都会让布局这一步在每帧反复执行。解法:读写分批(上一节讲过)、事件回调加节流、动画改用 transform。
3. 图层过多导致显存与合成压力
滥用 will-change 或 translateZ(0),让几百个元素各占一张位图。显存吃紧后,光是"把这么多层叠起来"就超预算了,低端机可能直接崩。解法:只给真正在动的元素提层,动画完就撤。
4. 大图解码与巨型绘制
一张 4000×3000 的原图,浏览器要先解码成未压缩位图(约 48 MB)才能显示,解码本身就是一次耗时的 CPU 工作。此外满屏的 box-shadow 大模糊、backdrop-filter 毛玻璃、复杂渐变也都是绘制阶段的重活。解法:图片按实际显示尺寸出图并用 srcset 提供多档、用 loading="lazy" 延后加载、给非首屏的重效果做降级。
还有几个容易忽略的隐形杀手:滚动事件里没加 passive: true(导致浏览器必须等你的回调执行完才敢滚动);用 setInterval 而不是 requestAnimationFrame 驱动动画(节奏和屏幕刷新不同步,必然抖动);以及在动画进行中触发了字体加载(字体一到就整页回流重排)。
用 DevTools Performance 面板抓凶手
猜是最低效的调试方式。浏览器自带的性能面板能把这 16.7 毫秒里发生的一切摊在你眼前。基本流程只有四步:
- 第一步 · 录制打开 DevTools → Performance,点圆形录制按钮,然后复现卡顿操作(滚动、点开抽屉、切换 tab),停止录制。录制时间控制在 3~5 秒,太长了不好看。
- 第二步 · 看 FPS 与红条最上方是帧率图,绿色柱越高越好;柱子上方出现红色横条的地方就是丢帧区间——直接把注意力放在那儿。
- 第三步 · 展开 Main 火焰图Main 轨道显示主线程在干什么。宽的色块 = 耗时长的任务,右上角带红色三角的是被标记的长任务。颜色有约定:黄色是 Scripting(JS),紫色是 Rendering(样式计算 + 布局),绿色是 Painting(绘制 + 合成)。哪种颜色占得宽,方向就明确了。
- 第四步 · 对症下药黄色过宽 → 优化 JS、拆分长任务、上 Worker。紫色过宽 → 找强制同步布局(面板会直接标 Forced reflow 警告并给出触发它的代码行)。绿色过宽 → 减少大面积重绘、检查阴影和滤镜、缩小图片。
几个配套的小工具也很值得知道:
- Rendering 面板在 DevTools 里按 Esc 打开抽屉 → Rendering。勾选 Paint flashing,凡是被重绘的区域会闪绿框——如果你只改了一个按钮却看到整屏闪绿,说明重绘范围失控了。勾选 Frame Rendering Stats 可以实时看帧率。
- Layers 面板3D 可视化当前所有合成图层,并显示每层占用的显存。怀疑图层爆炸时先看这里。
- CPU 降速模拟Performance 面板顶部可以把 CPU 调成 4x / 6x slowdown,模拟中低端手机。在你的开发机上流畅不代表用户流畅,这个开关能把问题提前暴露出来。
- Long Animation Frames较新的浏览器提供 LoAF 指标,能直接告出"这一帧超时了,主要时间花在哪个脚本上",比人工翻火焰图更省事。
最后给一段最常用的动画骨架代码,把这一节的所有原则都用上了:
/* 只用 transform + opacity,只在需要时提层 */
.panel {
transform: translateX(100%);
opacity: 0;
transition: transform .28s cubic-bezier(.2,.8,.2,1),
opacity .28s linear;
}
.panel.is-open {
transform: translateX(0);
opacity: 1;
}
.panel.is-animating { will-change: transform, opacity; }
/* 尊重用户的"减少动态效果"设置 */
@media (prefers-reduced-motion: reduce) {
.panel { transition: none; }
}
流畅的定义是硬的:每帧 16.7 毫秒,实际留给你的只有约 10 毫秒,超时就丢帧。CPU 像一位教授,擅长布局、样式、逻辑这些"证明题";GPU 像一千个小学生,擅长把海量像素统一变换这类"乘法题"。优化的本质就是把活从 CPU 挪到 GPU:把会动的元素提升为独立图层,然后只用 transform 和 opacity 驱动它——因为这两者只触发合成,改的是"看起来"而不是"事实"。反过来,卡顿基本只有四个源头:长任务堵住主线程、频繁回流、图层爆炸、大图解码与重绘过大。而找出它们不需要猜,打开 DevTools Performance 录一段,看红条、看火焰图的颜色宽度,凶手自己就站出来了。