浏览器渲染管线

浏览器把一帧画面送上屏幕,要走四条流水线:样式计算(Style)→ 布局(Layout)→ 绘制(Paint)→ 合成(Composite)。样式变化触发哪一段,直接决定这一帧的成本。改 `width` 要重排,改 `color` 要重绘,而改 `transform` 通常只走到合成,由 GPU 完成,成本最低。

掉帧的根源是主线程被超时任务占满:一次重排可能波及整棵布局树,一张大面积重绘要把像素重新画一遍。理解管线不是为了背概念,而是为了在写每一行样式时能预判成本——这是动画性能优化的起点。

// 触发 Layout:width、height、top、left、margin
// 触发 Paint:color、background、box-shadow、border-radius
// 只触发 Composite:transform、opacity

60 帧的体验意味着每帧预算约 16.6 毫秒,主线程还要留给事件与脚本。动画涉及的属性越靠后,留给合成的空间越大,也就越不容易掉帧。

动画性能的秘诀不是「写得快」,而是「少干活」:让浏览器每帧只做最便宜的阶段。
本文核心知识点
  • 渲染管线四阶段与成本。
  • transform/opacity合成器友好。
  • requestAnimationFrame驱动。
  • FLIP高性能布局动画。
动画性能优化配图:蓝调插画中横向发光曲线贯穿两侧矩形面板,上方波浪光带与光晕圆点,体现流畅帧率与渲染节奏
每一帧都在预算内完成,动效才谈得上「流畅」。

合成器友好属性

让元素移动、缩放、旋转,优先用 `transform`;让透明度变化,用 `opacity`。这两个属性直接走合成线程,不会触发主线程的布局与绘制,是动画性能的第一选择。`will-change: transform` 可以提前告诉浏览器把元素提升到独立合成层,减少动画开始时的首次提升开销。

.card {
  transition: transform 0.3s ease, opacity 0.3s ease;
  will-change: transform;
}
.card:hover { transform: translateY(-6px) scale(1.02); }

`will-change` 不是越多越好:每个独立合成层都占 GPU 显存,大量滥用反而拖慢页面。只对确实高频变化的元素使用,并且动画结束后及时移除。合成层太多时,滚动性能会被内存带宽拖垮。

rAF 与 JS 驱动动画

需要 JS 逐帧更新数值的动画,交给 `requestAnimationFrame`。它把回调排进下一帧的渲染前,跟随显示器刷新率,不会像 `setInterval` 那样堆积延迟回调,也能在页面切到后台时自动暂停,省电省资源。

function animateScroll(el, target) {
  const start = performance.now();
  const from = window.scrollY;
  const dur = 400;
  function step(now) {
    const p = Math.min((now - start) / dur, 1);
    const eased = 1 - Math.pow(1 - p, 3);
    window.scrollTo(0, from + (target - from) * eased);
    if (p < 1) requestAnimationFrame(step);
  }
  requestAnimationFrame(step);
}

两个常见坑:一是在 rAF 回调里读布局又写样式,造成「布局抖动」(layout thrashing);二是高频事件(如滚动、mousemove)里直接驱动动画,应该用 rAF 节流——把状态更新收进回调,事件只负责标记「有变化」。

FLIP 动画技术

列表增删、排序、展开折叠这类「布局变化」动画,直接过渡布局属性必然触发昂贵重排。FLIP 技术把过程反转:先记录元素起始位置(First),变化后记录终点(Last),再用 `transform` 从起点插值到终点(Invert/Play)。布局只发生一次,动画全程走合成。

function flip(el) {
  const first = el.getBoundingClientRect();
  // 这里执行布局变化,例如插入新元素
  const last = el.getBoundingClientRect();
  const dx = first.left - last.left;
  const dy = first.top - last.top;
  el.animate([
    { transform: `translate(${dx}px, ${dy}px)` },
    { transform: "translate(0, 0)" }
  ], { duration: 300, easing: "ease" });
}

`getBoundingClientRect` 是强制同步布局的「读」,FLIP 的诀窍是读和写分开:先集中读一批,再集中写。配合 Web Animations API 的 `animate`,代码简洁且原生支持取消与补间。