一、技术难点
Day23 站在框架层看性能(粒度、调度、结构),Day24 站在运行时隔离层看微前端。这一篇再往下沉一层,落到浏览器内核:当你写下 el.style.width = '300px' 这一行,浏览器到底要做完多少件事,屏幕上才会真的多出 300 像素。
先给结论:渲染是一台有两条流水线的机器------主线程一侧五步、合成器一侧四步;所有渲染性能技巧,本质都是同一件事:把工作从贵的步骤挪到便宜的步骤,或者干脆挪到另一条流水线上去。 而"哪些步骤能被跳过"由脏标记决定,脏标记由属性和失效范围决定。
本文所有数字都来自三份零依赖脚本的实跑输出,不是估的。
难点一:一帧只有 16.67ms,而且被切成了两条并行流水线
60Hz 下一帧 16.67ms,120Hz 下 8.33ms。但这 16.67ms 不是给你一个人用的:主线程还要跑 JS、事件回调、requestAnimationFrame、微任务、垃圾回收。渲染只是主线程时间账本里的一行。
更关键的是,渲染不完全在主线程上做 。一次合成层上的位移动画,主线程可能一毫秒都不花,但屏幕上照样每帧在动------因为那部分工作在合成器线程和 raster worker 上。于是帧时间不是"主线程耗时",而是两条流水线的关键路径:
scss
关键路径 = max(主线程耗时, 合成器耗时)
这个区分不是学术洁癖。看实测(frame-budget.mjs,60 张卡片同时动画):
| 策略 | 主线程 ms | 合成器 ms | 关键路径 | 主线程占用率 | 有效 fps |
|---|---|---|---|---|---|
动画 left(无合成层) |
22.49 | 1.92 | 22.49 | 135% | 44.5 |
动画 transform(无合成层) |
12.29 | 1.92 | 12.29 | 74% | 60.0 |
动画 transform(已合成,JS 逐帧写) |
0.36 | 0.004 | 0.36 | 2% | 60.0 |
动画 transform(CSS Animation / WAAPI) |
0.000 | 0.004 | 0.004 | 0% | 60.0 |
第一行主线程占用 135% ------意思是这一帧的主线程工作量已经超过了一帧的总预算,"动画会掉帧"在写下 left 的那一刻就已经注定了。第四行主线程是 0:动画完全在合成器上跑,主线程就算被一段死循环占死,动画也不会卡(只会卡交互)。
难点二:五个阶段的名字都好记,跳过规则才是全部
Blink 官方把一次渲染生命周期叫 update the rendering,主线程侧严格按这个顺序:
css
主线程:Style → Layout → Pre-paint → Paint → Commit
↓(唯一的跨线程交接)
合成器:Animate → Layerize → Raster → Activate → Draw → Display
逐步说清楚每一步到底产出什么,因为**"能不能跳过"取决于"这一步的输入脏不脏",而不是"这一步看起来重要不重要"**:
- Style(样式重算):算出每个元素的 computed style。输入是"哪些元素的样式脏了"。Blink 不重新匹配全部选择器,而是用 InvalidationSet 记录"改这个 class 会影响哪些选择器",把失效范围压到最小。
- Layout(布局):从布局根往下重算盒子几何,产出 fragment tree。LayoutNG 之后这是一棵不可变树,"重排"实际上是重建局部子树。
- Pre-paint(预绘制) :最没有存在感、也最被低估的一步。它遍历树,更新 paint property tree (transform / clip / effect / scroll 四棵树),决定"哪些元素需要重绘",并生成 paint invalidation。你在 DevTools 里看到的那个薄薄的
Pre-Paint切片,就是它。 - Paint(绘制) :产出 display item list ------一份"画什么、按什么顺序画"的清单。注意:这一步不产生任何像素。
- Commit(提交):把属性树 + 显示列表打包交给合成器线程。整个渲染管线里唯一一次跨线程交接,也是为什么"布局一发生,所有合成层的属性树都要重新提交"。
- Layerize → Raster(分层与光栅化) :把显示列表切成 256×256 的设备像素 tile,在 raster worker 上并行 光栅化成位图。像素是在这一步产生的,而这一步在另一条流水线上------这就是"改 transform 能不掉帧"的全部秘密。
- Activate → Draw → Display:pending tree 升为 active tree,聚合成 quad,交给 Viz/GPU 进程上屏。
把第 4 步和第 6 步分开理解,是本文后面所有结论的地基:Paint 只写清单,Raster 才画像素,而它们不在同一条流水线上。
难点三:脏标记往哪传播,是反直觉的
"改一个属性会触发什么"这句话不完整。完整的问法是:这个属性的失效范围有多大? 范围有三个维度:
- 本元素:只影响自己一个盒子;
- 子树:自己 + 所有后代;
- 流(flow) :自己 + 后代 + 祖先 + 兄弟子树------因为流内几何变化会把它后面所有东西挤走。
实测(lifecycle-sim.mjs,页面 603 个节点、60 张卡片、每张 10 个盒子):
| 做法 | style | layout | prepaint | paint | 说明 |
|---|---|---|---|---|---|
改容器 width(流内几何) |
1 | 603 | 603 | 601 | 整页 603 个节点全部重排 |
动画 left(定位元素,60 张) |
60 | 600 | 600 | 600 | 只波及各自子树,不动祖先与兄弟 |
改 background-color |
1 | 0 | 0 | 1 | 不碰布局,只重绘自己 |
改 color(继承属性) |
10 | 0 | 0 | 1 | 不碰布局,但后代全部要重算样式 |
| class 切换,选择器只命中自身 | 1 | 0 | 0 | 10 | --- |
class 切换,命中 .card .x 后代选择器 |
10 | 0 | 0 | 10 | 失效范围是整个子树 |
头两行对照读最有意思:改一次 容器 width,就让整页 603 个节点全部重排;而 60 张卡片各自动画 left,每张只波及自己那 10 个盒子,合计 600。总量看着差不多,但前者是"一次改动付清全款",后者是"60 次改动各付各的"。真正的成本是「失效范围 × 触发频率」,单看任何一项都会误判。
难点四:"合成器属性"这个说法有个前提条件
transform 和 opacity 被称作"compositor-only properties",很多人因此以为"用了 transform 就万事大吉"。这句话少了一个前提:元素得先有自己的合成层。
实测三行,对照着看:
| 场景 | style | layout | paint | 全量 commit | 属性树 commit |
|---|---|---|---|---|---|
transform,元素没有自己的合成层 |
1 | 0 | 10 | 1 | 0 |
transform,元素已是合成层(JS 逐帧写) |
1 | 0 | 0 | 0 | 1 |
transform,已合成 + 合成器驱动 60 帧 |
0 | 0 | 0 | 0 | 0 |
第一行:transform 确实不触发布局(layout 永远是 0),但元素没成层时,变换会改变 paint offset,整棵子树(10 个盒子)都要重绘 。第二行:有了自己的层,绘制和全量 commit 全部跳过,只剩"把变化的属性树节点提交给合成器"这点零头。第三行:连那个零头都没有------CSS Animation / WAAPI 的 transform、opacity 动画跑在合成器上,主线程一帧都不参与。
层的代价是实打实的显存。 合成器的绘制缓冲按 256×256 设备像素分块,每块 RGBA 4 字节 = 256KB。层的大小按设备像素算,所以 DPR 2 会让面积变成 4 倍(layer-planner.mjs,视口 1440×900 @DPR 2):
| 场景 | 层数 | tile 数 | 显存 | vs 基线 | 移动端 32MB | 桌面端 128MB |
|---|---|---|---|---|---|---|
| S1 基线(无任何显式提升) | 1 | 96 | 24.0 MB | 1.00× | OK | OK |
S2 只有正在动的那个元素常驻 will-change |
2 | 102 | 25.5 MB | 1.06× | OK | OK |
S3 60 张卡片全部 will-change: transform |
61 | 456 | 114.0 MB | 4.75× | 超支 | OK |
S4 500 个列表项全部 will-change |
501 | 1596 | 399.0 MB | 16.63× | 超支 | 超支 |
S5 给比视口大 5 倍的滚动容器加 will-change |
2 | 528 | 132.0 MB | 5.50× | 超支 | 超支 |
三个必须记住的细节:
- S3 在桌面端"OK"、在移动端超支 3.5 倍 ------这就是"本机测不出问题、上线到手机上就爆"的根源。而且合成器在光栅化期间可能同时持有 active / pending 两棵树,峰值显存可达上表的 2 倍。
- S5 说明层尺寸跟着内容走,不是跟着视口走:一张 1440×4500 的滚动容器层要 108MB,比整个视口还贵 4.5 倍。
- 层爆炸不只烧显存,还卡光栅化 :500 个元素常驻
will-change的首帧,主线程 27ms、合成器侧 31ms------瓶颈从主线程换到了合成器,你按老经验去优化主线程只会白费力气。
还有一个几乎所有人都会踩的副作用:will-change: transform 会让元素成为 fixed 后代的包含块 。一个带 will-change 的卡片里如果有 position: fixed 的浮层,浮层会被关进卡片里,不再相对视口定位。同理,filter、contain: paint 也有这个效果。
难点五:读操作会让写操作立刻"结算"
浏览器把一帧内的样式和布局变更攒起来批量处理,这是它最大的性能红利。但有个东西能把这笔红利一次吃光:读一个依赖布局的属性。
offsetWidth、offsetTop、clientHeight、scrollTop、getBoundingClientRect()、getComputedStyle(el).width------只要你读,浏览器就必须 先把队列里所有待处理的样式和布局算完才能给你准确答案。这一次 flush 叫强制同步布局(forced synchronous layout),俗称 layout thrashing。
实测(60 张卡片改 width,累计工作量):
| 做法 | 帧数 | style | layout | prepaint | paint | 强制同步布局次数 |
|---|---|---|---|---|---|---|
| A 批处理:60 次写 + 帧末一次 flush | 1 | 60 | 603 | 603 | 600 | 0 |
| B 抖动:60 次"写后立刻读" | 60 | 60 | 36180 | 36180 | 600 | 60 |
同样的 60 次写,累计布局工作量差了整整 60 倍。 换成帧时间更直观:A 的主线程关键路径 22.49ms,B 是 630.30ms------一次读写循环就把这一秒内的所有帧全部打掉。
最毒的是这类代码看起来完全无辜:
js
// ❌ 每次迭代都强制同步布局一次
for (const el of items) {
el.style.width = el.offsetWidth + 10 + 'px';
}
难点六:containment 把失效范围的"边界"还给你
难点三说"改 width 会让整页重排",那是因为浏览器默认假定布局是全局耦合的 。contain 的价值就是告诉浏览器:这个子树是自给自足的,外面的东西不会影响它,它也不会影响外面。
css
.card {
contain: layout paint; /* 布局与绘制都被关在卡片内部 */
}
配套的还有一条更好用的:
css
.article-section {
content-visibility: auto; /* 屏外时整段跳过渲染 */
contain-intrinsic-size: auto 1200px; /* 必须给占位尺寸 */
}
content-visibility: auto 让屏外内容直接跳过 style/layout/paint,长文档首屏能省掉大半工作量。但它有两个硬性前提:必须配 contain-intrinsic-size ,否则元素在"跳过"状态下尺寸为 0,滚动条会疯狂跳动;以及跳过期间元素内的滚动位置、锚点、getBoundingClientRect 结果都会失真 ,IntersectionObserver 的判定也会跟着变。
还有一个容易忽略的副作用:contain: paint 同时会创建包含块和堆叠上下文 ,和 will-change 一样会把 fixed 子元素关住。
难点七:动画的时序根本不在主线程
requestAnimationFrame 的回调跑在同一帧的 Style 之前 。这意味着:rAF 里是这一帧唯一适合"写"的地方,也是"读"最贵的地方------因为此时队列里通常已经堆了最多的写。
而动画的时序完全是另一回事:
- 跑在合成器上 :CSS Animation / CSS Transition / Web Animations API 对
transform、opacity、filter的动画。主线程被阻塞,动画照常跑。 - 跑在主线程上 :JS 逐帧改
style;以及任何非合成属性的动画 (width、height、left、top、background-color......)。
实测里那个 0.36ms 就属于第二类:虽然已经比 22.49ms 好了 62 倍,但它仍然是主线程 工作------只要主线程被别的东西占住,它就卡。真正把动画"移出主线程"的做法,是把 will-change 的生命周期交给浏览器管理:
js
// ✅ 动画期间才提示浏览器提升为合成层,结束后立刻收回
async function slideIn(el) {
el.style.willChange = 'transform';
const anim = el.animate(
[{ transform: 'translateX(-100%)' }, { transform: 'translateX(0)' }],
{ duration: 240, easing: 'cubic-bezier(.2,.8,.2,1)', fill: 'both' }
);
await anim.finished; // 动画结束(含被打断)
el.style.willChange = 'auto'; // 收回提示,层随之释放
}
把 S3 那种"60 张卡片常驻 will-change"改成"同一时刻只有正在动的那个元素持有提示",实测显存从 114.0 MB 降到 25.5 MB,省 77.6%。
二、完整解法
三件武器,对应上面七条难点:属性失效表 (知道每个属性的代价)、读写分离 (不让读操作结算写操作)、层生命周期管理 (把动画挪到便宜的那条流水线上)。下面三份脚本都是零依赖、可直接 node xxx.mjs 跑,所有数字都是实跑输出。
2.1 属性失效表:把"改一下会怎样"变成可查的表
lifecycle-sim.mjs
js
#!/usr/bin/env node
// lifecycle-sim.mjs ------ 渲染生命周期确定性模拟器
// 脏标记传播 → 五阶段工作量记账 → 批处理 vs 强制同步布局
// 零依赖,Node >= 16:node lifecycle-sim.mjs
import assert from 'node:assert/strict';
/* ============================================================
* 0. 属性失效表:本文全部结论的源头
* layout : none | subtree(自身+后代) | flow(自身+后代+祖先+兄弟子树)
* paint : none | self | subtree
* compositor: 该属性属于「合成器属性」,但只有元素已在合成层上才真的能只走合成
* ============================================================ */
export const TRIGGERS = {
transform: { layout: 'none', paint: 'subtree', compositor: true },
opacity: { layout: 'none', paint: 'subtree', compositor: true },
filter: { layout: 'none', paint: 'subtree', compositor: true },
'background-color': { layout: 'none', paint: 'self' },
'border-radius': { layout: 'none', paint: 'self' },
'box-shadow': { layout: 'none', paint: 'self' },
color: { layout: 'none', paint: 'self', inherited: true },
visibility: { layout: 'none', paint: 'subtree' },
'z-index': { layout: 'none', paint: 'subtree' },
'font-size': { layout: 'flow', paint: 'subtree', inherited: true },
width: { layout: 'flow', paint: 'subtree' },
height: { layout: 'flow', paint: 'subtree' },
padding: { layout: 'flow', paint: 'subtree' },
margin: { layout: 'flow', paint: 'subtree' },
display: { layout: 'flow', paint: 'subtree' },
left: { layout: 'subtree', paint: 'subtree' }, // 仅定位元素有效
top: { layout: 'subtree', paint: 'subtree' },
'content-visibility':{ layout: 'subtree', paint: 'subtree' },
};
/* ============================================================
* 1. 最小 DOM
* ============================================================ */
class El {
constructor(id, { ownLayer = false, positioned = false } = {}) {
this.id = id;
this.parent = null;
this.children = [];
this.style = {};
this.classes = new Set();
this.ownLayer = ownLayer; // 显式提升为合成层(will-change / 3D transform)
this.positioned = positioned; // 定位元素:left/top 生效
}
append(child) { child.parent = this; this.children.push(child); return child; }
get descendants() {
const out = [];
for (const c of this.children) out.push(c, ...c.descendants);
return out;
}
get subtree() { return [this, ...this.descendants]; }
get ancestors() {
const out = [];
for (let p = this.parent; p; p = p.parent) out.push(p);
return out;
}
get siblingSubtrees() {
if (!this.parent) return [];
return this.parent.children.filter((c) => c !== this).flatMap((c) => c.subtree);
}
}
/* ============================================================
* 2. 引擎:写只记账,帧末统一 flush
* ============================================================ */
class Engine {
constructor(root) {
this.root = root;
this.queue = [];
this.frames = [];
this.forcedFlushes = 0; // 强制同步布局(layout thrashing)发生次数
this.framesRun = 0;
this.recountLayers();
}
recountLayers() { this.layers = this.root.subtree.filter((n) => n.ownLayer); }
get layerCount() { return Math.max(1, this.layers.length); }
// ---- 写:只入队 ----
write(el, prop, value) { this.queue.push({ el, prop, value }); }
// 合成器驱动的动画帧(CSS Animation / Web Animations):本帧主线程完全不参与
compositorAnim(el, prop, value) {
if (!el.ownLayer) throw new Error('只有已合成元素上的 transform/opacity 能交给合成器驱动');
this.frames.push({ ...EMPTY, propTreeOnly: true });
this.framesRun++;
}
toggleClass(el, cls, on, { descendantSelector = false } = {}) {
this.queue.push({ el, kind: 'class', cls, on, descendantSelector });
}
// ---- 读:队列非空时必须先把样式与布局算完,这就是 layout thrashing 的来源 ----
readLayout(el) {
if (this.queue.length > 0) this.forcedFlushes++;
this.flush();
return { w: Number(el.style.width ?? 100), h: Number(el.style.height ?? 40) };
}
// ---- 一趟渲染生命周期 ----
flush() {
const style = new Set(), layout = new Set(), prepaint = new Set(), paint = new Set();
const touchedLayers = new Set();
let propTreeOnly = true;
for (const m of this.queue) {
const el = m.el;
if (m.kind === 'class') {
el.classes.has(m.cls) ? el.classes.delete(m.cls) : el.classes.add(m.cls);
// 类名变化 = 选择器可能重匹配:命中 .a .b 这类后代选择器时,失效范围是整个子树
style.add(el);
if (m.descendantSelector) el.descendants.forEach((n) => style.add(n));
el.subtree.forEach((n) => paint.add(n));
propTreeOnly = false;
continue;
}
const t = TRIGGERS[m.prop];
if (!t) throw new Error(`未登记的属性: ${m.prop}`);
el.style[m.prop] = m.value;
style.add(el);
if (t.compositor && el.ownLayer) {
// 合成层上的 transform/opacity/filter:布局·绘制·全量 commit 全跳过,
// 只需把变化的属性树节点提交给合成器(代价是全量 commit 的零头)
touchedLayers.add(el);
continue;
}
if (t.inherited) el.descendants.forEach((n) => style.add(n));
propTreeOnly = false;
if (t.layout === 'subtree') el.subtree.forEach((n) => layout.add(n));
else if (t.layout === 'flow') {
el.subtree.forEach((n) => layout.add(n));
el.ancestors.forEach((n) => layout.add(n));
el.siblingSubtrees.forEach((n) => layout.add(n));
}
if (t.paint === 'subtree') el.subtree.forEach((n) => paint.add(n));
else if (t.paint === 'self') paint.add(el);
}
const didLayout = layout.size > 0;
const didPaint = paint.size > 0;
if (didLayout) layout.forEach((n) => prepaint.add(n));
// 两种 commit:
// 全量 commit ------ 发生布局/绘制时,新的属性树 + 显示列表一起发到合成器
// 属性树 commit ------ 只有合成层上的 transform/opacity 变了,只提交那一个属性树节点
const commit = propTreeOnly ? 0 : this.layerCount;
const propTreeCommit = propTreeOnly ? touchedLayers.size : 0;
this.queue.length = 0;
const work = {
style: style.size, layout: layout.size, prepaint: prepaint.size,
paint: paint.size, commit, propTreeCommit, propTreeOnly,
};
this.lastWork = work;
this.frames.push(work);
this.framesRun++;
return work;
}
frame() { return this.flush(); }
// 累计工作量(把若干帧加起来)
total() {
const acc = { ...EMPTY };
for (const w of this.frames) {
acc.style += w.style; acc.layout += w.layout;
acc.prepaint += w.prepaint; acc.paint += w.paint; acc.commit += w.commit;
acc.propTreeCommit += w.propTreeCommit;
}
return acc;
}
}
/* ============================================================
* 3. 造页面:1 个容器 + 60 张卡片,每张卡片 9 个盒子
* ============================================================ */
function buildPage({ cards = 60, ownLayer = false } = {}) {
const root = new El('html');
const body = root.append(new El('body'));
const main = body.append(new El('main'));
const cardList = [];
for (let i = 0; i < cards; i++) {
const card = main.append(new El(`card${i}`, { ownLayer, positioned: true }));
card.classes.add('card');
const title = card.append(new El(`card${i}-title`));
const bodyEl = card.append(new El(`card${i}-body`));
const meta = card.append(new El(`card${i}-meta`));
meta.append(new El(`card${i}-avatar`));
meta.append(new El(`card${i}-name`));
meta.append(new El(`card${i}-time`));
title.append(new El(`card${i}-text`));
bodyEl.append(new El(`card${i}-excerpt`));
bodyEl.append(new El(`card${i}-tags`));
cardList.push(card);
}
return { root, body, main, cardList };
}
const CARD_NODES = 10; // card + title + body + meta + avatar + name + time + text + excerpt + tags
const TOTAL_NODES = (() => { const { root } = buildPage(); return root.subtree.length; })();
const EMPTY = { style: 0, layout: 0, prepaint: 0, paint: 0, commit: 0, propTreeCommit: 0 };
function table(title, rows) {
console.log(`\n### ${title}`);
const pad = (s, n) => String(s).padEnd(n, ' ');
console.log(pad('场景', 34) + pad('帧数', 6) + pad('style', 7) + pad('layout', 8)
+ pad('prepaint', 9) + pad('paint', 7) + pad('commit', 8) + '属性树commit');
for (const [name, w, frames] of rows) {
console.log(pad(name, 34) + pad(frames ?? 1, 6) + pad(w.style, 7) + pad(w.layout, 8)
+ pad(w.prepaint, 9) + pad(w.paint, 7) + pad(w.commit, 8) + (w.propTreeCommit ?? 0));
}
}
console.log(`页面节点总数: ${TOTAL_NODES}(1 html + 1 body + 1 main + 60 卡片 × ${CARD_NODES} 盒子)`);
/* ============================================================
* 4. 实验一:批处理 vs 强制同步布局
* ============================================================ */
{
// A. 批处理:60 次写,帧末统一 flush
const a = buildPage(); const ea = new Engine(a.root);
for (const c of a.cardList) ea.write(c, 'width', '300px');
ea.frame();
const batch = ea.total();
// B. 抖动:写一次读一次(经典的 offsetWidth / getBoundingClientRect 循环)
const b = buildPage(); const eb = new Engine(b.root);
for (const c of b.cardList) {
eb.write(c, 'width', '300px');
eb.readLayout(c); // ← 读操作把这一帧撕成 60 帧
}
const thrash = eb.total();
table('实验一:批处理 vs 逐条读写(60 张卡片改 width,累计工作量)', [
['A 批处理:60 写 + 帧末一次 flush', batch, ea.framesRun],
['B 抖动:60 次「写后立刻读」', thrash, eb.framesRun],
]);
console.log(` 批处理强制同步布局次数 = ${ea.forcedFlushes},累计 layout = ${batch.layout}`);
console.log(` 抖动强制同步布局次数 = ${eb.forcedFlushes},累计 layout = ${thrash.layout}`
+ `(是批处理的 ${(thrash.layout / batch.layout).toFixed(1)} 倍)`);
assert.equal(ea.forcedFlushes, 0, '批处理不应触发强制同步布局');
assert.equal(eb.forcedFlushes, 60, '逐条读写应触发 60 次强制同步布局');
assert.equal(ea.framesRun, 1, '批处理只跑一趟生命周期');
assert.equal(eb.framesRun, 60, '抖动把一帧撕成 60 趟');
assert.equal(thrash.layout, batch.layout * 60, '抖动每趟都重排整页,累计是批处理的 60 倍');
}
/* ============================================================
* 5. 实验二:同样是「变个颜色」,改 class 和改 style 谁便宜?
* ============================================================ */
{
const a = buildPage(); const ea = new Engine(a.root);
ea.write(a.cardList[0], 'background-color', 'red'); // 非继承属性,只失效自身
const inline = ea.frame();
const b = buildPage(); const eb = new Engine(b.root);
eb.toggleClass(b.cardList[0], 'active', true, { descendantSelector: false });
const classSelfOnly = eb.frame();
const c = buildPage(); const ec = new Engine(c.root);
ec.toggleClass(c.cardList[0], 'active', true, { descendantSelector: true });
const classDescendant = ec.frame();
const d = buildPage(); const ed = new Engine(d.root);
ed.write(d.cardList[0], 'color', 'red'); // 继承属性,后代也要重算样式
const inlineInherited = ed.frame();
table('实验二:一次「变颜色」的失效范围', [
['内联 style.background-color(非继承)', inline],
['class 切换,选择器只命中自身', classSelfOnly],
['class 切换,命中 .card .x 后代选择器', classDescendant],
['内联 style.color(继承属性)', inlineInherited],
]);
assert.equal(inline.layout, 0);
assert.equal(classSelfOnly.style, 1, '无后代选择器时 class 切换只失效自身');
assert.equal(classDescendant.style, CARD_NODES, '有后代选择器时 class 切换失效整个子树');
assert.ok(inlineInherited.style > inline.style, '继承属性要连带后代重算样式');
}
/* ============================================================
* 6. 实验三:transform / opacity 的代价取决于「有没有自己的层」
* ============================================================ */
{
const plain = buildPage({ ownLayer: false }); const e1 = new Engine(plain.root);
e1.write(plain.cardList[0], 'transform', 'translateX(4px)');
e1.frame(); const notLayered = e1.total();
const layered = buildPage({ ownLayer: true }); const e2 = new Engine(layered.root);
e2.write(layered.cardList[0], 'transform', 'translateX(4px)');
e2.frame(); const composited = e2.total();
const layeredOpacity = buildPage({ ownLayer: true }); const e3 = new Engine(layeredOpacity.root);
e3.write(layeredOpacity.cardList[0], 'opacity', '0.5');
e3.frame(); const compositedOpacity = e3.total();
// 合成器驱动:60 帧动画,主线程一帧都不参与
const raf = buildPage({ ownLayer: true }); const e4 = new Engine(raf.root);
for (let f = 0; f < 60; f++) e4.compositorAnim(raf.cardList[0], 'transform', `translateX(${f}px)`);
const compositorAnim = e4.total();
table('实验三:transform / opacity 的代价取决于「有没有自己的层」', [
['transform,元素无自己的合成层', notLayered, e1.framesRun],
['transform,元素已是合成层(JS 逐帧写)', composited, e2.framesRun],
['opacity,元素已是合成层(JS 逐帧写)', compositedOpacity, e3.framesRun],
['transform,已合成 + 合成器驱动 60 帧', compositorAnim, e4.framesRun],
]);
assert.equal(notLayered.layout, 0, 'transform 在任何情况下都不触发布局');
assert.equal(notLayered.paint, CARD_NODES, '未成层时 transform 仍要重绘整棵子树');
assert.equal(composited.layout, 0);
assert.equal(composited.paint, 0, '已合成时跳过绘制');
assert.equal(composited.commit, 0, '已合成时连 commit 都省了');
assert.equal(composited.style, 1, '内联样式确实变了,style 阶段仍要重算这一个元素');
assert.deepEqual(compositorAnim, { ...EMPTY });
assert.equal(composited.propTreeCommit, 1, '只需提交 1 个属性树节点');
}
/* ============================================================
* 7. 实验四:改 left 还是改 transform
* ============================================================ */
{
const a = buildPage(); const ea = new Engine(a.root);
for (const c of a.cardList) ea.write(c, 'left', '10px');
const animLeft = ea.frame();
const b = buildPage({ ownLayer: true }); const eb = new Engine(b.root);
for (const c of b.cardList) eb.write(c, 'transform', 'translateX(10px)');
const animTransform = eb.frame();
const c = buildPage(); const ec = new Engine(c.root);
ec.write(c.main, 'width', '1200px'); // 流内几何变化
const containerWidth = ec.frame();
table('实验四:三种「让卡片动起来」的做法(60 张卡片)', [
['动画 left(定位元素)', animLeft],
['动画 transform(各自成层)', animTransform],
['改容器 width(流内几何)', containerWidth],
]);
assert.equal(animLeft.layout, 60 * CARD_NODES, 'left 只失效自身子树,不动祖先与兄弟');
assert.ok(containerWidth.layout > animLeft.layout, '流内几何变化会波及祖先与兄弟子树');
assert.equal(animTransform.layout, 0);
assert.equal(animTransform.commit, 0);
assert.equal(animTransform.propTreeCommit, 60, '60 个元素的属性树节点各自提交一次');
}
/* ============================================================
* 8. 不变量汇总
* ============================================================ */
{
// 单调性:失效范围 flow ⊇ subtree ⊇ none
const order = { none: 0, subtree: 1, flow: 2 };
for (const [prop, t] of Object.entries(TRIGGERS)) {
assert.ok(order[t.layout] !== undefined, `${prop} 的 layout 取值非法`);
assert.ok(['none', 'self', 'subtree'].includes(t.paint), `${prop} 的 paint 取值非法`);
}
// 所有 transform/opacity/filter 都不得触发布局
for (const p of ['transform', 'opacity', 'filter']) assert.equal(TRIGGERS[p].layout, 'none');
// 所有几何属性都必须触发布局
for (const p of ['width', 'height', 'padding', 'margin', 'font-size', 'display']) {
assert.equal(TRIGGERS[p].layout, 'flow');
}
console.log('\n不变量断言全部通过(属性表取值合法 / 合成器属性不触发布局 / 几何属性必触发布局)');
}
2.2 合成层规划:先算显存,再决定提升谁
layer-planner.mjs
js
#!/usr/bin/env node
// layer-planner.mjs ------ 合成层规划器:谁会被提升、一层要多少显存、什么时候是灾难
// 零依赖,Node >= 16:node layer-planner.mjs
import assert from 'node:assert/strict';
/* ============================================================
* 0. 标定参数
* 合成器的绘制缓冲是分块的:一块 256×256 设备像素,RGBA 每像素 4 字节
* ============================================================ */
const TILE = 256;
const BYTES_PER_TILE = TILE * TILE * 4; // 262144 B = 256 KB
const VIEWPORT = { w: 1440, h: 900 }; // CSS px
const DPR = 2; // 设备像素比
const MOBILE_BUDGET = 32 * 1024 * 1024;
const DESKTOP_BUDGET = 128 * 1024 * 1024;
/** 一个矩形被提升为合成层后要占多少显存(按整块向上取整) */
function layerCost(rect, { dpr = DPR } = {}) {
const dw = rect.w * dpr, dh = rect.h * dpr;
const tx = Math.ceil(dw / TILE), ty = Math.ceil(dh / TILE);
const tiles = tx * ty;
return { tiles, bytes: tiles * BYTES_PER_TILE, device: { w: dw, h: dh } };
}
const mb = (b) => (b / 1024 / 1024).toFixed(1) + ' MB';
/* ============================================================
* 1. Blink 的合成原因(节选)------决定「谁会被提升」
* ============================================================ */
const COMPOSITING_REASONS = {
'will-change: transform / opacity': '开发者显式要求,最终解释权在浏览器',
'3D transform(translateZ / translate3d / rotate3d)': '元素进入 3D 渲染上下文,必须有自己的层',
'transform / opacity 动画进行中': '动画期间临时提升,动画结束自动收回(transition 也在内)',
'position: fixed': '相对视口定位,滚动时不能跟着重绘',
'overflow: scroll 的滚动内容': '滚动要在合成器线程上完成,天然独立成层',
'video / canvas / iframe': '外部合成源,内容不由 Blink 绘制',
'与已合成内容重叠的元素': 'overlap 原因------最反直觉的一条,会「传染」给邻居',
};
/* ============================================================
* 2. 场景
* ============================================================ */
const scenarios = [];
const add = (name, layers, note) => {
const cost = layers.reduce((a, l) => ({ tiles: a.tiles + l.tiles, bytes: a.bytes + l.bytes }),
{ tiles: 0, bytes: 0 });
scenarios.push({ name, layerCount: layers.length, ...cost, note });
};
// 基线:整页一张层,尺寸等于视口
const baseline = layerCost(VIEWPORT);
const CARD = { w: 360, h: 200 };
const LIST_ITEM = { w: 300, h: 60 };
const SCROLLER = { w: 1440, h: 4500 };
add('S1 基线:无任何显式提升', [baseline], '整页只有一张视口大小的层');
add('S2 正确做法:只有正在动的那个元素常驻 will-change',
[baseline, layerCost(CARD)], '层数 = 2');
add('S3 层爆炸:60 张卡片全部 will-change: transform',
[baseline, ...Array.from({ length: 60 }, () => layerCost(CARD))],
'卡片在屏幕外也照样占着显存');
add('S4 灾难:500 个列表项全部 will-change',
[baseline, ...Array.from({ length: 500 }, () => layerCost(LIST_ITEM))],
'长列表每条都加 will-change 是自杀');
add('S5 给比视口大 5 倍的滚动容器加 will-change: transform',
[baseline, layerCost(SCROLLER)], '层尺寸跟着内容走,不是跟着视口走');
console.log('### 合成层显存账(视口 1440×900 @DPR 2,tile 256×256×4B)\n');
const pad = (s, n) => String(s).padEnd(n, ' ');
console.log(pad('场景', 44) + pad('层数', 6) + pad('tile', 7) + pad('显存', 10)
+ pad('vs 基线', 10) + pad('移动端 32MB', 14) + '桌面端 128MB');
for (const s of scenarios) {
console.log(pad(s.name, 44) + pad(s.layerCount, 6) + pad(s.tiles, 7) + pad(mb(s.bytes), 10)
+ pad((s.bytes / baseline.bytes).toFixed(2) + '×', 10)
+ pad(s.bytes > MOBILE_BUDGET ? '超支' : 'OK', 14)
+ (s.bytes > DESKTOP_BUDGET ? '超支' : 'OK'));
}
console.log('\n注:合成器在光栅化期间可能同时持有 active / pending 两棵树,峰值显存可达上表的 2 倍。');
/* ============================================================
* 3. 单层成本拆解
* ============================================================ */
console.log('\n### 单层成本拆解\n');
for (const [name, rect] of [['视口', VIEWPORT], ['一张卡片 360×200', CARD],
['一个列表项 300×60', LIST_ITEM], ['滚动容器 1440×4500', SCROLLER]]) {
const c = layerCost(rect);
console.log(` ${pad(name, 22)} 设备像素 ${pad(`${c.device.w}×${c.device.h}`, 12)} `
+ `${pad(`${c.tiles} tile`, 10)} ${mb(c.bytes)}`);
}
/* ============================================================
* 4. 「动画期间临时提升」能省多少
* ============================================================ */
const peakAll = scenarios.find((s) => s.name.startsWith('S3')).bytes;
const peakOne = scenarios.find((s) => s.name.startsWith('S2')).bytes;
console.log(`\n### 把常驻 will-change 改成「动画期间临时加、结束就删」\n`);
console.log(` 60 张卡片常驻: ${mb(peakAll)}`);
console.log(` 同时只有 1 张在动: ${mb(peakOne)}`);
console.log(` 省下 ${(((peakAll - peakOne) / peakAll) * 100).toFixed(1)}%`);
/* ============================================================
* 5. 不变量
* ============================================================ */
{
// 层的显存只随设备像素增长,与 CSS 像素无关
const a = layerCost({ w: 100, h: 100 });
assert.equal(a.tiles, Math.ceil(200 / TILE) ** 2);
// DPR 翻倍 → 设备像素面积 4 倍 → tile 数约为 4 倍
const b = layerCost({ w: 1440, h: 900 });
const c = layerCost({ w: 1440, h: 900 }, { dpr: 1 });
assert.ok(b.tiles > c.tiles * 3.5, 'DPR 2 的层开销应接近 DPR 1 的 4 倍');
// 显式提升一定比同尺寸的整页层更省
assert.ok(layerCost(CARD).bytes < baseline.bytes);
// S3 桌面端勉强能扛、移动端必然超支------这正是「本机测不出问题」的根源
assert.ok(peakAll > MOBILE_BUDGET, '60 张常驻 will-change 的卡片应当超出移动端预算');
assert.ok(peakOne < MOBILE_BUDGET, '只提升 1 张卡片时应当在移动端预算内');
assert.ok(scenarios.find((s) => s.name.startsWith('S4')).bytes > DESKTOP_BUDGET,
'500 个列表项常驻 will-change 应当连桌面预算都超');
assert.ok(layerCost(SCROLLER).bytes > layerCost(VIEWPORT).bytes * 4,
'滚动容器的层尺寸应随内容增长');
// tile 一定是整数块
for (const s of scenarios) assert.ok(Number.isInteger(s.tiles) && s.tiles > 0);
console.log('\n不变量断言全部通过(DPR 平方关系 / 单卡成本 < 整页 / S3 超支 S2 达标 / 层尺寸随内容增长)');
}
2.3 换算成帧时间:把工作量乘上单价
frame-budget.mjs
js
#!/usr/bin/env node
// frame-budget.mjs ------ 把「工作量」乘上「单价」,换算成帧时间与掉帧数
// 工作量取自 lifecycle-sim.mjs 的实跑输出,单价是数量级标定值
// 零依赖,Node >= 16:node frame-budget.mjs
import assert from 'node:assert/strict';
/* ============================================================
* 0. 单价表
* 这些不是某台机器的实测值,而是量级标定:目的是让不同策略落在同一坐标系里
* 比较,结论(谁比谁贵一到两个数量级)与具体机器无关。
* ============================================================ */
const PRICE = {
style: 0.004, // ms / 节点 ------ 主线程
layout: 0.015, // ms / 盒子 ------ 主线程
prepaint: 0.002, // ms / fragment ------ 主线程
paint: 0.020, // ms / 节点 ------ 主线程
commit: 0.050, // ms / 层(全量) ------ 主线程
propTreeCommit: 0.002, // ms / 属性树节点 ------ 主线程(合成层上的 transform/opacity 只走这条)
rasterTile: 0.080, // ms / tile ------ 合成器侧,可多线程
draw: 0.002, // ms / quad ------ 合成器侧
};
const RASTER_THREADS = 4;
const BUDGET_60 = 1000 / 60; // 16.67 ms
const BUDGET_120 = 1000 / 120; // 8.33 ms
/** 主线程被迫干完才能交帧的部分 */
function mainThreadMs(w) {
return w.style * PRICE.style
+ w.layout * PRICE.layout
+ w.prepaint * PRICE.prepaint
+ w.paint * PRICE.paint
+ w.commit * PRICE.commit
+ (w.propTreeCommit ?? 0) * PRICE.propTreeCommit;
}
/** 合成器侧的部分:与主线程并行,但有自己的一条关键路径 */
function compositorMs(w) {
return (w.rasterTiles * PRICE.rasterTile) / RASTER_THREADS + w.quads * PRICE.draw;
}
/** 一条帧的关键路径 = 两条并行流水线里更慢的那条 */
function criticalPathMs(w) { return Math.max(mainThreadMs(w), compositorMs(w)); }
/* ============================================================
* 1. 六种策略(60 张卡片同时动,每帧的工作量)
* 工作量数值来自 lifecycle-sim.mjs / layer-planner.mjs 的实跑输出
* ============================================================ */
const STRATEGIES = [
{ name: 'A 动画 left(定位元素,无合成层)',
work: { style: 60, layout: 600, prepaint: 600, paint: 600, commit: 1, rasterTiles: 96, quads: 1 } },
{ name: 'B 动画 transform(无合成层)',
work: { style: 60, layout: 0, prepaint: 0, paint: 600, commit: 1, rasterTiles: 96, quads: 1 } },
{ name: 'C 动画 transform(已合成,JS 逐帧写 style)',
work: { style: 60, layout: 0, prepaint: 0, paint: 0, commit: 0, propTreeCommit: 60, rasterTiles: 0, quads: 2 } },
{ name: 'D 动画 transform(CSS Animation / WAAPI,合成器驱动)',
work: { style: 0, layout: 0, prepaint: 0, paint: 0, commit: 0, propTreeCommit: 0, rasterTiles: 0, quads: 2 } },
{ name: 'E 500 个元素常驻 will-change 的首帧',
work: { style: 500, layout: 0, prepaint: 0, paint: 0, commit: 500, rasterTiles: 1500, quads: 500 } },
{ name: 'F A 的抖动版(60 次强制同步布局,累计)',
work: { style: 60, layout: 36180, prepaint: 36180, paint: 600, commit: 60, rasterTiles: 96, quads: 1 } },
];
console.log('### 单价表(ms / 单位)\n');
console.log(' 主线程: ' + ['style', 'layout', 'prepaint', 'paint', 'commit', 'propTreeCommit']
.map((k) => `${k}=${PRICE[k]}`).join(' '));
console.log(' 合成器: ' + ['rasterTile', 'draw'].map((k) => `${k}=${PRICE[k]}`).join(' ')
+ ` raster 并发=${RASTER_THREADS}`);
console.log('\n### 每帧时间与有效帧率(60 张卡片同时动画,60Hz 预算 16.67ms)\n');
const pad = (s, n) => String(s).padEnd(n, ' ');
const fmt = (v) => (v < 0.01 ? v.toFixed(3) : v.toFixed(2));
console.log(pad('策略', 46) + pad('主线程 ms', 11) + pad('合成器 ms', 11)
+ pad('关键路径', 10) + pad('占用率', 9) + pad('有效 fps', 10) + '掉帧/60帧');
const results = [];
for (const s of STRATEGIES) {
const main = mainThreadMs(s.work);
const comp = compositorMs(s.work);
const path = criticalPathMs(s.work);
const busy = (main / BUDGET_60) * 100; // 占用率看主线程:它决定交互是否卡
const fps = Math.min(60, 1000 / Math.max(path, 0.001));
const dropped = Math.max(0, Math.round((path * 60) / BUDGET_60) - 60);
results.push({ ...s, main, comp, path, busy, fps, dropped });
console.log(pad(s.name, 46) + pad(fmt(main), 11) + pad(fmt(comp), 11)
+ pad(fmt(path), 10) + pad(busy.toFixed(0) + '%', 9)
+ pad(fps.toFixed(1), 10) + dropped);
}
/* ============================================================
* 2. 拆解:钱花在哪一步
* ============================================================ */
console.log('\n### A 与 B 的成本拆解(钱到底花在哪个阶段)\n');
for (const r of [results[0], results[1]]) {
const w = r.work;
const parts = [
['style', w.style * PRICE.style],
['layout', w.layout * PRICE.layout],
['prepaint', w.prepaint * PRICE.prepaint],
['paint', w.paint * PRICE.paint],
['commit', w.commit * PRICE.commit],
['raster', (w.rasterTiles * PRICE.rasterTile) / RASTER_THREADS],
['draw', w.quads * PRICE.draw],
].filter(([, v]) => v > 0);
const total = parts.reduce((a, [, v]) => a + v, 0);
console.log(` ${r.name}(合计 ${total.toFixed(2)} ms)`);
for (const [k, v] of parts) {
const bar = '█'.repeat(Math.max(1, Math.round((v / total) * 40)));
console.log(` ${pad(k, 10)} ${pad(v.toFixed(2) + ' ms', 10)} `
+ `${pad(((v / total) * 100).toFixed(0) + '%', 5)} ${bar}`);
}
}
/* ============================================================
* 3. 120Hz 下同一个页面的处境
* ============================================================ */
console.log('\n### 换成 120Hz(预算 8.33ms),关键路径必须再砍一半\n');
for (const r of results.slice(0, 4)) {
const slack = BUDGET_120 - r.path;
console.log(` ${pad(r.name, 46)} ${pad(fmt(r.path) + ' ms', 10)} `
+ `${r.path > BUDGET_120 ? '超预算' : '达标'}(余量 ${slack.toFixed(2)} ms)`);
}
/* ============================================================
* 4. 不变量
* ============================================================ */
{
const [A, B, C, D, E, F] = results;
assert.ok(A.path > BUDGET_60, '动画 left 每帧必须超预算,否则这个例子不成立');
assert.ok(B.path < BUDGET_60, '去掉布局后应当能在 60Hz 下勉强跑完');
assert.ok(C.main < 1, '已合成 + transform 的每帧主线程成本应当小于 1ms');
assert.equal(D.main, 0, '合成器驱动动画的主线程成本为 0');
assert.ok(A.main / C.main > 50, 'left 与「合成层 + transform」的差距应当接近两个数量级');
assert.ok(C.main > 0 && C.main < 1, 'C 仍需为每个变化元素提交一个属性树节点,但总量不到 1ms');
assert.ok(F.path > 10 * A.path, '抖动的代价应当比同一动作的批处理版高一个数量级以上');
assert.ok(A.busy > 100, '主线程占用超过 100%,这一帧必然掉');
assert.ok(C.busy < 5 && D.busy === 0);
assert.ok(E.comp > E.main, '层爆炸首先压垮的是合成器侧的光栅化');
assert.equal(D.fps, 60);
// 单调性
assert.equal(criticalPathMs({ style: 0, layout: 0, prepaint: 0, paint: 0, commit: 0, rasterTiles: 0, quads: 0 }), 0);
assert.ok(criticalPathMs({ style: 1e4, layout: 0, prepaint: 0, paint: 0, commit: 0, rasterTiles: 0, quads: 0 }) > 0);
console.log('\n不变量断言全部通过(A 超预算 / C 主线程 <1ms / D=0 / A:C>50× / F>10×A / E 卡在光栅化)');
console.log('\n一句话结论:同一件「让 60 张卡片动起来」的事------');
console.log(` 动画 left ${A.path.toFixed(1)}ms(掉 21 帧)`
+ ` → 动画 transform ${B.path.toFixed(1)}ms`
+ ` → 加上合成层 ${C.path.toFixed(2)}ms(快 ${(A.path / C.path).toFixed(0)} 倍)`
+ ` → 交给合成器驱动:主线程 ${D.main.toFixed(0)}ms。`);
}
2.4 "改 class 比改 style 便宜"有一半是错的
这句流传很广的经验,Day24 结尾预告过要拆它。实测数据摆在这里:
| 做法 | style 节点数 | paint 节点数 | 结论 |
|---|---|---|---|
el.style.backgroundColor = 'red' |
1 | 1 | 最便宜 |
el.classList.toggle('active'),选择器只命中自身 |
1 | 10 | 与内联等价 |
el.classList.toggle('active'),命中 .card .x 后代选择器 |
10 | 10 | 比内联贵 10 倍 |
el.style.color = 'red'(继承属性) |
10 | 1 | 和内联写颜色一样贵 |
结论分两半:
- 对的那一半:改变 class 只改一个属性,选择器重匹配的成本被 InvalidationSet 摊薄了;而且样式集中在 CSS 里,可维护性和复用性远好于内联。
- 错的那一半 :代价从来不是由"用 class 还是用 style"决定的,而是由"改了哪个属性、这个属性的失效范围有多大"决定的。 内联写
color会因为继承把后代全部拖下水,和写 class 一样贵;命中后代选择器的 class 切换会失效整个子树,比内联写background-color贵 10 倍。
所以正确的判据不是"class 还是 style",而是:这次改动的最小失效范围是什么?我实际触发的是不是恰好等于它? 内联 style 的真正优势不是"便宜",而是失效范围可预测------你能一眼看出它只影响这一个元素;而 class 的失效范围取决于选择器长什么样,需要你去查。
另外两个真正廉价的技巧,与 class/内联无关:
- 改 CSS 自定义属性会失效整棵子树 (继承属性 + 变量参与计算),但配合
@property声明类型后,浏览器可以在合成器上补间它。这是近年"用 CSS 变量做动画"的实现基础。 transform/opacity的中间帧不经过 style 阶段 (合成器直接补间),而其它属性的动画每一帧都要完整走一遍 style recalc。这才是"动画只用 transform/opacity"这条规则的真正理由。
2.5 十二条治理清单
| # | 规则 | 依据 |
|---|---|---|
| 1 | 动画只用 transform / opacity / filter |
只有这三个能在合成器上补间(难点七) |
| 2 | 动画元素才加 will-change,动画结束就删 |
S3 vs S2:114MB → 25.5MB(2.2 节) |
| 3 | 禁止在长列表每一项上常驻 will-change |
S4:399MB,连桌面预算都超(2.2 节) |
| 4 | 一帧内先集中读、再集中写,中间不交叉 | 实测 60 倍差距(难点五) |
| 5 | 布局读取放进 rAF 开头或 ResizeObserver 回调 |
rAF 在 style 之前,是唯一的写窗口(难点七) |
| 6 | 用 classList 切换样式,但避开深层后代选择器 |
命中后代选择器 = 失效整棵子树(2.4 节) |
| 7 | 大范围改色用 CSS 变量,不用继承属性批量写内联 | color 是继承属性,实测 1 → 10(2.4 节) |
| 8 | 给独立卡片/列表项加 contain: layout paint |
把失效范围切成局部(难点六) |
| 9 | 长文档用 content-visibility: auto + contain-intrinsic-size |
屏外内容整体跳过(难点六) |
| 10 | 记住 will-change / filter / contain: paint 会关住 fixed 子元素 |
三个都会创建包含块(难点四) |
| 11 | 层尺寸跟着内容走,别提升比视口大很多的容器 | S5:132MB(2.2 节) |
| 12 | 移动端按 32MB 显存预算验,不要只在本机桌面验 | S3 桌面 OK、移动超支 3.5×(2.2 节) |
三、应用场景
场景一:长列表 + 悬浮动画 → 层爆炸
症状 :列表有 200 项,每项 hover 时上浮 2px,给每项都写了 will-change: transform。列表一长,滚动开始掉帧,手机上尤其明显。
定位 :DevTools → Rendering → 勾选 Layer borders ,会看到满屏黄色边框------每一项都是独立合成层。再勾 Frame rendering stats 看显存。
修法 :把常驻提示改成动画期间临时加、结束就删(难点七的 slideIn 写法),或者干脆用纯 CSS transition + :hover,让浏览器自己管理层生命周期。
css
.list-item {
transition: transform 160ms cubic-bezier(.2,.8,.2,1);
}
.list-item:hover { transform: translateY(-2px); }
浏览器只在 :hover 生效期间为这个元素提升层,移开就回收。你不需要手写 will-change。
场景二:拖拽排序 → pointermove 里读布局
症状:拖拽列表项,手感发黏,帧率掉到 20 上下。
定位 :Performance 面板录一段拖拽,看到 Layout 切片密集且每个都不小,中间夹着一堆 Recalculate Style------典型的每帧多次强制同步布局。
根因 :pointermove 回调里读 other.getBoundingClientRect() 来判断谁该让位。每次移动都要 flush 一次,而且此时队列里还堆着上一帧的写(难点五)。
修法 :把几何信息预先缓存,并放进 rAF。
js
// ✅ 拖拽前量一次,移动过程中只读缓存
function beginDrag(list) {
const rects = list.map((el) => el.getBoundingClientRect()); // 一次性读
let pending = null;
const onMove = (e) => {
pending = { x: e.clientX, y: e.clientY }; // 只记数据,不碰 DOM
requestAnimationFrame(apply);
};
const apply = () => {
if (!pending) return;
const idx = hitTest(rects, pending); // 用缓存算,零布局读取
if (idx !== current) { current = idx; paintIndicator(idx); }
pending = null; // 这一帧只写一次
};
addEventListener('pointermove', onMove);
}
场景三:主题切换 → 大面积继承属性失效
症状 :点"深色模式",整站卡顿 200~400ms,color、background-color 全站变一遍。
根因 :切换方式是在 :root 上换 class,触发全站样式重算;如果主题色散落在几百条规则里,InvalidationSet 规模巨大(难点三)。更糟的是有人用 JS 遍历元素逐个写内联 color------color 是继承属性,每写一个元素,它的整棵后代都要重算样式(实测 style 从 1 变 10)。
修法 :把颜色收敛成 CSS 自定义属性,全站只改 :root 上的几个变量值。
css
:root { --bg: #fff; --fg: #1a1a1a; --accent: #2563eb; }
:root[data-theme='dark'] { --bg: #0b0b0c; --fg: #e8e8ea; --accent: #60a5fa; }
body { background: var(--bg); color: var(--fg); }
改 CSS 变量确实仍会失效整棵子树(变量可继承),但失效的规模从"几百条规则各自重算"降到"一棵子树按变量求值" ------这一步本文的脚本没有单独量化,但原理上求值远比逐条规则匹配便宜,实际收益取决于你的主题色散落程度。如果切换时还要配过渡,记得只过渡 background-color、color 这类不成层的属性会逐帧走 style recalc------想要顺滑就用 transform/opacity 做一次遮罩式的过渡,别去补间全站颜色。
场景四:抽屉动画 → left 换成 transform
症状 :侧边抽屉从右侧滑入,transition: left .3s,低端机上手感顿。
根因 :left 是几何属性,每帧都要走完整布局 + 重绘。实测动画 left 每帧关键路径 22.49ms(超 16.67ms 预算),掉帧 21/60(2.3 节)。
修法:
css
.drawer {
transform: translateX(100%);
transition: transform 280ms cubic-bezier(.2,.8,.2,1);
will-change: transform; /* 只在这个元素上,动画期短暂持有 */
}
.drawer[data-open='true'] { transform: translateX(0); }
同一件事的关键路径从 22.49ms 降到 0.36ms (JS 逐帧写)甚至 0.004ms(交给合成器补间),快 62~5600 倍。
但要注意难点四那个坑 :给抽屉加了 transform 或 will-change 之后,抽屉内部任何 position: fixed 的浮层都会被关在抽屉里。修法是把这个浮层挪到抽屉外面(用 <dialog> 或 portal),或者给它单独的层。
场景五:折叠面板 → 别用 height 补间
症状 :手风琴展开动画,transition: height 从 0 到 auto,抖动并且内容裁切异常。
根因 :height 是流内几何属性,失效范围是"自己 + 后代 + 祖先 + 兄弟"------展开一个面板等于让整页重排(实测改一次 width,603 个节点全中,难点三)。
修法(按推荐度排序):
css
/* 1. 现代方案:补间 grid-template-rows,浏览器可优化,且高度自适应 */
.accordion {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 240ms ease;
}
.accordion[data-open='true'] { grid-template-rows: 1fr; }
.accordion > .inner { overflow: hidden; min-height: 0; }
/* 2. 兜底方案:JS 量一次高度写成固定值,再补间 height(仍然是布局动画,但少算很多) */
如果内容多、对流畅度要求高,最彻底的做法是只动画 transform: scaleY() 再加一次 clip-path,把整个过程搬进合成器------代价是文字会被拉伸,需要在内部再反向缩放回来,可读性会打折,属于"要极致流畅"时的取舍。
场景六:仪表盘看板 → 屏外内容白算
症状:一个长看板,首屏 8 个图表卡片,往下还有 40 个。首屏渲染要 1.2s,滚动也顿。
根因:40 个屏外卡片照样走完了 style / layout / paint 全流程(难点二),工作量白花。
修法:
css
.dashboard-card {
content-visibility: auto;
contain-intrinsic-size: auto 320px; /* 必须给,否则滚动条乱跳 */
contain: layout paint; /* 卡片内部失效不外溢 */
}
再配合三件事:图表初始化挪到 IntersectionObserver 回调里(别在 DOMContentLoaded 里一次建 48 个实例);图表容器用 contain: strict 固定尺寸;列表本身做虚拟滚动。
四、总结
渲染管线的完整解法是三件武器加一条纪律:属性失效表 (把"改一下会怎样"变成可查的表)→ 读写分离 (一帧内集中读、集中写)→ 层生命周期管理(把动画挪到合成器那条流水线上)→ 而它们共同遵守的纪律是**"这次改动的最小失效范围是什么,我实际触发了多大"**。
七个最值得记住的判据:
- 帧时间看的是 max(主线程, 合成器),不是主线程。 实测 500 个元素常驻
will-change时,主线程 27ms、合成器 31ms------瓶颈已经换边了,按老经验优化主线程是白费力气。 - "transform 是合成器属性"有个前提:元素得先有自己的层。 没有层时它不触发布局(layout 恒为 0),但照样重绘整棵子树(实测 paint 10)。
- Paint 不产生像素,Raster 才产生,而 Raster 在另一条流水线上。 这是"改 transform 能不掉帧"唯一的解释。
- 一次强制同步布局就能吃掉整帧的红利。 实测 60 次"写后立刻读"让累计布局工作量从 603 变成 36180(60 倍),主线程 630ms。
will-change的代价按设备像素算,DPR 2 就是 4 倍。 60 张常驻卡片 = 114MB,500 条 = 399MB;而且桌面端"OK"、移动端超支 3.5 倍------这就是"本机测不出问题"的根源。- 代价不由"class 还是 style"决定,而由"改了哪个属性、失效范围多大"决定。 内联写继承属性
color实测和内联写 class 一样贵(10 个节点);命中后代选择器的 class 切换比内联写background-color贵 10 倍。 will-change、filter、contain: paint都会创建包含块,把fixed子元素关住。 这是加优化时最常见、也最难查的副作用。
架构上收敛成一句话:渲染性能优化的本质是把工作分级------能交给合成器的绝不留给主线程,能批处理的绝不逐条 flush,能限制在子树内的绝不外溢到整页。 这三条分别对应本文的层与流水线、脏标记与强制同步布局、失效范围与 containment。而 Day23 讲过的框架层粒度与调度,本质上是在同一坐标系里做同一件事------减少必须重算的东西。
下一篇 Day26 进入加载性能:从浏览器视角看一个页面从 TCP 握手到首屏可交互要走完哪些步骤,以及"首屏时间"这个数字到底该由谁来定义。
运行环境
Node.js ≥ 16(三份脚本零依赖、可直接运行,均带不变量断言,断言失败即 exit 1):
lifecycle-sim.mjs:脏标记传播 + 五阶段工作量记账 + 批处理 vs 强制同步布局 + class/style 失效范围对照 + 合成层与否的代价对照layer-planner.mjs:合成原因表 + 层的 tile/显存核算 + 五种场景的预算超支判定 + "动画期临时提升"的收益frame-budget.mjs:工作量 × 单价 → 主线程/合成器关键路径、有效帧率、掉帧数;并给出 120Hz 预算下的对比
三份脚本的模型是确定性简化模型:统计的是"需要重新计算的节点/盒子/层数量",单价表是量级标定而非某台机器的实测值。它的价值在于让不同策略落在同一坐标系里比较------结论(谁比谁贵一到两个数量级)与具体硬件无关。
浏览器侧示例需 ES2020+(可选链、Element.animate、getAnimations、content-visibility,Chrome/Edge 85+、Safari 15.4+ 支持 content-visibility)。