GPU 合成与 60fps
"卡"这个字,用户说得含糊,但它在技术上有一个精确到小数点的定义:你只有 16.7 毫秒来准备好每一帧,超时就丢帧,丢帧就是卡。这一节把这笔时间账算清楚,讲明白 CPU 和 GPU 各干什么、图层提升到底提升了什么、为什么同样的动画换个写法就从卡成幻灯片变成丝滑,以及怎么用浏览器自带的工具当场抓到凶手。
你手里有一本翻页动画本,每秒要翻过 60 页,才能让小人跑得自然流畅。
问题是:每一页都得有人现场画出来,而且必须在你翻到之前画好。
也就是说画师只有 1/60 秒 ≈ 16.7 毫秒 画一页。
如果某一页他画了 30 毫秒——你翻过去时那页还是白的,只能把上一页多举一会儿。小人就"顿"了一下。
如果他每页都画 30 毫秒,实际帧率就掉到 30fps,小人一路都在抽搐。
聪明的画师会怎么做?把不动的背景单独画在一张纸上,只在透明片上画会动的小人——工作量瞬间降到十分之一。
为什么偏偏是 60fps
60 这个数字不是设计师拍脑袋定的,它来自硬件:绝大多数显示器的刷新率是每秒 60 次,即每 16.7 毫秒把屏幕内容整体更新一遍。屏幕就像那本翻页本,它按固定节奏翻,不会等你。
"刷新率"这个词换成大白话,就是"屏幕多久换一次内容"。它更像公交发车间隔——每 16.7 毫秒发一班车,到点就走。你的画面赶上了就上车,没赶上就只能等下一班,而上一班车会把旧画面多举一会儿。这就是"丢帧"的全部含义:不是画面画错了,是没在发车前画完,错过了这一班。
再往上追一层,是人眼的特性。大约 10~12fps 时人开始感知到"这是连续动作"而不是一张张图;24fps 是电影标准,够看但快速运动时会有拖影;到 60fps,视觉上基本感觉不到离散感,手指拖动界面时的跟手感也才成立。所以 60fps 是"硬件能给、人眼够用"的交汇点。
这条时间预算算下来更让人紧张:16.7 毫秒里,浏览器自己的内务(合成、上屏、和系统交互)要占掉几毫秒,真正留给你的 JS 逻辑、样式计算、布局和绘制的时间,大约只有 10 毫秒。而现在的手机上,一次全页布局就可能吃掉 5 毫秒以上。
16.7 毫秒到底有多短?打个比方:你眨一次眼大约 100 毫秒,也就是说浏览器要在你眨一下眼的时间里,把整套流程干完六遍。而实际留给你自己代码的只有 10 毫秒——相当于眨眼时间的十分之一。在这十分之一里,你要跑完事件回调、算完样式、排完布局、画完变化的图层。这就是为什么前端性能优化不是"锦上添花",而是一笔从一开始就非常紧的预算。
再换个更有画面感的算法:一块 1080p 屏幕有 207 万个像素,60fps 意味着每秒要送出 1.24 亿个像素值。假如让你手动一秒填一个格子,填完一帧要不眠不休干 24 天,而屏幕一秒钟要干 60 帧。这活儿之所以做得到,靠的完全不是"干得快",而是"几百万个格子同时开工"——也就是下面要讲的 GPU。
| 帧率 | 每帧预算 | 主观感受 |
|---|---|---|
| 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 之间反复跳动的画面感觉更好,因为后者的节奏不可预测,大脑会持续感到别扭。所以优化目标不是"平均帧率高",而是"没有超时的长帧"。
这一条用公交的例子最好懂:一趟每 10 分钟准点发一班的公交,体验远好过"有时 3 分钟一班、有时 20 分钟一班"的那种——哪怕后者的平均间隔更短。因为你能不能安排自己的时间,取决于间隔可不可预测,而不是平均值有多好看。所以看性能数据时,"平均帧率 58fps"这种指标基本没有意义,真正该看的是"有几帧超时了、最长的那帧花了多久"。
垂直同步与掉帧:为什么会"卡一下"
上面说屏幕像公交按点发车,这个类比还能再往下挖一层,因为它解释了"卡顿"在技术上究竟是什么样子。
屏幕的刷新是硬件驱动的、雷打不动的节奏:每 16.7 毫秒,显示控制器就去取一次当前的画面数据往屏幕上推。浏览器为了跟上这个节奏,会在每次刷新前把画好的那一帧交出去,这套配合机制叫垂直同步(VSync)。说白了就是"跟着发车时刻表干活"——不是想什么时候交就什么时候交,而是必须在发车前把货装上车。
那"卡一下"到底发生了什么?假设某一帧你花了 30 毫秒才算完。发车时间到了,你手里的新画面还没做好,显示控制器只能把上一帧的内容再推一遍。于是这一班车拉的是旧货,用户看到的是"画面定住了一下"。等你终于算完,已经错过了一整班,画面会突然跳到新位置——用户感受到的"卡",本质是"该变的时候没变,然后一下变了很多"。
这解释了两个常被误解的现象。第一,为什么"稍微超时"的惩罚这么大?因为帧的交付是离散的:你花 17 毫秒和花 33 毫秒,效果完全一样,都是丢掉这一帧。好比赶末班地铁,早到一分钟和早到十分钟没区别,但晚到一秒钟就得打车。所以优化的目标不是"尽量快",而是"务必压进 16.7 毫秒这条线以内"。
第二,为什么用 setInterval 做动画一定会抖?因为 setInterval(fn, 16) 的节奏和屏幕的发车时刻表是两套独立的钟,两者会缓慢错开。相当于你按自己的手表每 16 分钟去一次公交站,而公交是每 16.7 分钟一班——迟早会出现"刚好错过"和"等半天"交替出现的情况。正确做法是用 requestAnimationFrame,它的语义就是"下一班车发车前叫我一声",节奏永远和屏幕对齐。
顺带说清高刷屏的情况:120Hz 的设备把发车间隔压到了 8.3 毫秒,预算直接砍半。所以在高刷设备上,原本刚好达标的动画可能反而更容易掉帧。这也是为什么现代动画代码不应该假设"一帧是 16.7 毫秒",而应该用 requestAnimationFrame 回调里给出的时间戳去算实际经过了多久。按时间算位移,而不是按帧数算位移——这一条能让同一段动画在 60Hz 和 120Hz 上都以同样的速度播完。
CPU 与 GPU 的分工
要理解为什么某些动画快得离谱,必须先理解这两个处理器的性格差异。
先用一句大白话把结论说了:CPU 是那种"会做难题但只能一件一件做"的人,GPU 是那种"只会做简单题但能一千个人同时做"的队伍。界面渲染刚好这两种活都有,性能优化的全部本质就是尽量把活从前者手上挪到后者那边去。
- CPU · 少数全能选手核心数少(几个到几十个),但每个都极其聪明,擅长复杂的逻辑判断、分支跳转、串行推理。上一节讲的解析、布局、绘制指令生成,全是 CPU 的活。
- GPU · 海量专才核心数成千上万,每个都很"笨",只会做简单的数学运算,但能同时对海量数据做同一件事。它天生就是为"把这几百万个像素统一乘上一个矩阵"这类任务设计的。
要解一道复杂的证明题——找教授(CPU),一千个小学生也解不出来。
要算一万道"两位数乘法"——找一千个小学生(GPU),每人十道,一分钟搞定;教授一个人算得抓狂。
界面渲染刚好两种活都有:"这段文字该在哪折行、这个 flex 容器怎么分配空间" 是证明题,交给 CPU;"把这张 300 万像素的贴图整体缩放 1.2 倍再叠上去" 是一万道乘法题,交给 GPU。
性能优化的本质,就是尽量把活从教授桌上挪到小学生那边去。
具体的分工边界是这样的:CPU 负责跑 JavaScript、计算样式、执行布局、生成绘制指令、把纹理数据上传给 GPU;GPU 负责栅格化部分工作、以及最关键的——把所有图层按变换矩阵和透明度叠成最终画面。合成这件事对 GPU 来说轻如鸿毛,因为它就是干这个的。
这里有个容易被忽略的成本项:把纹理数据上传给 GPU 这一步本身是要花时间的。相当于把货从仓库搬到传送带上——传送带跑得再快,装车这一趟路省不掉。所以"图层越多越快"是错的:每提升一个图层,就多一份要装车的货和一份要占用的显存。GPU 的便宜只体现在"叠"这个动作上,不体现在"把东西交给它"这个过程上。
这一节的三个核心概念——帧预算、图层、GPU 合成——可以用三个日常物件一次讲透。
一、帧是一格胶片,时刻表是硬的。把屏幕想成一台电影放映机:胶片按固定速度往前走,每 16.7 毫秒过一格。你的任务是在下一格到来之前把画面画好放进去。画好了,观众看到流畅动作;没画好,放映机只能把上一格再放一遍,观众看到"顿了一下"。关键在于这个时刻表不听你的——就像公交到点就发车,你没赶上就只能等下一班,而这一班拉走的是旧画面。所以"花 17 毫秒"和"花 33 毫秒"的惩罚一模一样:都是丢掉这一帧。
二、图层就是动画师那几张透明片。手绘动画时代,聪明的画师绝不会每帧重画整张画面。他把不动的背景(房子、树、天空)画在一张底纸上,只把会动的小人画在一张透明赛璐珞片上。小人跑动时,他只需要挪那张透明片,底纸一笔都不用重画——工作量瞬间降到十分之一。浏览器的图层机制一模一样:被"提升"为独立图层的元素,动起来时不需要任何人重画,只要把它这张片子换个位置摆一下。
三、GPU 就是那个只负责摆片子的人。他压根不看片子上画的是什么,只按一张写着"往右挪多少、放大几倍、透明度几成"的小卡片去摆。这活儿有个致命优势:几百万个像素可以同时开工,互不影响——好比一条传送带上几百个工位同时贴同一款标签,不用商量、不用排队、一趟过。而 CPU 那边的布局是"这个人挪窝后面全排跟着挪"的连锁题,只能一步一步来。这就是 transform 和 opacity 快得离谱的全部原因:它们只需要换一张摆放卡片,压根不惊动画面内容。
四、代价也在这个类比里。透明片不是白来的——每张片子都要占一块桌面(显存)。给一个 200 项的列表每项都做一张透明片,桌子会被占满,光是"把这么多片子按顺序摆好"就超时了。这就是图层爆炸。所以纪律很简单:只给真正在动的那一两个元素做透明片,动完就把片子收起来。这跟用完东西放回原位是同一个道理。
五、把三件事串起来,性能优化就只剩一句话:在每一班车发车之前,尽量少惊动 CPU 那边的连锁题,尽量多把活交给只需要摆片子的那个人。卡顿的四大源头——长任务、频繁回流、图层爆炸、大图解码——本质上全是"要么惊动了连锁题,要么把桌子占满了"。
图层提升:给会动的东西一张独立的纸
上一节提到过图层。这里把它和性能直接挂钩:一个元素被提升为独立图层后,它的移动、缩放、淡入淡出就不再需要重画任何内容,只需要 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 显存,结果往往是整页更卡甚至标签页崩溃。正确姿势是只在真正要动的元素上加,动画结束后撤掉:
这个坑之所以常见,是因为它违反了"越多越好"的直觉。will-change 相当于给停车场提前预留车位——预留一个很合理,给全公司三百人每人预留一个,停车场就满了,反而没人停得进来。显存是有限资源,预约不是免费的。更糟的是这个问题在开发机上往往看不出来(显存充足),一到中低端手机就直接崩。
/* ✓ 只在交互即将发生时提升 */
.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 最擅长的那类乘法题。
换成大白话:opacity 就是在摆那张透明片的时候,顺手告诉一句"这张片子按七成的浓度贴"。片子本身一笔没改,只是贴的时候淡了点。几百万个像素各自乘一个 0.7,互不影响,正好是可以同时开工的那类活儿。
由此得出一个可以背下来的替换表:
- 位移动画别用
left/top/margin,用transform: translate(x, y)。 - 缩放动画别用
width/height,用transform: scale(n)。 - 显隐动画别用
display(它根本没法过渡),用opacity配合visibility或pointer-events。 - 旋转动画用
transform: rotate(),本来就只走合成,放心用。 - 列表重排动画用 FLIP 思路:先读取首末位置,用
transform把元素"假装"放回原处,再让它过渡到零——全程只走合成。
常见卡顿的四类原因
知道了预算和分工,卡顿的诊断就有了框架。绝大多数卡顿逃不出这四类。
先把四类的共性说了:它们要么是"惊动了 CPU 那边的连锁题",要么是"把桌面占满了"。换成大白话——卡顿从来不是"电脑不行",而是有人在发车前的十毫秒里干了一件本不该在这时候干的事。
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 驱动动画(节奏和屏幕刷新不同步,必然抖动);以及在动画进行中触发了字体加载(字体一到就整页回流重排)。
这三个里最隐蔽的是第一个,值得翻译一下。没加 passive: true 的滚动监听,相当于电梯门口站了一个人说"等我说完你再关门"——浏览器必须先跑完你的回调,确认你没有调用 preventDefault() 阻止滚动,才敢真的滚。你的回调哪怕只花 3 毫秒,也已经吃掉了这一帧三分之一的预算。加上 passive: true 就等于提前声明"我不会拦门,你直接关",浏览器可以立刻滚动,回调稍后再跑。
第三个也有个很日常的对应:字体加载完成会引发整页回流,好比一场会议进行到一半,突然换了一批不同尺寸的椅子——所有人都得重新坐一遍,桌子的间距也得重排。这就是所谓的"字体闪动"。解法是用 font-display: swap 先用系统字体顶着,或者干脆把首屏字体预加载。
一段动画从"卡成幻灯片"到"丝滑"的完整改造
把前面所有原则用在一个具体例子上,你会看到差距有多大。假设需求是:一个侧边抽屉从右侧滑入,同时淡入,滑入过程中列表项依次错开出现。
第一版 · 卡成幻灯片。用 right 从 -320px 过渡到 0 做滑入,用 display 配合 height 做列表项出现,还在 scroll 回调里读了一次 getBoundingClientRect() 来判断抽屉位置。这一版的每一帧都在干什么?right 改的是文档流里的真实位置,每帧都要重排;height 动画每帧都要重新算文字折行;那个 getBoundingClientRect() 还在每帧强制结算一次布局。三样叠加,一帧轻松跑到 30 毫秒以上,实际帧率掉到 25fps 左右,用户感受就是"一格一格地跳"。
第二版 · 把位移换成 transform。把 right 改成 transform: translateX(100%) → translateX(0)。这一步的收益最大:元素在文档流里根本没动,兄弟元素毫不知情,浏览器只需要给合成器换一张摆放卡片。每帧代价从"重排加重画"降到"挪一下片子",直接从 10 毫秒量级掉到 1 毫秒以下。
第三版 · 把显隐换成 opacity。display 压根没法过渡(它是个开关,不是个刻度),而 height 动画每帧都要重算折行。换成 opacity 配 transform: translateY():列表项从下方轻轻上浮并淡入,全程只走合成。如果需要在动画结束后真的不可点击,用 pointer-events: none 或延迟设置 visibility,而不是用 display 参与过渡。
第四版 · 收拾掉强制布局。那个 scroll 里的 getBoundingClientRect() 其实完全可以省掉——抽屉的开合状态用一个 class 记录就行,不需要每帧去量它的真实位置。这就是前面说的"别在厨师长攒单的时候插话"。如果确实需要读,把读操作挪到 requestAnimationFrame 回调的最前面,和写操作分开。
第五版 · 管好图层。只在抽屉即将打开时给它加 will-change: transform, opacity,transitionend 之后立刻撤掉。列表项不要每一项都加——它们的动画很短,提层的收益抵不上显存代价。
第六版 · 尊重用户设置。加一条 @media (prefers-reduced-motion: reduce),对开了"减少动态效果"的用户直接关掉过渡。这不是性能优化,是基本的尊重——有些人对动效会产生眩晕反应。
六步走完,同一个功能从 25fps 变成稳定 60fps,而代码量几乎没变,改的全是"用哪个属性"和"什么时候读"。这就是这一节最值钱的结论:界面性能的绝大部分收益,来自选对属性和排好顺序,而不是来自更强的硬件或更聪明的算法。
用 DevTools Performance 面板抓凶手
猜是最低效的调试方式。浏览器自带的性能面板能把这 16.7 毫秒里发生的一切摊在你眼前。基本流程只有四步:
这个面板的作用,相当于给你的页面做一次挂号检查:先录一段"病症发作时"的过程,再看报告上哪一项指标超标。凭感觉猜"大概是图片太大了吧",跟不做检查直接开药是一回事。
- 第一步 · 录制打开 DevTools → Performance,点圆形录制按钮,然后复现卡顿操作(滚动、点开抽屉、切换 tab),停止录制。录制时间控制在 3~5 秒,太长了不好看。
- 第二步 · 看 FPS 与红条最上方是帧率图,绿色柱越高越好;柱子上方出现红色横条的地方就是丢帧区间——直接把注意力放在那儿。
- 第三步 · 展开 Main 火焰图Main 轨道显示主线程在干什么。宽的色块 = 耗时长的任务,右上角带红色三角的是被标记的长任务。颜色有约定:黄色是 Scripting(JS),紫色是 Rendering(样式计算 + 布局),绿色是 Painting(绘制 + 合成)。哪种颜色占得宽,方向就明确了。
- 第四步 · 对症下药黄色过宽 → 优化 JS、拆分长任务、上 Worker。紫色过宽 → 找强制同步布局(面板会直接标 Forced reflow 警告并给出触发它的代码行)。绿色过宽 → 减少大面积重绘、检查阴影和滤镜、缩小图片。
火焰图的三种颜色也可以用大白话记住:黄色是"人在算"(JS),紫色是"在排灶台"(样式与布局),绿色是"在炒菜和端盘子"(绘制与合成)。哪种颜色的色块最宽,方向就明确了——你不需要读懂火焰图里的每一层函数名,只要看哪种颜色占得宽。
几个配套的小工具也很值得知道:
- 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; }
}
把第 2 章五节串成一条线
这一节是本章的收尾,正好可以把五节回头串一遍。你会发现它们其实在回答同一句话的五个部分:"谁来决定屏幕上每个小格子填什么颜色,以及必须在多快之内填完。"
§ 2.1 定义了那个格子。像素是屏幕上能独立控制颜色的最小单元,内部是红绿蓝三个小灯。而"一个 px 到底多大"这件事被拆成了物理像素和逻辑像素两套,中间靠 DPR 换算——说白了就是"布上有多少格"和"图纸上写多少厘米"的区别。
§ 2.2 讲清了格子里的内容从哪来。位图是已经绣好的成品,格数出厂钉死,放大只能靠猜;矢量是一份绣花图纸,每次显示都按当前尺寸重新绣一遍。一个存结果,一个存做法。
§ 2.3 讲清了一个格子的颜色怎么记。三个数字加一份说明书:RGB/HSL 是两种写法,sRGB/P3 是两种计量口径,透明是一次和背景的加权兑合,而对比度决定了这颜色到底能不能读。
§ 2.4 讲清了从网页文本到几百万个格子要过几道工序。解析、布局、绘制、合成四道,单向流动,从哪一步重来后面都得跟着重来。这就是餐厅从听单、排灶台、炒菜到端盘子的流程。
§ 2.5 也就是本节,讲清了这几道工序必须在多快之内跑完。16.7 毫秒一班车,实际留给你的只有 10 毫秒;把活从 CPU 挪到 GPU,把会动的东西单独放一张透明片,只用 transform 和 opacity 驱动它。
五节合起来,其实就给了你一套完整的排查地图:图糊了往 § 2.1 和 § 2.2 查,颜色不对往 § 2.3 查,白屏和闪烁往 § 2.4 查,卡顿往 § 2.5 查。而这套地图最大的价值不是记住结论,是遇到问题时知道该往哪一层找,而不是在最外层反复试。这就好比家里水压不对,懂行的人会先分清是总阀、管路还是水龙头的问题——问对了层,工具都不用换几件。
流畅的定义是硬的:每帧 16.7 毫秒,实际留给你的只有约 10 毫秒,超时就丢帧。CPU 像一位教授,擅长布局、样式、逻辑这些"证明题";GPU 像一千个小学生,擅长把海量像素统一变换这类"乘法题"。优化的本质就是把活从 CPU 挪到 GPU:把会动的元素提升为独立图层,然后只用 transform 和 opacity 驱动它——因为这两者只触发合成,改的是"看起来"而不是"事实"。反过来,卡顿基本只有四个源头:长任务堵住主线程、频繁回流、图层爆炸、大图解码与重绘过大。而找出它们不需要猜,打开 DevTools Performance 录一段,看红条、看火焰图的颜色宽度,凶手自己就站出来了。