陌上人如玉
公子世无双

5.17 动画性能与合成层

5.17 动画性能与合成层

一句话:动画流畅的本质是「让浏览器只在『合成(Composite)』阶段工作,而不是『布局(Layout)』或『绘制(Paint)』阶段」——即「只改 transform 与 opacity」,并用 will-change/合成层把动画从主线程搬到合成线程,代价是「留给显存与合成开销」(层太多会翻车)。

一、渲染管线:四个阶段与「属性 → 阶段」对照

⭐⭐⭐ 浏览器渲染一帧要经过四个阶段:

   ① Style(样式计算)→ ② Layout(布局/重排)→ ③ Paint(绘制/重绘)→ ④ Composite(合成)

   后面的阶段越"贵":
   · Layout   —— 重新计算所有受影响元素的几何位置(可能触发整棵树)
   · Paint    —— 重新生成像素(光栅化)
   · Composite—— 把「已有的层」按顺序拼起来(GPU 做,最便宜)
属性 触发阶段 说明
width/height/padding/margin/border-width Layout 几何属性变化 → 重排
top/left/right/bottom Layout ⭐ 用 transform: translate 替代
font-size/line-height Layout 文字排版变化
display/position/float Layout 结构变化
color/background-color Paint 只重绘
box-shadow/border-radius/outline Paint ⚠️ box-shadow 的重绘尤其贵
visibility Paint
transform Composite ⭐ 首选(但要满足「独立层」条件)
opacity Composite ⭐ 首选(同上)
filter/backdrop-filter Composite(有自己的层) ⚠️ 但它们本身的开销很大(模糊/卷积)
⭐⭐ 两个「反直觉」的重要事实:

① 【`transform`/`opacity` 并不总是「只走合成」】
   如果元素**没有**被提升到独立的合成层,
   改 `transform` 仍可能触发「重绘」(只是不触发 Layout)。
   ✅ 所以「**让元素成为独立层**」(`will-change` / `translateZ(0)`)才是关键,
      这就是「为什么『加 `transform: translateZ(0)`』这个老技巧有效」。

② 【`top/left` 与 `transform` 的差别是「量级」的】
   `left: 0 → left: 100px`:触发 Layout(可能整棵树重排)
   `transform: translateX(100px)`:只影响这个元素自己的合成结果
   ⭐ 差距在复杂页面里可能是「**几毫秒 vs 几十毫秒**」。
⭐⭐ 四种「一帧内的正确顺序」(避免布局抖动):

   ❌ 【布局抖动(layout thrashing)】
      for (let i = 0; i < 100; i++) {
        el.style.height = el.offsetHeight + 1 + 'px';   // 读 → 写 → 读 → 写 …
      }
      → 每次「读 offsetHeight」都会强制浏览器「立刻重排」(同步布局),
        100 次循环 = 100 次重排。

   ✅ 【先读后写】
      const h = el.offsetHeight;        // 一次性读
      el.style.height = h + 1 + 'px';   // 一次性写
      → 只触发一次重排。

   ⭐ 这就是「**批量读、批量写**」原则(也叫「读写分离」)。

二、合成层的创建条件与代价

⭐⭐ 元素被「提升为独立合成层(composited layer)」的常见触发条件(Chromium):

① `will-change: transform` / `will-change: opacity`
② `transform: translateZ(0)` / `translate3d(...)`(3D 变换)
③ `position: fixed`(多数情况会独立成层)
④ `<video>` / `<canvas>` / `<iframe>`
⑤ `filter` / `backdrop-filter`(有值的时候)
⑥ `opacity` 动画/过渡「进行中」(浏览器会临时提升)
⑦ 与「已有层的元素」重叠且需要独立重绘时(启发式规则,不可控)

⭐ 记忆点:「**3D 变换、will-change、fixed、video/canvas、filter**」是主要入口。
⭐⭐⭐ 「层爆炸」的代价(这是 will-change 被滥用后的主要问题):

① 【显存占用】
   一个层的「纹理」大致是「宽 × 高 × 4 字节」:
   · 1920×1080 ≈ 8MB
   · 加 mipmap/额外缓冲可能翻倍
   → ⭐ 手机端 GPU 显存有限(尤其低端机),
     层数多了会「掉帧、闪白、甚至崩溃」。

