返回首页

只动 transform 和 opacity:网页动效的性能底线

2026-08-28 · 阅读约 6 分钟

做前端动画时最常见的一条建议是「只动 transformopacity」。这条规则我照着用了很久,但一开始并不清楚背后的原因,直到踩过几次掉帧才真正理解。这篇把浏览器渲染管线的逻辑理一遍。

浏览器画一帧要做的事

从 DOM 到屏幕像素,浏览器大致要走这几步:

  1. Style:计算每个元素最终生效的 CSS 属性值。
  2. Layout(回流):算出每个元素的几何信息——宽高、位置。
  3. Paint(重绘):把元素画成一系列绘制指令,填充颜色、边框、阴影、文字。
  4. Composite(合成):把若干图层按顺序叠起来,交给 GPU 输出到屏幕。

关键在于:这四步是有依赖顺序的。改动触发的阶段越靠前,后面的阶段就得跟着重做一遍。

不同属性触发的阶段不同

改动的属性触发代价
width height top left margin paddingLayout → Paint → Composite最高
background-color box-shadow border-radius colorPaint → Composite
transform opacity只有 Composite最低

动画意味着每秒要重复这个过程 60 次,每帧的预算只有约 16.7ms。如果每帧都要重新 layout 整棵子树,很容易就超预算,表现出来就是卡顿掉帧。

transformopacity 之所以便宜,是因为它们可以完全由合成器(compositor)线程处理:元素已经被光栅化成一张位图,动画只是在改这张位图的变换矩阵和透明度。这个过程甚至不需要主线程参与——也就是说,即使主线程正在跑一段耗时的 JS,这类动画依然能保持流畅。

常见的改写

大部分「看起来必须改布局」的动画,都能翻译成 transform:

/* ❌ 每帧触发 layout */
.box { transition: left 300ms; }
.box.move { left: 200px; }

/* ✅ 只走合成 */
.box { transition: transform 300ms; }
.box.move { transform: translateX(200px); }

缩放同理,用 scale() 代替改 width/height

/* ❌ */
.card:hover { width: 320px; height: 220px; }

/* ✅ */
.card { transition: transform 250ms; }
.card:hover { transform: scale(1.04); }

注意 scale 会把内部的文字和圆角一起缩放,如果不希望文字变形,做法是给子元素套一个反向的 scale,或者干脆只对不含文字的装饰层做缩放。

阴影怎么办

box-shadow 动画属于 paint 阶段,模糊半径越大越贵。想要「hover 时阴影加深」的效果又不掉帧,常见做法是准备两层阴影,用 opacity 交叉淡入:

.card { position: relative; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.card::after {
  content: ""; position: absolute; inset: 0; border-radius: inherit;
  box-shadow: 0 18px 40px -18px rgba(0,0,0,.25);
  opacity: 0; transition: opacity 250ms ease;
  pointer-events: none;
}
.card:hover::after { opacity: 1; }

实际项目里如果卡片数量不多、阴影模糊半径也不夸张,直接 transition box-shadow 通常也能接受。这个技巧留给列表里有几十上百个卡片的场景。

高度展开动画

折叠面板是个老问题,因为 height: auto 无法参与过渡。现在有几个选择:

这三种都不完美,grid 方案目前是可读性最好的。

will-change 不是万能药

will-change: transform 会提前把元素提升为独立的合成层,避免动画开始那一帧的抖动。但它有成本——每个合成层都要占显存,滥用会让内存暴涨,反而更卡。

我的用法是:只在确实观察到动画起始有 1px 抖动时才加,而且尽量通过 CSS 加在真正会动的那个元素上,不要写成 will-change: all。如果是用 JS 控制的一次性动画,动画结束后应该把它移除。

filter: blur() 的取舍

模糊是 GPU 上开销较大的操作,半径越大越贵,在 Safari 上尤其明显。做背景光晕这类效果时,我会控制在 20px 以内,或者用一张预先模糊好的渐变图代替实时滤镜。动画中同时改变 blur 半径基本要避免——那等于每帧重新做一次模糊。

怎么验证

光靠肉眼判断不可靠,Chrome DevTools 里有两个直接的入口:

最后一条

性能之外还有一条底线:任何动画都要给 prefers-reduced-motion 留退路。前庭功能障碍的用户会因为大幅位移和视差产生眩晕,这跟掉帧不同,是实实在在的不适。

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation: none !important;
    transition: none !important;
  }
}

一行媒体查询的事,没有理由不写。