§ 2.5 · Section

GPU 合成与 60fps

GPU Compositing & 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 fps8.3 ms高刷设备,拖动极其跟手
60 fps16.7 ms流畅,行业基线
30 fps33.3 ms明显不顺,滑动时能感到台阶感
24 fps41.7 ms电影可以,交互界面已经难受
< 15 fps> 66 ms用户会认为"这个应用坏了"

还有一个容易忽略的点:帧率的稳定性比绝对值更重要。稳定的 30fps 比在 60 和 25 之间反复跳动的画面感觉更好,因为后者的节奏不可预测,大脑会持续感到别扭。所以优化目标不是"平均帧率高",而是"没有超时的长帧"。

CPU 与 GPU 的分工

要理解为什么某些动画快得离谱,必须先理解这两个处理器的性格差异。

Analogy · 一位教授 vs 一千个小学生

要解一道复杂的证明题——找教授(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 最擅长的那类乘法题。

由此得出一个可以背下来的替换表:

常见卡顿的四类原因

知道了预算和分工,卡顿的诊断就有了框架。绝大多数卡顿逃不出这四类。

1. 长任务阻塞主线程

JavaScript 和渲染共用一个主线程。一段跑了 200 毫秒的 JS(大数组循环、复杂 JSON 解析、同步的加密计算)执行期间,浏览器完全无法渲染任何东西,连点击都不响应,这叫"掉帧"甚至"假死"。任何超过 50 毫秒的任务都算长任务。解法:拆成小片用 requestIdleCallback / setTimeout 分批执行,或挪到 Web Worker 里去跑。

2. 频繁回流 / 布局抖动

在滚动或 resize 回调里读 getBoundingClientRect()、循环里读写交替、给动画用几何属性——都会让布局这一步在每帧反复执行。解法:读写分批(上一节讲过)、事件回调加节流、动画改用 transform

3. 图层过多导致显存与合成压力

滥用 will-changetranslateZ(0),让几百个元素各占一张位图。显存吃紧后,光是"把这么多层叠起来"就超预算了,低端机可能直接崩。解法:只给真正在动的元素提层,动画完就撤。

4. 大图解码与巨型绘制

一张 4000×3000 的原图,浏览器要先解码成未压缩位图(约 48 MB)才能显示,解码本身就是一次耗时的 CPU 工作。此外满屏的 box-shadow 大模糊、backdrop-filter 毛玻璃、复杂渐变也都是绘制阶段的重活。解法:图片按实际显示尺寸出图并用 srcset 提供多档、用 loading="lazy" 延后加载、给非首屏的重效果做降级。

还有几个容易忽略的隐形杀手:滚动事件里没加 passive: true(导致浏览器必须等你的回调执行完才敢滚动);用 setInterval 而不是 requestAnimationFrame 驱动动画(节奏和屏幕刷新不同步,必然抖动);以及在动画进行中触发了字体加载(字体一到就整页回流重排)。

用 DevTools Performance 面板抓凶手

猜是最低效的调试方式。浏览器自带的性能面板能把这 16.7 毫秒里发生的一切摊在你眼前。基本流程只有四步:

几个配套的小工具也很值得知道:

最后给一段最常用的动画骨架代码,把这一节的所有原则都用上了:

/* 只用 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; }
}
Recap · 收束

流畅的定义是硬的:每帧 16.7 毫秒,实际留给你的只有约 10 毫秒,超时就丢帧。CPU 像一位教授,擅长布局、样式、逻辑这些"证明题";GPU 像一千个小学生,擅长把海量像素统一变换这类"乘法题"。优化的本质就是把活从 CPU 挪到 GPU:把会动的元素提升为独立图层,然后只用 transform 和 opacity 驱动它——因为这两者只触发合成,改的是"看起来"而不是"事实"。反过来,卡顿基本只有四个源头:长任务堵住主线程、频繁回流、图层爆炸、大图解码与重绘过大。而找出它们不需要猜,打开 DevTools Performance 录一段,看红条、看火焰图的颜色宽度,凶手自己就站出来了。

☰ 主页
学海无涯 · 界面篇 · § 2.5