算法与数据结构 / 动画引擎原理 · 从插值到播放控制 / 该动什么:合成层与布局绘制 待审核 36 / 47
compositor · transform/opacity · reflow

该动什么:合成层与布局绘制

一帧画面要经过布局、绘制、合成三个阶段。动画每帧改哪个 CSS 属性,决定了浏览器要从这条管线的哪一层重新算起:

  • left / top / width / height / margin 触发 layout,整条管线从头跑
  • color / background / box-shadow / border-radius 跳过 layout 但仍要 paint
  • transform / opacity 只需 composite,布局与绘制的结果可直接复用

最后一档还有一个额外好处:这一步交给合成器线程处理,不占主线程。

1 · 同一段位移的两种代价

图 1-1 · 同一个左右往返动画的两种写法实测。左侧每帧改 left 并读 offsetWidth,右侧改 transform;可调元素数量观察差距如何随规模拉开。
表 1-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 而不读几何,浏览器会把这批写入合并到下一次渲染前一起处理,代价远没有这么夸张。真实代码里的读写交错往往不这么显眼:offsetTopgetBoundingClientRectgetComputedStylescrollHeight 都会强制同步布局,而它们常混在一个「遍历元素、算位置、写样式」的循环里。修法是把读与写分成两趟——先全部读完,再全部写。

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 动画,从而避开逐帧重排。每帧到底由谁驱动、如何做到帧率无关,见驱动器与帧;把主线程的活儿挪走的另一条路见调度