② 【合成开销】
   层数越多,GPU 合成(以及「层顺序管理」)越贵。

③ 【首帧更慢】
   提升层的动作本身有成本(创建纹理、上传)。

⭐⭐ 判定方法(DevTools):
   · **Layers 面板**:直接列出「所有合成层的数量、每层内存占用、`
     Compositing Reasons`(为什么被提升)」
   · **Performance 面板**:看 `Composite Layers` 事件与 `GPU` 占用
   · 经验判据:**一个页面「层数量 > 20~30」或「层内存 > 几十 MB」就要警惕**
/* ⭐ will-change 的正确用法(三条规则) */

/* ① 「提前加」:在动画「开始之前」就加好(否则提升动作本身会造成第一帧卡顿) */
.card:hover { will-change: transform; }            /* ❌ 太晚 —— hover 那一刻才提升 */
.card-list:hover .card { will-change: transform; } /* ✅ hover 容器时就给子元素加好 */

/* ② 「用完就移除」:动画结束就移除,否则层一直占着显存 */
/* ③ 「不要写在常驻样式里」 */
.bad { will-change: transform; }                   /* ❌ 所有元素永远都是层 → 层爆炸 */
// ✅ 「动画期间临时加 will-change」的封装(这是最实用的用法)
function animateOnce(el: HTMLElement, keyframes: Keyframe[], options: KeyframeAnimationOptions) {
  // ① 动画开始前加上(让浏览器准备好层)
  el.style.willChange = 'transform, opacity';

  const animation = el.animate(keyframes, { duration: 300, fill: 'both', ...options });

  // ② 动画结束后移除(释放显存)
  const cleanup = () => { el.style.willChange = 'auto'; };
  animation.finished.then(cleanup, cleanup);

  return animation;
}

三、把动画「搬到合成线程」:为什么 CSS/WAAPI 更流畅

⭐⭐⭐ 两种动画路径的本质区别:

① 【主线程动画】(JS 逐帧改样式 / 涉及布局属性的 CSS 动画)
   主线程 → 每帧计算新样式 → 布局 → 绘制 → 合成
   ⚠️ 只要主线程「忙」(长任务、大量 JS),
      动画就**直接掉帧**(因为它抢不到主线程)。

② 【合成线程动画】(只改 transform/opacity 的 CSS animation/transition,或 WAAPI)
   主线程(只在开始/结束时参与)→ 合成线程**每帧自己插值**
   ⭐ 所以「**主线程被长任务占住时,动画依然流畅**」——
      这是「为什么用 CSS transform 做动画比用 JS 改 left 更流畅」的根本原因。

⭐ 结论(实践优先级):
   ① 用 **CSS transition/animation** + 只改 `transform`/`opacity`(最省心,自动走合成)
   ② 需要「JS 控制时序/中断/复杂编排」→ 用 **Web Animations API**(`el.animate()`)
      (对可合成属性,它也能跑在合成线程)
   ③ 只有「真的需要每帧读值/复杂逻辑」→ 才用 `requestAnimationFrame` +
      只改 `transform`/`opacity`
   ❌ 最差:`setInterval`/`scroll` 事件里直接改 `left`/`top`/`width`
// ✅ WAAPI:可被中断、可暂停、可反向、可拿进度
const anim = el.animate(
  [
    { transform: 'translateY(8px)', opacity: 0 },
    { transform: 'translateY(0)', opacity: 1 },
  ],
  { duration: 240, easing: 'cubic-bezier(.2,.8,.2,1)', fill: 'both' }
);

anim.pause();
anim.currentTime = 100;            // ⭐ 精确定位到某个时刻
anim.reverse();
await anim.finished;               // ⭐ 可 await(用于编排多个动画)
// ✅ rAF 的正确用法(只改 transform/opacity,且「读写分离」)
function startDrag(el: HTMLElement) {
  let x = 0;
  let queued = false;
  let rafId = 0;

  const flush = () => {
    queued = false;
    el.style.transform = `translateX(${x}px)`;      // ⭐ 只改 transform
  };

  const onMove = (e: PointerEvent) => {
    x = e.clientX;
    // ⭐ 每帧最多写一次(避免「一帧内多次写样式」)
    if (!queued) {
      queued = true;
      rafId = requestAnimationFrame(flush);
    }
  };

  const onUp = () => {
    window.removeEventListener('pointermove', onMove);
    cancelAnimationFrame(rafId);
  };

  window.addEventListener('pointermove', onMove, { passive: true });
  window.addEventListener('pointerup', onUp, { once: true });
}

