先记住这个答案
给图片做灰度过渡的写法本身很简单,一行 transition: filter 0.3s 就够了。性能上要关注的是 filter 在过渡期间的渲染成本:浏览器每帧都要对这个元素的像素做一次 grayscale 计算,这是一次 repaint 操作。图片越大、同时过渡的元素越多,主线程的绘制压力越大。如果在列表页一次性对几十张卡片做去色动画,掉帧的概率很高。
filter可用transition过渡- 灰度过渡需反复处理像素,可能掉帧
will-change可能优化合成,但需实测- 大图或大量元素同时过渡时考虑限制作用范围
最基本的去色过渡写法
给一张图片加灰度效果,然后 hover 时还原成彩色,只需要两个状态的切换。filter 是可动画属性,浏览器能自动在两个值之间做插值。
.card-img {
filter: grayscale(1); /* 默认灰色 */
transition: filter 0.4s ease; /* 过渡 filter 属性 */
}
.card-img:hover {
filter: grayscale(0); /* hover 时还原彩色 */
}
/* 反过来也常见:默认彩色,hover 变灰 */
.card-img-alt {
filter: grayscale(0);
transition: filter 0.4s ease;
}
.card-img-alt:hover {
filter: grayscale(0.8); /* 不一定要到 1,0.8 更柔和 */
}grayscale() 的参数是 0 到 1 之间的数值,0 表示原色,1 表示完全灰度。过渡期间浏览器会在两个值之间线性插值,视觉上就是颜色逐渐变灰或者逐渐还原。
为什么大图或多图过渡会卡
filter 变化不会触发 layout(不影响元素尺寸和位置),但会触发 repaint。repaint 阶段浏览器要对元素的每一个像素重新做颜色计算。一张 1920x1080 的图片就是两百万个像素,每帧都要处理一遍。
这和 opacity 的区别在于,opacity 只是调整整个图层的透明度,可以完全在合成阶段完成,不需要重新计算像素。而 grayscale 需要读取每个像素的 RGB 值再算出灰度值,这一步跳不过。
默认情况下,元素和它周围的其他元素共享同一个绘制层。filter 变化会让整个绘制层都标记为需要重绘。如果页面上有 20 张卡片图在同一个层里,哪怕只有一张在做过渡,整个层都可能被重绘。
will-change 限制重绘范围CSS.card-img {
filter: grayscale(1);
transition: filter 0.4s ease;
will-change: filter; /* 提示浏览器提前创建独立合成层 */
}
.card-img:hover {
filter: grayscale(0);
}
/* 动画结束后最好移除 will-change,避免长期占用内存 */加了 will-change: filter 之后,浏览器会为这个元素单独创建一个合成层。过渡期间的重绘只影响这个层,不会波及旁边的元素。代价是多了一个层,会额外占用 GPU 内存。如果页面上几十个元素都加了 will-change,层太多反而会拖慢合成阶段。
实际项目里的优化思路
在列表页做去色效果时,一般不需要所有图片同时过渡。只对 hover 的那一张做动画就够了,其余图片保持静态灰度不动。这样同一时刻只有一个元素在做 filter 过渡,性能压力可以忽略不计。
如果设计要求页面加载时所有图片从灰到彩逐个亮起来,可以用 animation-delay 错开,让同一帧里最多只有两三张图在过渡。也可以考虑降低图片分辨率——显示尺寸 200x200 的缩略图没必要用 2000x2000 的原图,像素少了计算量成比例下降。
还有一个常见场景是整个页面变灰(比如哀悼日),这时 filter 加在 <html> 上,只需要算一次,不存在过渡期间掉帧的问题,因为根本不做动画。
容易答错的地方
- 把
filter过渡和transform过渡的性能画等号 transform过渡可以完全在合成层完成,不触发 layout 也不触发 repaint。filter过渡虽然不触发 layout,但每帧都要 repaint。两者的性能差一个量级,不能混为一谈。- 以为
will-change没有代价 will-change会让浏览器提前为元素创建独立合成层,每个层都占用一份 GPU 纹理内存。滥用will-change会导致层爆炸,合成阶段反而变慢。只在真正需要过渡的元素上加,过渡结束后用 JavaScript 移除是比较稳妥的做法。
面试官还会怎么问?
除了 grayscale,filter 的其他函数过渡性能一样吗?
基本一样。blur()、brightness()、contrast()、saturate() 都是逐像素计算,过渡期间都要 repaint。其中 blur() 最重,因为模糊需要读取周围像素做卷积运算,半径越大越慢。
能不能用 backdrop-filter 代替 filter 来减少重绘?
不能。backdrop-filter 是对元素背后的内容做滤镜,计算量只多不少——它需要先把背后的像素合成出来,再做滤镜处理。而且 backdrop-filter 也会创建层叠上下文,副作用和 filter 一样。
怎么用 DevTools 验证 filter 过渡的性能?
打开 Chrome DevTools 的 Rendering 面板,勾选 Paint Flashing。做过渡时如果元素高亮为绿色,说明在重绘。再到 Layers 面板看有没有独立层。最后到 Performance 面板录一段,看 Paint 时间占每帧的比例。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。