渲染管线

一、技术难点

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

逐步说清楚每一步到底产出什么,因为**"能不能跳过"取决于"这一步的输入脏不脏",而不是"这一步看起来重要不重要"**:

  1. Style(样式重算):算出每个元素的 computed style。输入是"哪些元素的样式脏了"。Blink 不重新匹配全部选择器,而是用 InvalidationSet 记录"改这个 class 会影响哪些选择器",把失效范围压到最小。
  2. Layout(布局):从布局根往下重算盒子几何,产出 fragment tree。LayoutNG 之后这是一棵不可变树,"重排"实际上是重建局部子树。
  3. Pre-paint(预绘制) :最没有存在感、也最被低估的一步。它遍历树,更新 paint property tree (transform / clip / effect / scroll 四棵树),决定"哪些元素需要重绘",并生成 paint invalidation。你在 DevTools 里看到的那个薄薄的 Pre-Paint 切片,就是它。
  4. Paint(绘制) :产出 display item list ------一份"画什么、按什么顺序画"的清单。注意:这一步不产生任何像素。
  5. Commit(提交):把属性树 + 显示列表打包交给合成器线程。整个渲染管线里唯一一次跨线程交接,也是为什么"布局一发生,所有合成层的属性树都要重新提交"。
  6. Layerize → Raster(分层与光栅化) :把显示列表切成 256×256 的设备像素 tile,在 raster worker 上并行 光栅化成位图。像素是在这一步产生的,而这一步在另一条流水线上------这就是"改 transform 能不掉帧"的全部秘密。
  7. 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 固定尺寸;列表本身做虚拟滚动。


四、总结

渲染管线的完整解法是三件武器加一条纪律:属性失效表 (把"改一下会怎样"变成可查的表)→ 读写分离 (一帧内集中读、集中写)→ 层生命周期管理(把动画挪到合成器那条流水线上)→ 而它们共同遵守的纪律是**"这次改动的最小失效范围是什么,我实际触发了多大"**。

七个最值得记住的判据:

  1. 帧时间看的是 max(主线程, 合成器),不是主线程。 实测 500 个元素常驻 will-change 时,主线程 27ms、合成器 31ms------瓶颈已经换边了,按老经验优化主线程是白费力气。
  2. "transform 是合成器属性"有个前提:元素得先有自己的层。 没有层时它不触发布局(layout 恒为 0),但照样重绘整棵子树(实测 paint 10)。
  3. Paint 不产生像素,Raster 才产生,而 Raster 在另一条流水线上。 这是"改 transform 能不掉帧"唯一的解释。
  4. 一次强制同步布局就能吃掉整帧的红利。 实测 60 次"写后立刻读"让累计布局工作量从 603 变成 36180(60 倍),主线程 630ms。
  5. will-change 的代价按设备像素算,DPR 2 就是 4 倍。 60 张常驻卡片 = 114MB,500 条 = 399MB;而且桌面端"OK"、移动端超支 3.5 倍------这就是"本机测不出问题"的根源。
  6. 代价不由"class 还是 style"决定,而由"改了哪个属性、失效范围多大"决定。 内联写继承属性 color 实测和内联写 class 一样贵(10 个节点);命中后代选择器的 class 切换比内联写 background-color 贵 10 倍。
  7. 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)。

相关推荐
程序员大阳1 小时前
副队长CSS教程(33)–圆角与透明
前端·css·透明·圆角
志尊宝1 小时前
Vue3 零基础每日笔记(082):文件上传实战——el-upload 校验、自定义上传、头像预览
前端·javascript·vue.js·笔记·#javascript·#vue.js
鬼手点金2 小时前
opencode-rules 插件
java·开发语言·前端·人工智能·chrome·git·github
柳杉2 小时前
借助 Codex 和可视化工作流,从调研到完整实现,3D 大屏竟然可以这么简单
前端·ai编程·数据可视化
IMPYLH2 小时前
HTML 的 <wbr> 元素
前端·html
Dovis(誓平步青云)3 小时前
常用命令藏得太深?怎么做一个快捷命令坞
运维·前端·自动化·音视频·prometheus
IT_陈寒3 小时前
Vite的这个问题坑了我三天,原来如此解决!
前端·人工智能·后端
网易云信3 小时前
大模型能上岗做销售吗?我们让 12 款国产模型交了 420 份作业
前端·后端
蜗牛互联网4 小时前
Python Agent工具调用的白名单、参数校验与审批门禁
java·服务器·前端·人工智能·后端·python