四、scroll-linked 动画:三条路

// ❌ 最差:直接在 scroll 事件里改样式(高频触发 + 触发布局 + 无法与帧同步)
window.addEventListener('scroll', () => {
  el.style.top = `${window.scrollY * 0.5}px`;       // 每滚一点就改一次
});
// ✅ 较好:scroll + rAF 节流(仍是「主线程动画」,但至少与帧对齐)
let ticking = false;
window.addEventListener('scroll', () => {
  if (ticking) return;
  ticking = true;
  requestAnimationFrame(() => {
    const y = window.scrollY;                        // ⭐ 在 rAF 里读,避免强制同步布局
    el.style.transform = `translateY(${y * 0.5}px)`; // ⭐ 只改 transform
    ticking = false;
  });
}, { passive: true });
/* ✅ 最好(若浏览器支持):Scroll-driven Animations(滚动驱动动画) */
/* 它由浏览器在合成阶段驱动,不走 JS 主线程 */
@keyframes parallax {
  from { transform: translateY(0); }
  to   { transform: translateY(-200px); }
}

.hero-bg {
  animation: parallax linear;
  animation-timeline: scroll(root block);     /* ⭐ 滚动进度驱动动画进度 */
}
// ⭐ 用 JS 检测支持情况并降级
const supportsScrollTimeline = CSS.supports('animation-timeline: scroll()');
if (!supportsScrollTimeline) {
  // 降级到「scroll + rAF」的实现
}
方案 帧同步 主线程负担 兼容性
scroll 直接改样式 ❌ 高(且触发布局) 全支持
scroll + rAF + 只改 transform ✅ 中(每帧一次 JS) 全支持
Scroll-driven Animations ✅ ≈0(合成线程) Chrome 115+ 等,需降级
IntersectionObserver ✅(离散触发) 低 全支持
⭐ `IntersectionObserver` 的定位:
   它**不适合**「连续随滚动变化的动画」(它只在「进入/离开」时触发),
   但非常适合「**进入视口时播放一次入场动画**」「懒加载」「曝光埋点」
   → ⭐ 这类场景用 IO 比 scroll 事件好得多(不阻塞、不与帧竞争)

五、content-visibility:跳过屏外内容的渲染

/* ✅ 跳过「屏幕外」元素的渲染(样式计算/布局/绘制都不做) */
.card {
  content-visibility: auto;
  /* ⭐ 必须给「占位尺寸」,否则滚动条会跳(因为浏览器不知道它多高) */
  contain-intrinsic-size: 0 300px;
}

/* 变体:尺寸已知时给精确值,已知范围时给 auto */
.item {
  content-visibility: auto;
  contain-intrinsic-size: auto 200px;   /* 首次渲染后记住实测尺寸 */
}
⭐⭐ `content-visibility: auto` 的价值与边界:

   ✅ 对「长列表/长文章/大量卡片」收益极大:
      屏外元素的「样式计算 + 布局 + 绘制」全部跳过
      → 首屏渲染时间可能几十毫秒的下降,滚动也更顺

   ⚠️ 三个注意点:
   ① **必须给 `contain-intrinsic-size`**(否则滚动条跳动 + 滚动位置错乱)
   ② **屏外元素「在内存里仍存在」**(DOM 没删)→ **不解决内存问题**
      (内存问题要用虚拟滚动)
   ③ **不能保证「滚动到那一屏时」零卡顿**(它对每个元素的首次渲染仍有成本)
      → 大列表「滚动到深处」的场景,「虚拟滚动」仍是更彻底的方案

   ⭐ 与「虚拟滚动」的分工:
     · `content-visibility` → 「**少做无用的渲染**」(简单、无需改结构)
     · 虚拟滚动 → 「**连 DOM 都不创建**」(更彻底,但要改列表实现)
     → 通常先用 `content-visibility`,不够再上虚拟滚动(见 5.5)

