浏览器渲染管线
浏览器把一帧画面送上屏幕,要走四条流水线:样式计算(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`,代码简洁且原生支持取消与补间。
减少重排与重绘
重排是渲染管线里最贵的环节,一次改动可能牵动大量节点重新布局。批量策略能显著降本:读属性与写样式分开进行(避免强制同步布局循环);用 `DocumentFragment` 或一次性 innerHTML 批量插入 DOM;样式批量切换用 class 而不是逐条改 style。
// 坏:读写交替,每轮都强制同步布局
items.forEach(it => {
const w = it.offsetWidth; // 读
it.style.width = w * 2 + "px"; // 写
});
// 好:先集中读,再集中写
const widths = items.map(it => it.offsetWidth);
items.forEach((it, i) => { it.style.width = widths[i] * 2 + "px"; });
绘制阶段的大面积重绘同样要避免:不要给高频动画元素加实时变化的 `box-shadow` 或模糊滤镜,这些效果在每次重绘都要重新计算。能用位图分层解决的,不要让绘制面积等于整个视口。
CSS 动画与懒渲染
能用 CSS 动画完成的交互优先用 CSS:浏览器可以在合成线程上运行 transform/opacity 动画,即使主线程忙碌也不掉帧。关键帧里避免使用会触发 Layout 的属性;动画结束后用 `animation-fill-mode` 收尾,避免残留层占用资源。
.list-item {
content-visibility: auto;
contain-intrinsic-size: 240px;
}
长列表用 `content-visibility: auto` 让浏览器跳过视口外内容的渲染,配合 `contain-intrinsic-size` 保留占位尺寸,滚动性能立刻提升。它把「懒渲染」从 JS 层面下沉到浏览器原生,是长页面动画流畅的秘密武器。
性能测量与实战清单
- 用 DevTools Performance 录制动画过程,看每一帧是否越过 16.6ms 预算线。
- 关注 Rendering 面板的 FPS 计量器与绘制区域高亮,定位大面积重绘。
- 动画属性首选 transform 与 opacity,避免布局属性进关键帧。
- will-change 只用在真正高频变化的元素,用完即清。
- rAF 回调内不读布局属性,读写分离防抖动。
- 列表与卡片动画优先 FLIP,布局只发生一次。
- 尊重 prefers-reduced-motion,对减少动效的系统设置提供降级。
流畅不是感觉,是数字:每帧预算、绘制面积、合成层数,都可测量、可改进。