ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

深入优化CSS动画性能:利用transform与opacity避开重排重绘

深入优化CSS动画性能:利用transform与opacity避开重排重绘 1. 动画卡顿的根源浏览器如何绘制一帧1.1 帧时间的真相60fps是如何被打破的做前端的朋友应该都遇到过类似的场景明明只是给元素加了个hover放大效果或者写了个简单的过渡动画结果在 Chrome 里一跑画面就跟幻灯片似的有时候甚至整个页面的滚动都变得一卡一卡的。先说一个很多人不知道的事实浏览器的屏幕刷新率通常是 60Hz也就是说每秒钟屏幕会刷新 60 次。为了让动画看起来顺滑浏览器必须在每次刷新前准备好新的画面留给每一帧的处理时间只有大约 16.7 毫秒。一旦单帧的处理时间超过这个值画面就会出现掉帧表现出来就是肉眼可见的卡顿。我之前做过一个营销页的动效优化页面上同时跑着七八个动画有涟漪扩散、有元素位移动画、还有几个无限循环的透明度变化。最开始在本地开发机上看着还行但到了用户的普通笔记本上粉丝直接在评论区反馈页面转不动。后来我用 Performance 面板一测才发现单帧渲染时间经常冲到 30 到 40 毫秒掉帧率超过了一半。问题的根源不在于动画数量多而在于我触发了一系列非常高开销的渲染操作。很多入门教程只告诉你怎么写动画属性却很少讲浏览器背后到底发生了什么。理解浏览器绘制一帧的完整流程是优化 CSS 动画的第一课。1.2 渲染流水线上的三类性能杀手浏览器的渲染流程大致可以分成五个阶段JavaScript 执行、样式计算Style、布局Layout、绘制Paint、合成Composite。不是所有 CSS 属性的变化都会走完全部阶段不同属性的开销天差地别这才是性能优化的核心逻辑。先看属性分类我直接给结论触发 Layout重排的属性width、height、margin、padding、top、left、font-size、display等。这类属性一变浏览器要重新计算元素的几何位置然后往下走绘制和合成。这些操作最贵动画里能不用就不用。触发 Paint重绘的属性color、background-color、box-shadow、border-radius等。这些属性变化不会影响页面布局但浏览器需要重新绘制元素开销也不小。仅触发 Composite合成的属性transform、opacity。这两个属性的动画可以绕过布局和绘制阶段直接在合成器上处理效率最高。用个生活化的类比transform动画就像在投影仪上挪动一张透明胶片胶片本身没变只是换了个位置而width动画就像是重新排版一本书每一帧都要重新计算文字和图片的位置工作量大得多。我见过一个典型的反面案例有人用 CSS 动画让一个弹窗从顶部滑入用的是top left逐帧逼近的方式。其实只需要把top: -100px改成transform: translateY(-100px)性能立刻就能翻几倍。原理很简单top一变就触发整棵子树的重排transform则只影响合成阶段。写完动画之后还有一个经常被忽略的点一个元素动画卡顿不一定是它自己的问题可能是它上层的某个容器触发了重排导致这个元素跟着遭殃。所以排查性能问题的时候要往上看几层看看祖先元素是不是在动画期间同时改变了尺寸或位置。2. 核心优化原则只动合成器别碰布局和绘制2.1 硬件加速的边界与误区提到动画性能几乎所有文章都会说用 GPU 加速加 will-change。但很多人对这个概念的理解是有偏差的。浏览器之所以能用 GPU 处理transform和opacity动画是因为它把元素提升到了一个独立的合成层Compositing Layer在这一层上的变化不需要重新绘制原始页面。问题来了并不是所有元素都适合提升为合成层。每增加一个合成层浏览器就要占用额外的 GPU 内存。移动端设备的内存本来就紧张如果页面上有成百上千个元素都加了will-change: transformGPU 内存被吃满反而会让整个页面崩溃或者变得异常卡顿。我做一个列表动画的时候犯过这个错误页面里有两百多个列表项每个项都有一个缩放动画。为了优化我给每一项都加了will-change: transform结果在低端安卓机上页面直接白屏了。后来去查原因就是合成层数量过多GPU 内存溢出被浏览器强制终止了。正确的姿势是克制。只给正在动画的元素加动画结束后及时移除或者干脆不加让浏览器自己决定是否提升。现代浏览器的合成器已经足够聪明频繁的手动优化往往适得其反。2.2 动画属性的黄金选择transform与opacityCSS 动画性能优化的核心说白了就一句话能用transform和opacity表达的动画就不要使用其他属性。先说transform。它能做的动作其实非常丰富不只是位移还包括缩放scale、旋转rotate、倾斜skew以及 3D 变换rotateX、rotateY、translateZ等。很多看似需要修改尺寸或位置的动画都可以通过 transform 实现。比如菜单展开的动画最直觉的实现是修改height从 0 到 200px。但height是触发重排的属性动画每一帧都要重新计算布局很难达到流畅效果。正确的做法是给容器设置固定高度或使用max-height技巧然后对内部元素使用transform: scaleY()配合transform-origin: top来实现展开效果。这样动画只发生在合成阶段性能表现要优秀得多。再看opacity。透明度变化是淡入淡出动画的基础它同样是只触发合成的属性非常适合用在卡片切换、弹层显隐、loading 动画这些场景里。一个重要的细节opacity: 0的元素仍然占据布局空间而且仍然可以响应鼠标事件。如果你做了一个元素淡出的动画结束后希望它完全消失记得在animationend事件里加上visibility: hidden或者display: none否则视觉上是没了实际上还在拦截底部元素的点击。关于动画组合使用的性能优势我做了一个简单的对比表动画效果低效方案高效方案性能差距元素滑入top: -100px → 0transform: translateY(-100px → 0)差距可达 20 倍以上按钮缩放width/height变化transform: scale()差距约 10-15 倍淡入淡出visibility display切换opacity渐变差距约 5 倍以上水波涟漪width/height border-radiustransform: scale() opacity差距约 15 倍以上这组数据来自我在 DevTools Performance 面板里的实测记录不同浏览器会有一定差异但量级排序不会变。transform和opacity组合几乎可以覆盖 90% 以上的常见动画效果而且全部能做到高性能运行。2.3 will-change的正确打开方式关于will-change这是 CSS 里一个专门的性能优化提示属性它告诉浏览器某个元素将要发生哪些变化让浏览器提前做好优化准备。但它的名字已经说明了一切它只是一个提示不是命令。浏览器收到提示后会检查是否有必要执行优化并不是所有属性的变化它都会提升成合成层。实际使用的时候我建议遵守以下三个原则第一只对持续动画使用。如果一个动画只播放一次而且持续时间不到 1 秒加will-change的收益非常有限甚至可能因为额外的合成层分配而导致动画开始时出现短暂的闪烁。持续循环动画比如 loading 旋转、涟漪扩散才是will-change的典型适用场景。第二动画结束后移除。用 JS 在animationstart时添加will-change在animationend时移除这是一个比较稳妥的做法。但如果你使用的是纯 CSS 方案也可以在关键帧结束状态里把will-change重置为auto。第三不要在大面积区域上使用。will-change的粒度是元素级别而不是属性级别。给一个 800px 宽的大容器加will-change: transform等于让浏览器给整个区域创建合成层内存开销非常明显。更合理的做法是只把它加在真正做位移动画的那个内部元素上。注意will-change不是万金油。如果你发现加了它之后反而更卡第一件事是检查是否创建了过多合成层第二件事是检查是否把它加在了错误属性的动画上。举个例子如果你对color动画加will-change: color浏览器会认为这是一个需要持续重绘的属性并做额外优化准备但实际效果微乎其微。3. 动画控制的细节建模延迟、完成态、次数与方向3.1 animation-delay与fill-mode的配合CSS 动画的属性非常多不是只有 duration 和 timing-function 这两个基础项。在实际开发中animation-delay延迟和animation-fill-mode结束状态保持这两兄弟经常被误用导致动画表现和预期差距很大。先说animation-delay。一个最常见的场景页面加载时多个元素依次出现形成错落有致的入场效果。这时候给每个元素设置不同的延迟时间可以让入场动画更自然。但这里有个新手容易踩的坑如果同时设置了animation-fill-mode: backwards或both元素在延迟期间就会处于关键帧的起始状态如果fill-mode的默认值是none元素在延迟期间会保持正常状态。用一个具体例子说明假设一个元素要从opacity: 0渐变到opacity: 1持续 1 秒延迟 2 秒。.element { animation-name: fadeIn; animation-duration: 1s; animation-delay: 2s; /* animation-fill-mode: backwards; */ } keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }如果fill-mode是默认的none那在这 2 秒延迟期间元素是以opacity: 1的状态显示的。等动画开始后它才会突然变成透明然后再逐渐显现。这就是一个视觉 bug元素在延迟期间闪了一下。解决办法是加上animation-fill-mode: backwards这样在延迟期间元素就会自动应用关键帧的起始状态即opacity: 0。如果你想动画结束后也保持终点状态例如元素滑入后停留在新位置就要用animation-fill-mode: forwards。两者都想要就设成both。3.2 animation-iteration-count与animation-direction的实战场景热词里提到了一组很具体的 CSS 动画知识点执行次数和逆向播放。对应到 CSS 属性分别是animation-iteration-count和animation-direction。animation-iteration-count决定动画播放几次可以填具体数字比如3也可以填infinite表示无限循环。animation-direction则控制动画的播放方向有四个取值normal正常方向播放每次从头到尾。reverse反向播放每次从尾到头。alternate正向一次、反向一次交替播放。alternate-reverse先反向一次、再正向一次交替播放。这两个属性配合使用能实现很多有趣的动效。最常见的场景是呼吸灯效果元素在放大和还原之间循环。如果只是用normal每次循环都会从起点重新开始视觉上会有一个明显的跳变改成alternate之后动画会平滑地在两个方向之间来回切换观感自然得多。.breathing { animation: breathe 2s ease-in-out infinite alternate; } keyframes breathe { from { transform: scale(1); } to { transform: scale(1.1); } }除了呼吸灯alternate还非常适合做手风琴菜单的展开收起、图标的左右摇摆、开关按钮的拨动效果等。还有一个实用经验用animation-direction结合单个关键帧合可以轻松实现往返一次动画。如果设置animation-direction: alternate且animation-iteration-count: 2动画会正向播放一次、反向播放一次正好是一个完整的来回。这种方式比单独定义from/to两个关键帧要清晰得多代码量也更少。3.3 鼠标移入移出场景下的过渡中断处理热词里出现的css 鼠标移入事件在实际开发中主要体现在:hover配合transition的组合上。这个组合是最常见的动效写法但也是问题高发区。先记住一个原则如果只是简单的悬浮反馈比如按钮变色、图标放大用transition而不是animation更合理。transition是状态之间的平滑过渡天然支持中途取消animation则是完整的帧动画一旦启动就很难在中途平滑地停止。做一个翻牌卡片的效果鼠标移入翻转、移出还原。用transition配合transform: rotateY()来实现非常顺手。.card { transition: transform 0.6s ease-in-out; transform-style: preserve-3d; } .card:hover { transform: rotateY(180deg); }当鼠标快速移入再移出时transition会自动从当前中间状态向目标状态过渡而不是跳回起点重新播放这个细节让交互手感好了很多。不过transition也有性能陷阱需要避开。在transition的属性列表里只列出需要参与过渡的属性不要写all。比如/* 不推荐监听所有属性容易误触发 */ .card { transition: all 0.3s ease; } /* 推荐只声明需要过渡的属性 */ .card { transition: transform 0.3s ease, box-shadow 0.3s ease; }transition: all的问题在于任何属性变化都会触发过渡动画比如页面加载时元素的color、background变化也会产生过渡既增加了没必要的渲染计算又可能导致意料之外的闪烁效果。4. 实战优化让动画流畅的完整落地流程4.1 用DevTools Performance定位卡顿关键帧理论说完了进入实操环节。拿到一个卡顿的 CSS 动画页面第一步不是猜而是用工具去量。打开 Chrome DevTools切到 Performance 面板点击录制按钮后在页面里触发动画录制约 3 到 5 秒然后停止。面板会生成一条完整的性能时间线包含帧率、CPU 占用和各种渲染事件。看时间线的时候重点观察两个东西第一是 FPS 图表。如果 FPS 稳定在 55 到 60 之间动画性能基本达标如果经常跌到 30 以下说明存在明显的性能瓶颈需要进一步排查。第二是渲染事件的耗时分布。展开时间线后可以看到每一帧都经历了哪些阶段紫色的是 Scripting脚本执行紫色偏蓝的是 Rendering样式计算与布局绿色的是 Painting绘制灰色的是 System系统开销。如果Layout和Paint的时间占比很高超过单帧总耗时的一半那基本可以断定是动画属性选择不当触发了高开销的重排或重绘。我之前优化一个跑马灯公告栏的时候就是用这个方式定位到了问题。时间线显示每一帧的 Layout 耗时都要 8 到 10 毫秒翻看代码发现它用的是margin-left做位移改成transform: translateX之后Layout 时间直接降到了 0.2 毫秒以下动画立刻变得丝滑了。Performance 面板还有一个很有用的功能点击某个耗时较长的帧在 Summary 标签页可以看到这一帧里具体是哪些函数或操作消耗了时间能帮你精确定位到某个 JS 操作或者引起重排的属性。4.2 从DOM改动到合成层优化前后的对比为了更直观地展示优化效果我把一个典型的入场动画从低效方案改成了高效方案并把中间的过程完整记录了下来。原始方案是这样的一个元素从屏幕左侧滑入初始left: -300px最终left: 0。关键帧里同时修改了left和opacitykeyframes slideIn { from { left: -300px; opacity: 0; } to { left: 0; opacity: 1; } }用 Performance 面板录制这段动画FPS 只能维持在 40 到 45 帧掉帧现象明显。每一帧的 Layout 耗时大约 7 毫秒Paint 耗时约 4 毫秒。主要开销来自left属性变化触发的重排和重绘。优化后的方案用transform: translateX(-300px)替代left: -300pxkeyframes slideIn { from { transform: translateX(-300px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }同样的录制条件下FPS 稳定在 60Layout 和 Paint 阶段的时间都降到了 0。整帧渲染时间从原来的 15 毫秒以上降到了 3 毫秒左右。这个案例很有代表性。它证明了在很多场景下性能优化并不需要复杂的技巧只需要改变属性的选择思路。动画属性的选择优先级是transform / opacity 大于 visibility / clip-path 大于 multiple background-position 大于 top / left / margin / width / height。4.3 移动端与低端机型的性能降级策略PC 上跑得丝滑的动画到了移动端可能惨不忍睹。移动设备的 CPU 和 GPU 性能远弱于桌面设备同样的动画在手机上的渲染耗时可能是电脑上的三到四倍。所以移动端适配是 CSS 动画性能优化里绕不开的一个环节。我的移动端动画性能降级策略大致分三层第一层动画数量减负。移动端页面同时运行的 CSS 动画数量控制在 3 到 5 个以内超过这个数量即使每个动画本身是合成层的操作也会因为 GPU 带宽不够而出现卡顿。这个可以用 CSS 媒体查询实现在屏幕宽度较小的设备上直接关闭部分装饰性动画。media (max-width: 768px) { .decoration-animation { animation: none; } }第二层动画复杂度降级。有 3D 变换效果的动画在移动端上尽量改成 2D 变换。rotateX、rotateY这些属性会强制开启 GPU 3D 渲染在部分低端 GPU 上的开销非常大。降级成scale、translate这类 2D 变换后视觉效果可能稍有差异但流畅度有质的提升。第三层降低动画时长与帧率。有研究表明在低端设备上把动画时长缩短 20% 到 30%用户主观感受到的流畅度会提升因为动画更快结束暴露卡顿的时间窗口更短。此外可以用steps()函数把动画帧率主动降低到 30fps配合animation-duration调整视觉效果影响很小但渲染耗时会显著下降。keyframes countdown { 0% { content: 3; } 33% { content: 2; } 66% { content: 1; } 100% { content: Go; } } .step-animation { animation: countdown 3s steps(1, end) infinite; }5. 常见问题排查与踩坑实录5.1 Chrome网页动画展示时很卡可能是这些原因关于 Chrome 里网页动画卡顿我遇到过几个高频原因整理成一个排查清单供参考。第一个原因是页面上存在持续触发的重型 CSS 属性动画比如box-shadow或filter: blur()的动画。这两个属性非常吃性能因为它们的计算量大而且一旦变化几乎整个元素区域都要重绘。如果你确实需要阴影效果一个折中方案是预先用伪元素画一个静态的阴影图形只对伪元素做opacity过渡来模拟阴影变化。第二个原因是动画元素的父层存在overflow: hidden或者border-radius裁剪这会打断浏览器对元素合成分层的优化。在开启合成层时浏览器要额外考虑裁剪区域的边界导致无法把子元素独立到单独的图层。遇到这种情况可以尝试把动画元素从裁剪容器中提出来或者直接给动画元素也设置相同的border-radius以规避裁剪计算。第三个原因是页面里的图片等大资源与动画同时加载。图片解码和动画渲染争抢 CPU 资源动画自然卡顿。解决办法是把非关键资源的加载延后到requestIdleCallback或者使用loadinglazy让动画优先占用资源。第四个原因也是最隐蔽的某个父级元素上有 continuously running 的transition或animation哪怕它在屏幕外的不可见区域也会持续消耗 GPU。建议对所有不可见区域display: none或visibility: hidden的元素主动暂停或移除动画。我还制作了一个速查表方便排查时对照现象可能原因解决方案动画开始时卡一下元素由静态变为动画态缺少合成层预分配添加will-change或transform: translateZ(0)动画中途掉帧动画属性触发了 Layout / Paint改用transform/opacity动画全部卡顿页面合成层过多移除不必要的will-change滚动和动画同时卡动画元素未独立成层给动画元素加contain: layout style移动端低端机型闪屏GPU 内存不足降低合成层数量简化动画5.2 动画显示不全与边界裁剪这个问题的典型场景是动画元素的某些部分在运行过程中消失了或者被切了一角。原因通常是元素被设置成了圆角或 overflow 裁剪导致内部动画元素在变换时被容器边界裁掉。一个典型的例子是涟漪扩散动画。通常实现方式是使用::after伪元素让它从 0 放大到整个容器大小同时透明度从 1 渐变到 0。但如果容器设置了overflow: hidden和border-radius: 50%伪元素放大到一定程度后就会贴着容器边界视觉上出现明显的生硬裁剪。解决办法是把伪元素做成一个向容器中心收缩的动画或者把伪元素从容器中移出挂在动画元素的兄弟节点上。还有一类情况是 CSS 动画中使用了transform-origin但没有设置在正确的位置导致动画元素的位移超出预期范围。调试这类问题最直接的方式是在 DevTools 的 Elements 面板里选中动画元素打开右上角的动画标签可以逐步查看每一帧的状态精确定位是哪一帧开始出现显示异常。5.3 外部动画库引入后性能反而变差的排查思路热词里出现了前端动画库和loading动画很多团队会选择引入现成的动画库来提效。但引入库之后页面变卡的情况也时有发生我的排查经验是这样的。第一步确认动画库的实现方式。有些动画库尤其是老牌库仍然依赖 JavaScript 逐帧操作 DOM 属性比如频繁修改style.left和style.top来实现位移动画。这类方式本身就带有较高的重排开销在任何设备上都会存在性能瓶颈。推荐选择基于 Web Animations API 或原生 CSS 动画实现的库性能表现会好很多。第二步检查是不是引入了过多未使用的内容。很多动画库是全量引入的包含几百个动画效果和配套的 JS 逻辑但页面只需要其中三五个。使用 Tree Shaking 或者改为按需引入可以有效减少初始 JS 体积和内存占用。第三步检查动画库是否创建了不必要的 DOM 包裹层。有些库为了让动画兼容性更好会在目标元素外面套一层容器这层容器会影响浏览器对合成层的判断。解法是尽量使用那些不改变 DOM 结构的轻量级库。我自己维护的一个后台管理项目中用过一个 loading 动画库单个 spinner 消耗的 CPU 持续在 10% 以上改用纯 CSS 自实现后降到了 2% 以内。所以对有明确性能指标的界面优先考虑原生 CSS 实现不要为了开发效率牺牲运行时性能。最后分享一点个人经验做了这么多 CS 动画优化我最大的感触是高性能动画最关键的不是技巧多花哨而是对渲染原理有底层的理解。理解了合成层就知道为什么transform比top快理解了重排就知道为什么不要在动画里改宽度和边距理解了 GPU 内存边界就知道为什么will-change不能滥用。另外在优化动画性能时一定要做真实的性能测量和对比不要只凭感觉。肉眼在低端设备上可能看不出明显的差异但这不代表优化无效。用 Performance 面板记录前后的帧率和耗时数据才能准确定量分析也才能说服产品经理和设计师接受某些动画效果的降级方案。把这些基础打扎实了写出来的动画自然会丝滑。这比追求各种奇技淫巧要可靠得多。
返回列表