六、完整实现:一个 230 行的「帧率计 + 合成友好的动画工具」

// fps.ts —— 帧率计(采样 + P95 + 长帧统计)
export interface FpsStats {
  /** 平均帧率 */
  fps: number;
  /** 采样时长内的总帧数 */
  frames: number;
  /** P50 / P95 帧时长(ms)—— P95 比平均更能反映「卡顿」 */
  p50: number;
  p95: number;
  /** 超过「一帧预算」(16.7ms)的帧数 */
  longFrames: number;
  /** 最长的单帧(ms)—— 定位「那一刻发生了什么」 */
  worstFrame: number;
}

export function createFpsMeter(options: { windowSize?: number } = {}) {
  const windowSize = options.windowSize ?? 120;      // 约 2 秒(60fps)
  const samples: number[] = [];

  let rafId = 0;
  let lastTs = 0;
  let running = false;

  const tick = (ts: number) => {
    if (lastTs !== 0) {
      const delta = ts - lastTs;
      samples.push(delta);
      if (samples.length > windowSize) samples.shift();
    }
    lastTs = ts;
    rafId = requestAnimationFrame(tick);
  };

  return {
    start() {
      if (running) return;
      running = true;
      lastTs = 0;
      samples.length = 0;
      rafId = requestAnimationFrame(tick);
    },

    stop() {
      running = false;
      cancelAnimationFrame(rafId);
    },

    reset() {
      samples.length = 0;
    },

    /** ⭐ 关键:报告「分位数」而不是「平均值」 */
    stats(): FpsStats {
      if (samples.length === 0) {
        return { fps: 0, frames: 0, p50: 0, p95: 0, longFrames: 0, worstFrame: 0 };
      }

      const sorted = [...samples].sort((a, b) => a - b);
      const pick = (q: number) => {
        const idx = Math.min(sorted.length - 1, Math.floor(sorted.length * q));
        return sorted[idx]!;
      };

      const total = samples.reduce((a, b) => a + b, 0);
      const avgFrame = total / samples.length;

      return {
        fps: Number((1000 / avgFrame).toFixed(1)),
        frames: samples.length,
        p50: Number(pick(0.5).toFixed(2)),
        p95: Number(pick(0.95).toFixed(2)),
        longFrames: samples.filter((d) => d > 16.7).length,
        worstFrame: Number(Math.max(...samples).toFixed(2)),
      };
    },
  };
}
// anim.ts —— 「合成友好」的动画工具
import { useEffect, useRef, useState } from 'react';

/** 只允许「可合成」的属性(在开发期拦住会触发 layout/paint 的属性) */
const COMPOSITED_PROPS = new Set(['transform', 'opacity', 'filter', 'backdrop-filter']);

function assertComposited(keyframes: Keyframe[]) {
  if (process.env.NODE_ENV === 'production') return;
  for (const frame of keyframes) {
    for (const prop of Object.keys(frame)) {
      if (prop === 'offset' || prop === 'easing' || prop === 'composite') continue;
      const normalized = prop.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
      if (!COMPOSITED_PROPS.has(normalized)) {
        console.warn(
          `[anim] 关键帧里出现了「非合成属性」${prop}\n` +
          ` → 会触发 ${isLayoutProp(normalized) ? 'Layout(重排)' : 'Paint(重绘)'},动画可能掉帧。\n` +
          ` 建议:改用 transform / opacity。`
        );
      }
    }
  }
}

function isLayoutProp(prop: string) {
  return /^(width|height|padding|margin|border|top|left|right|bottom|font-size|line-height|display|position|flex|grid)/.test(prop);
}

export interface UseEnterAnimationOptions {
  duration?: number;
  easing?: string;
  /** 从哪个方向进入 */
  from?: 'bottom' | 'top' | 'left' | 'right' | 'scale';
  /** 是否只在「首次可见」时播放 */
  once?: boolean;
}

/**
 * ⭐ 只使用 transform/opacity 的入场动画(自动加/移除 will-change,走合成线程)
 */
