调度:单 rAF 与读写分离
页面上同时跑着多个动画时,真正决定每帧成本的不是动画数量,而是调度方式。两个工程要点决定一帧能否在预算内完成:
- 所有动画共用一个
requestAnimationFrame循环,而非各自注册 - 一帧之内把所有 DOM 读批量做完,再批量写
1 · 两个开关的实测
2 · 读写交错的代价
关掉读写分离后,每个方块变成「读一次布局、立刻写 transform、下一个方块再读」的交错序列。每次读都强制浏览器把上一次写产生的样式变更同步结算成新的布局,元素越多,反复 reflow 的代价越呈倍数放大。
分离之后整帧只结算一次布局:先把所有 getBoundingClientRect 做完(此时布局仍是上一帧的干净状态,读取不触发重算),再一口气把所有 transform 写下去(写入只是弄脏布局,结算推迟到帧末)。
注 · 这个 lab 里每个方块真的去读了自己的布局位置,而不是假装读一下。这一步是必要的:若写入的值与读取无关,浏览器有可能把这次读优化掉,两种模式就测不出差别。真实场景里的读写依赖也大多长这样——按当前位置算下一步偏移。
3 · 单 rAF 的两重意义
关掉单 rAF 后,每个方块各自注册一次 requestAnimationFrame。开销上,回调调度次数随元素数线性增长;语义上,不同回调看到的时间戳会出现漂移,动画之间不再严格对齐。
第二点常被低估。同一批元素若各自持有自己的时钟,交错动画的相位会慢慢散开,几秒之后看得出来。把「唯一的时间来源」收敛成一个循环,正是引擎驱动器的设计——所有动画挂在同一个 rAF 上,单次开销、时间戳一致、天然对齐。
// 交错: 读一个就立刻写一个 → 每次 read 强制结算上次 write, 反复 reflow
function tickInterleaved(cells, t) {
for (const el of cells) {
const base = el.getBoundingClientRect().top; // READ (强制同步布局)
el.style.transform = pose(base, t); // WRITE (弄脏布局)
}
}
// 分离: 先把所有 READ 做完, 再统一 WRITE → 整帧只结算一次布局
function tickSeparated(cells, t) {
const bases = cells.map((el) => el.getBoundingClientRect().top); // 全部 READ
cells.forEach((el, i) => (el.style.transform = pose(bases[i], t))); // 全部 WRITE
}
// 单 rAF: 一个循环驱动全部动画 (引擎的唯一 driver 即此形)
function loop(now) {
for (const a of animations) a.sample(now); // 单次回调, 时间戳一致, 自动对齐
requestAnimationFrame(loop);
}