该动什么:合成层与布局绘制
一帧画面要经过布局、绘制、合成三个阶段。动画每帧改哪个 CSS 属性,决定了浏览器要从这条管线的哪一层重新算起:
left/top/width/height/margin触发 layout,整条管线从头跑color/background/box-shadow/border-radius跳过 layout 但仍要 painttransform/opacity只需 composite,布局与绘制的结果可直接复用
最后一档还有一个额外好处:这一步交给合成器线程处理,不占主线程。
1 · 同一段位移的两种代价
| 属性 | 触发的最早阶段 | 每帧代价 |
|---|---|---|
transform / opacity |
composite | 低,合成器线程,可不占主线程 |
color / background / box-shadow / border-radius |
paint | 中,重新栅格化图层 |
left / top / width / height / margin / padding |
layout | 高,几何重排后 paint 与 composite 跟着重跑 |
2 · layout thrashing
左侧那一栏并非只是「改了 left」。它在同一个循环里先写样式再读几何,逼浏览器立刻同步重算布局来回答这次读取——缓存里攒着的批量更新被反复打断。这是故意制造的最坏情形。
警示 · 单纯改 left 而不读几何,浏览器会把这批写入合并到下一次渲染前一起处理,代价远没有这么夸张。真实代码里的读写交错往往不这么显眼:offsetTop、getBoundingClientRect、getComputedStyle、scrollHeight
都会强制同步布局,而它们常混在一个「遍历元素、算位置、写样式」的循环里。修法是把读与写分成两趟——先全部读完,再全部写。
3 · 合成层为什么便宜
盒子一旦被提升为独立的合成层,它的像素已栅格化缓存好;平移、缩放、改透明度只是给这张现成纹理一个新的变换矩阵或 alpha,由合成器线程在 GPU 上完成,主线程哪怕忙于 JS 也不阻塞它。
will-change: transform 提前告诉浏览器「这个元素将要变换」,促其预先建层,避免动画开始那一刻的建层抖动。
警示 · will-change 有显存成本,每一层都要一张纹理。给一整个列表无差别加上它,显存占用会随元素数线性增长,在低端设备上反而更卡。只标真正要动的元素,动完移除。图 1-1 里右栏的 300 个 mover 全带 will-change: transform,正是「为了演示故意越界」的用法。
// 触发 layout: 改几何 + 读 offsetWidth 强制同步布局 (layout thrashing)
function moveByLeft(el, x) {
el.style.left = x + 'px'; // 写: 让布局失效
void el.offsetWidth; // 读: 逼浏览器立刻重算布局
} // → layout → paint → composite
// 只触发 composite: 平移现成图层, 不碰布局与绘制
function moveByTransform(el, x) {
el.style.transform = `translateX(${x}px)`; // → 仅 composite
}
// 预声明将要变换的属性, 促浏览器提前建合成层
el.style.willChange = 'transform';
// ... 动画结束后移除, 释放显存
el.style.willChange = 'auto';
本页测的是「动什么属性」。当布局确实必须变,用 FLIP:先量布局前后的位置差,把变化倒推成一段纯 transform 动画,从而避开逐帧重排。每帧到底由谁驱动、如何做到帧率无关,见驱动器与帧;把主线程的活儿挪走的另一条路见调度。