export function useEnterAnimation<T extends HTMLElement>(options: UseEnterAnimationOptions = {}) {
  const { duration = 240, easing = 'cubic-bezier(.2,.8,.2,1)', from = 'bottom', once = true } = options;
  const ref = useRef<T>(null);
  const playedRef = useRef(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    if (once && playedRef.current) return;
    playedRef.current = true;

    // ⭐ ① 动画前加 will-change(让浏览器提前准备层)
    el.style.willChange = 'transform, opacity';

    const offset =
      from === 'bottom' ? 'translateY(12px)' :
      from === 'top' ? 'translateY(-12px)' :
      from === 'left' ? 'translateX(-12px)' :
      from === 'right' ? 'translateX(12px)' :
      'scale(0.96)';

    const keyframes: Keyframe[] = [
      { transform: offset, opacity: 0 },
      { transform: 'translate(0, 0) scale(1)', opacity: 1 },
    ];

    assertComposited(keyframes);                       // ⭐ 开发期校验

    const anim = el.animate(keyframes, { duration, easing, fill: 'both' });

    // ⭐ ② 动画后移除 will-change(释放显存)
    const cleanup = () => { el.style.willChange = 'auto'; };
    anim.finished.then(cleanup, cleanup);

    return () => {
      try { anim.cancel(); } catch { /* 已结束 */ }
      cleanup();
    };
  }, [duration, easing, from, once]);

  return ref;
}

/** ⭐ 一个「滚动进视口才播放」的组合(IntersectionObserver + 合成动画) */
export function useInViewAnimation<T extends HTMLElement>(options: UseEnterAnimationOptions = {}) {
  const ref = useRef<T>(null);
  const [inView, setInView] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    // ⭐ IO 比 scroll 事件好得多(不阻塞、不与帧竞争)
    const io = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (entry.isIntersecting) {
            setInView(true);
            io.disconnect();                            // 只播一次
          }
        }
      },
      { rootMargin: '0px 0px -10% 0px' }
    );

    io.observe(el);
    return () => io.disconnect();
  }, []);

  return { ref, inView };
}
// ✅ 使用示例
export function AnimatedCard({ children }: { children: React.ReactNode }) {
  const { ref, inView } = useInViewAnimation<HTMLDivElement>({ from: 'bottom' });

  return (
    <div
      ref={ref}
      style={{
        // ⭐ 用 transform/opacity 做状态(而不是 top/height)
        transform: inView ? 'none' : 'translateY(12px)',
        opacity: inView ? 1 : 0,
        transition: 'transform 240ms cubic-bezier(.2,.8,.2,1), opacity 240ms',
        willChange: inView ? 'auto' : 'transform, opacity',
      }}
    >
      {children}
    </div>
  );
}
// ✅ 一个「帧率面板」(开发期挂在右下角,观察真实帧率分布)
export function FpsOverlay() {
  const [stats, setStats] = useState<FpsStats | null>(null);

  useEffect(() => {
    if (process.env.NODE_ENV === 'production') return;
    const meter = createFpsMeter({ windowSize: 120 });
    meter.start();

    const timer = setInterval(() => setStats(meter.stats()), 1000);
    return () => { clearInterval(timer); meter.stop(); };
  }, []);

  if (!stats || process.env.NODE_ENV === 'production') return null;

  // ⭐ 判定:P95 > 20ms 或 长帧多 → 说明有卡顿
  const unhealthy = stats.p95 > 20 || stats.longFrames > 5;

  return (
    <div style={{
      position: 'fixed', right: 12, bottom: 12, zIndex: 9999,
      padding: '8px 10px', borderRadius: 8, fontSize: 12, fontFamily: 'monospace',
      background: unhealthy ? 'rgba(180,30,30,.92)' : 'rgba(20,20,20,.85)',
      color: '#fff', lineHeight: 1.6, pointerEvents: 'none',
    }}>
      <div>FPS {stats.fps}</div>
      <div>P50 {stats.p50}ms | P95 {stats.p95}ms</div>
      <div>长帧 {stats.longFrames} | 最差 {stats.worstFrame}ms</div>
    </div>
  );
}
⭐⭐ 这个工具复现了四个「动画性能」的关键实践:

① 【用 P95 而不是平均帧率】
   平均 58fps 看起来很好,但如果 P95 是 50ms(掉到 20fps),
   用户的感受就是「偶尔卡一下」——⭐ 「卡顿」是「长帧」造成的,不是平均值。

② 【在「关键帧」层面校验属性】
   `assertComposited` 在开发期直接告诉你「你改了会触发 layout 的属性」——
   这比「等线上用户抱怨卡」早得多。

③ 【will-change 的「加 → 用 → 移除」生命周期】
   动画前加、动画后移除,避免「层长期存在」。

④ 【用 IntersectionObserver 代替 scroll 事件】
   「进入视口才播放」是 IO 的典型场景,且不与滚动帧竞争。

七、本篇特有的坑

/* ① 用 left/top/width/height 做动画(触发 Layout) */
.box { transition: left 300ms; }              /* ❌ 应改 transform */
.box { transition: transform 300ms; }         /* ✅ */

/* ② 用 margin 做「收起/展开」动画(每次都要重排整棵树) */
.panel { transition: margin-top 300ms; }      /* ❌ */
.panel { transition: transform 300ms; }       /* ✅ 或 max-height + 可接受的代价 */

/* ③ 给「hover 的元素自己」加 will-change(太晚) */
.btn:hover { will-change: transform; }         /* ⚠️ hover 那一刻才提升 → 第一帧反而卡 */
.btn-parent:hover .btn { will-change: transform; }   /* ✅ 提前加 */

/* ④ will-change 写在常驻样式里(层爆炸) */
* { will-change: transform; }                 /* ❌❌ 灾难 */

/* ⑤ 用 box-shadow 做「呼吸动画」(每次都在重绘大面积阴影) */
.glow { animation: pulse 2s infinite; }        /* 若 keyframes 里改 box-shadow → 每帧重绘 */
/* ✅ 用 opacity + 一个「已有阴影的伪元素」来淡入淡出 */
.glow::after { box-shadow: 0 0 40px rgba(0,200,255,.8); opacity: 0; }
@keyframes pulse { 50% { opacity: 1; } }
.glow::after { animation: pulse 2s infinite; }

/* ⑥ 动画里改了「会触发 layout 的 CSS 变量」 */
@keyframes bad { to { --w: 200px; } }          /* ⚠️ 若 --w 被用于 width → 每帧重排 */
// ⑦ 在 scroll 事件里「读布局属性」(强制同步布局)
window.addEventListener('scroll', () => {
  const rect = el.getBoundingClientRect();      // ⚠️ 每次滚动都强制重排
  el.style.transform = `translateY(${-rect.top * 0.3}px)`;
});
// ✅ 在 rAF 里读,且「批量读、批量写」

// ⑧ 用 setInterval 做动画(不跟帧、后台被节流)
setInterval(() => { el.style.transform = `translateX(${x++}px)`; }, 16);   // ❌
// ✅ CSS/WAAPI 或 requestAnimationFrame

// ⑨ 以为「用了 transform 就一定走合成」
// ⚠️ 元素没有独立层时,改 transform 仍可能重绘
// ✅ 用 will-change 或 3D 变换确保提升(但要节制)

// ⑩ 无节制地「给所有卡片加 translateZ(0)」
// ⚠️ 几十上百个层 → 显存压力 + 合成变慢(低端机直接崩)
// ✅ 只给「真的会频繁动」的元素加,且用完移除

// ⑪ 用 `filter: blur()` 做「大面积模糊背景」而不看代价
// ⚠️ 模糊是「卷积」,即使走合成也 GPU 很贵;全屏 blur 会显著掉帧
// ✅ 用「预先模糊好的图片」或降低模糊半径/分辨率

// ⑫ 忽略「低端机」
// ⚠️ 高端机 120Hz 上「看起来顺」,到低端机上就掉帧
// ✅ 用 Chrome DevTools 的 CPU 节流(4×/6×)+ 真机测试

// ⑬ 只看 FPS 不看「帧时长分布」
// ⚠️ 60fps 的「平均」可能掩盖「每 10 帧卡一次」→ ✅ 看 P95/P99 与长帧数

// ⑭ 动画「结束后」没有清理(元素留在合成层上)
// ✅ `animation.finished.then(() => el.style.willChange = 'auto')`

// ⑮ 用 `content-visibility: auto` 但不给 `contain-intrinsic-size`
// ⚠️ 滚动条跳动、滚动定位错乱(浏览器不知道屏外元素多高)
// ✅ `contain-intrinsic-size: auto 300px`

// ⑯ 以为 `content-visibility` 能解决「内存」
// ⚠️ DOM 还在(只是不渲染)→ 内存问题要用虚拟滚动

// ⑰ 在动画里「同时改 transform 与 layout 属性」
// 例:`{ transform: 'translateX(10px)', width: '200px' }`
// → 每次仍会重排(layout 主导)→ ✅ 只用 transform 表达位移/缩放

// ⑱ 忽略「合成层与「文本抗锯齿/清晰度」的副作用」
// ⚠️ 提升为层后,某些平台上的文字渲染会有细微差别(亚像素抗锯齿失效)
// → 如果看到「动画后文字变糊」,检查是不是被提升成了层

// ⑲ 用 `requestAnimationFrame` 做「每帧都改 DOM 属性」的动画
// ⚠️ 每帧写样式会造成「样式重算」;若涉及布局属性,每次都重排
// ✅ 优先用 WAAPI / CSS(让浏览器在合成阶段插值,根本不需要每帧 JS)

// ⑳ 忘记「passive` 事件监听」
// window.addEventListener('touchmove', onMove)   // ⚠️ 浏览器不知道你不会 preventDefault
// ✅ { passive: true } → 允许「滚动立刻开始」(不等 JS)

八、面试延伸

  1. 「为什么用 transform 做动画比用 left/top 更流畅?」
    因为触发的渲染阶段不同:left/top 是几何属性 → 改变会触发 Layout(重排)(可能影响整棵树的布局);而 transform/opacity 可以只触发 Composite(合成)——即「用 GPU 把已有的层重新拼一下」。更关键的一点是:如果元素处于独立合成层上,只改这两个属性可以让动画跑在合成线程(不占用主线程)→ 所以「主线程被长任务占住时,动画依然流畅」。⭐ 注意:transform 也不是无条件「只走合成」——元素没有独立层时仍可能重绘,这就是「加 will-change: transform / translateZ(0)」这个技巧存在的原因。

  2. 「will-change 该怎么用?滥用会怎样?」
    三条规则:① 提前加——要加在「动画开始之前」(如 hover 时给容器加,让子元素提前提升;如果是「hover 元素自己才加」,提升动作本身会造成首帧卡顿);② 用完移除——动画结束后设回 auto(否则层一直占显存);③ 不要写在常驻样式里(will-change: transform 加在 * 或所有卡片上是经典滥用)。滥用的后果是「层爆炸」:每个合成层都要占显存(一个 1920×1080 的层大约 8MB),层太多会「显存压力 + 合成开销增加」,在低端机上直接表现为掉帧甚至崩溃。判断方法是 DevTools 的 Layers 面板(看层数量、每层内存、Compositing Reasons)。

  3. 「什么是『层爆炸』?怎么判断?」
    「层爆炸」指「页面里被提升为独立合成层的元素过多」。代价有三:① 显存占用(每个层约「宽 × 高 × 4 字节」,全屏层 8MB 起,加了额外缓冲更多);② 合成开销(GPU 要合成更多层);③ 首帧变慢(创建纹理/上传有成本)。判断方法:DevTools → Layers 面板(直接列出层数量、每层内存、以及 Compositing Reasons 说明「为什么被提升」),或 Performance 面板看 Composite Layers 与 GPU 占用。经验判据:层数 > 20~30 或层内存 > 几十 MB 就要警惕。

  4. 「CSS 动画、WAAPI、requestAnimationFrame 怎么选?」
    优先级:① CSS transition/animation + 只改 transform/opacity——最省心,浏览器会自动让它跑在合成线程,主线程忙也不掉帧;② Web Animations API(el.animate())——当需要「JS 控制时序、中断、反向、拿进度、编排多个动画」时用(对可合成属性同样能走合成线程,且比 keyframes + 类名切换更好控制);③ requestAnimationFrame——只在「真的需要每帧读值或复杂逻辑」时用,且只改 transform/opacity(并且要「批量读、批量写」,避免布局抖动)。❌ 最差的是「setInterval 或 scroll 事件里直接改 left/top/width」。

  5. 「主线程忙为什么会影响动画?怎么避免?」
    因为「主线程动画」(JS 逐帧改样式,或涉及 Layout/Paint 的动画)需要每一帧都占用主线程去计算样式与布局——主线程一忙(长任务、大量 JS),动画就抢不到执行机会 → 掉帧。避免方式是「把动画交给合成线程」:① 只改 transform/opacity;② 用 CSS animation/transition 或 WAAPI(而不是 JS 逐帧);③ 必要时用 will-change/3D 变换把元素提升为独立层。⭐ 这样即使主线程在跑 200ms 的长任务,动画依然由合成线程按帧插值推进。补充:如果动画「必须」与滚动/JS 逻辑绑定,用 Scroll-driven Animations(浏览器在合成阶段驱动)或「scroll + rAF + 只改 transform」。

  6. 「content-visibility: auto 是什么?和虚拟滚动有什么区别?」
    content-visibility: auto 让「屏幕外」的元素跳过渲染(样式计算、布局、绘制都不做),极大降低「长列表/长文章」的渲染成本。必须配 contain-intrinsic-size(告诉浏览器「屏外元素大概多高」),否则滚动条会跳动、滚动定位会错乱。与虚拟滚动的区别:① content-visibility DOM 元素仍然存在(只是不渲染)→ 不解决内存问题,实现简单、不需要改列表结构;② 虚拟滚动「连 DOM 都不创建」→ 同时解决渲染与内存问题,但需要改造列表实现(见 5.5)。实践顺序是「先用 content-visibility,不够再上虚拟滚动」。

  7. 「滚动相关的动画怎么做最好?」
    按优先级:① Scroll-driven Animations(animation-timeline: scroll())——由浏览器在合成阶段驱动,几乎零主线程负担(Chrome 115+ 等支持,需要降级方案);② scroll 事件 + requestAnimationFrame + 只改 transform——通用方案(⭐ 在 rAF 里读值,避免「scroll 里读 getBoundingClientRect 造成强制同步布局」);③ IntersectionObserver——适合「进入视口播放一次入场动画 / 懒加载 / 曝光埋点」这类离散触发(不适合「连续随滚动变化」的动画)。❌ 最差是「直接在 scroll 回调里改 top/left」(高频触发 + 触发布局 + 不与帧同步)。

  8. 「怎么『量化』动画是否流畅?」
    不要看「平均 FPS」(它会被大量正常帧拉高)——要看「帧时长的分位数」:① P50(中位帧时长);② P95 / P99(⭐ 「偶发卡顿」都在这里,P95 > 20ms 基本可判定用户能感知到卡);③ 长帧数量(超过一帧预算 16.7ms 的帧数);④ 最差单帧(用于定位「那一刻发生了什么」,配合 Performance 面板看当时的任务)。测量方法:用 requestAnimationFrame 连续采样「相邻两次回调的时间差」(不要用「每秒数帧」的粗暴算法),并在 CPU 节流与真机上验证(高端机 + 120Hz 会掩盖问题)。

一句话速记

渲染管线是「Style → Layout → Paint → Composite」,动画要「只改 transform/opacity」(把 left/top/width/margin/box-shadow 的动画换掉),并且「让元素处于独立合成层」才能让动画跑在合成线程(主线程忙也不掉帧)——will-change 的三条纪律是「提前加(加在容器 hover 上)、用完移除、不写常驻样式」,滥用导致「层爆炸」(全屏层 ≈ 8MB,用 Layers 面板看层数与 Compositing Reasons);动画载体优先级是「CSS transition/animation > WAAPI > requestAnimationFrame(只改 transform + 批量读写)」,❌ 别用 setInterval/scroll 直接改样式;滚动动画优先 Scroll-driven Animations,其次「scroll + rAF + 只改 transform」,离散触发用 IntersectionObserver;content-visibility: auto + 必配 contain-intrinsic-size 能跳过屏外渲染(但不省内存,内存问题要靠虚拟滚动);量化流畅度看「P95/P99 帧时长与长帧数」,不看平均 FPS;DevTools 的 Layers 面板看「层」、Performance 面板看「一帧里各阶段耗时」。

赞(0) 打赏
未经允许不得转载:陌上寒 » 5.17 动画性能与合成层

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