前端动画分类与性能优化:逐帧、补间、形状、路径、遮罩选型指南

发布时间:2026/10/31 6:20:03
前端动画分类与性能优化:逐帧、补间、形状、路径、遮罩选型指南

做前端和可视化这些年,被问到最多的一类基础题就是“计算机动画到底分几种”。这题看着像课堂简答,实际在项目里天天要用:给页面加个加载动画、给数据看板做条趋势线、给产品做个转场、给游戏角色做套动作,你都得先判断“这活儿该用哪一类动画来实现”。判断错了,轻则代码难维护,重则掉帧卡成 PPT。这篇就围绕计算机五种常见动画——逐帧动画、补间动画、形状补间(变形动画)、路径动画、遮罩动画,把每一种的原理、特点、现代工程落地方式和性能账都算一遍。刚入门的朋友能拿到一套清晰的对照框架,已经做过项目的同行可以重点看参数计算和踩坑那几节,里面有几条是我改线上卡顿换来的经验,文档里不会写。

1. 先把分类逻辑捋清楚,别被不同教材的答案绕晕

1.1 为什么同一道题会有好几种标准答案

“五种常见动画”这个说法本身有歧义,因为分类的坐标轴不一样。按制作方式分,是逐帧、补间、形状补间、引导层、遮罩这老五位;按渲染原理分,会变成 DOM 属性动画、Canvas 重绘、WebGL 着色器动画;按驱动来源分,又能分成时间驱动、物理驱动、交互驱动。你搜到的答案五花八门,本质是大家站在不同的坐标轴上作答。

我在实际项目里更认第一种坐标轴,也就是**“画面是怎么动起来的”**。理由很实在:这个维度直接对应实现成本。逐帧意味着你要准备 N 张图,补间意味着你只准备两端让算法去插,遮罩意味着内容没动、动的只是可见范围。这几种的素材成本、CPU/GPU 开销、可维护性完全不在一个量级,选型时最有指导意义。所以下面全部按这条主线走,同时在每节里补上它在现代前端、可视化、游戏引擎里的对应形态。

先给一个粗略的对照,让你有个整体印象,后面再逐个拆开:

动画类型本质一句话素材成本计算开销典型场景
逐帧动画一张张画轮播最高最低表情、序列帧特效
补间动画只给两端,中间插值低低位移、缩放、透明度
形状补间顶点在两组形状间过渡中中图标变形、按钮状态切换
路径动画时间映射到一条曲线上的位置低中沿轨迹运动、引导线
遮罩动画内容不动,可见范围在动低视实现而定扫光、擦除转场、聚光灯

1.2 这五种动画的共同底层:时间到画面的映射

不管哪一种,扒到底层都逃不开一个公式:给定时间 t,算出这一刻的画面状态。逐帧是查表,t 落在第几帧就显示第几张图;补间是插值,t 归一化成 0 到 1 的进度 p,再代入缓动函数得到 s,最后用 s 在起点值和终点值之间做线性组合;路径是几何计算,p 转成弧长,再取曲线上的点;遮罩是合成,p 决定遮罩层的偏移或尺寸。

理解这一层之后你会发现,五种动画的差别其实只在“p 到画面的映射函数”长什么样。逐帧的映射函数是一张离散查找表,补间的映射函数是连续的多项式,形状补间是顶点坐标的线性组合,路径是参数曲线求值,遮罩是裁剪区域的几何变换。所以后面我讲每一种,都会尽量把这条映射链讲清楚,你换个框架、换门语言也能照着推。

提示:很多人学动画卡在“会用 API 但不会调参”,根子就在这里。API 只是帮你把 t 递进去,真正决定观感的是那条映射曲线。曲线想不明白,参数就只能瞎试。

2. 逐帧动画:把时间切成一张张画

2.1 原理与特点:最笨也最可靠的那一类

逐帧动画的思路朴素到不能再朴素:预先渲染好每一帧的图像,播放时按固定节奏一张张替换。老式动画片是一张张手绘,计算机里就是把序列图打包成图集,靠改变显示区域的偏移来切换。它的最大特点是表现力上限极高——任何复杂的光影、纹理、形变,只要画得出来就能播出来,不需要实时计算。同时它的下限也很明确:素材体积随帧数线性增长,而且几乎不具备可编辑性,改一帧就得重出整套图。

它的另一个隐性优点是渲染开销极低。因为每帧都是现成的位图,播放时只是换一张贴图或改一次背景偏移,GPU 几乎不需要做额外运算。这也是为什么很多低端设备上的特效、老式游戏里的角色动作、各类表情包,都还在用序列帧。帧率和流畅度的关系在这里是硬绑定:想做 30 帧的观感,素材就必须 30 帧,没有插值补救的空间。

我一般会把它归到“内容驱动”的一类——动画质量取决于美术产出,而不取决于程序逻辑。团队里如果没有稳定的美术产能,慎选这条路。

2.2 现代落地:steps() 配合精灵图是最省事的做法

网页端做逐帧,最轻的方案是用 CSS 的steps()把一段连续的背景位移动画切成离散台阶。核心思路是让背景图的横向偏移从 0 匀速走到总宽度,但每一步必须“跳”而不是“滑”,steps()干的就是这件事。

.sprite { width: 200px; height: 200px; background-image: url("./walk.webp"); background-repeat: no-repeat; /* 图集按 6 列 6 行排布,共 36 帧 */ background-size: 1200px 1200px; animation: play 3s steps(35) infinite; } @keyframes play { from { background-position: 0 0; } to { background-position: -1200px 0; } }

这里有几个容易写错的地方。steps(35)而不是steps(36),是因为首帧已经由from位置占掉了,写 36 会让最后一帧多停一格,观感上有个明显的“顿一下”。另外background-size必须显式写成整数倍,否则浏览器在缩放时会对齐到像素网格,产生一像素的缝隙抖动。

如果你的序列帧超过单行能容纳的张数,就得排成网格。这时候不能再用单一方向的background-position线性动画,得用多段关键帧手动指定每一格的偏移,或者干脆用 JavaScript 定时切类名。帧数一多,CSS 方案的代码量会迅速失控,这时候我会转向脚本驱动:

const frames = 36; const cols = 6; const size = 200; const img = document.querySelector(".sprite"); let i = 0; setInterval(() => { const x = -(i % cols) * size; const y = -Math.floor(i / cols) * size; img.style.backgroundPosition = `${x}px ${y}px`; i = (i + 1) % frames; }, 1000 / 12);

2.3 算一笔体积和显存的账

逐帧动画最容易被低估的是成本。假设你要做一个 3 秒、每秒 12 帧、单帧 200×200 的动画,总共 36 帧。单帧压到 15KB,整包就是 540KB,这已经相当于一张中等大小的首屏主图了。如果帧率提到 24,体积直接翻倍到 1MB 以上,移动端流量场景基本不能接受。

更隐蔽的是显存。图集打包后,单张纹理的边长不能超过设备的纹理上限,常见是 4096 或 8192 像素。36 帧 200×200 排成 6×6 网格是 1200×1200,没问题;但如果排成一行就是 7200×200,横向超过 4096,某些设备会直接贴图失败,画面上出现一块空白。所以永远排网格,不要排长条。

再算显存:一张 1200×1200 的 RGBA 纹理,占用 1200×1200×4 字节 ≈ 5.5MB。看起来不多,但页面上要是同时有十个这样的动画元素,55MB 就出去了,加上其他图层,低端机的内存压力会非常明显。

注意:序列帧的资源要按“同时存活的实例数”而不是“动画个数”来估。同一个图集被十个元素复用,浏览器通常只存一份;但如果你为了适配不同尺寸导出了三套分辨率,那就是三份,别漏算。

3. 补间动画:只画两端,中间交给算法

3.1 插值的本质和缓动曲线的意义

补间动画是日常用得最多的一类。它的逻辑是:给定起点状态和终点状态,再给一个时间进度 p(0 到 1),用公式算出中间状态。最朴素的线性插值是value = start + (end - start) * p。问题在于,真实世界的运动几乎都不是匀速的——门推开是慢起快停,球落地是加速,弹窗出现是带一点回弹。所以工程里会在线性插值外面套一层缓动函数,把线性的 p 映射成非线性的 s,再做插值。

缓动的价值不只是“好看”。人眼对匀速运动的判断很敏感:一个匀速弹出的对话框会显得机械、廉价;而带轻微加速的进场会让人觉得“有重量”。这也是为什么各家设计规范都会给一条标准曲线,常见的是cubic-bezier(0.4, 0, 0.2, 1)这种快进慢出的形状——起步稍快抓住注意力,收尾放慢让人看清结果。

理解贝塞尔曲线的读法很关键:四个参数对应两个控制点,第一个点控制起步速度,第二个点控制收尾速度。第一个点的 x 越小,起步越慢;第二个点的 x 越大,收尾越拖。参数全靠试是效率最低的做法,我更建议先定意图再调参数:要“干脆利落”就把时长压到 150 到 200 毫秒、曲线接近线性;要“从容优雅”就放到 300 到 400 毫秒、曲线两端都放缓。

3.2 CSS 与脚本两条路的取舍

CSS 的transition和animation是补间动画最省心的实现。它们跑在合成线程上,主线程被长任务卡住时也不容易掉帧,而且代码量极小:

.card { transition: transform 280ms cubic-bezier(0.4, 0, 0.2, 1), opacity 280ms cubic-bezier(0.4, 0, 0.2, 1); will-change: transform, opacity; } .card.is-active { transform: translate3d(0, -8px, 0) scale(1.02); opacity: 1; }

但 CSS 补间有明显的能力边界:它只能动“可插值的 CSS 属性”,一旦你需要根据滚动位置、鼠标位置、物理状态实时改变目标值,或者需要在动画过程中做逻辑判断,就得交给脚本。脚本方案的核心不是自己写setInterval,而是用requestAnimationFrame对齐屏幕刷新:

function tween(from, to, duration, onUpdate, easing = t => t * t * (3 - 2 * t)) { const start = performance.now(); function frame(now) { const p = Math.min((now - start) / duration, 1); onUpdate(from + (to - from) * easing(p)); if (p < 1) requestAnimationFrame(frame); } requestAnimationFrame(frame); }

requestAnimationFrame的回调会带上当前时间戳,用它算进度比用Date.now()更稳定,因为前者和屏幕刷新节奏对齐,后者会和渲染时机错位,导致进度跳变。这个细节很多手写动画的代码都忽略了,结果在 120Hz 屏上看起来一顿一顿的。

3.3 合成层:省性能的开关,也是内存的坑

补间动画最常见的性能优化手段是把transform和opacity单独提升成合成层,让它们绕开主线程的重排重绘。translate3d、will-change、backface-visibility都能触发这个行为。优化到位的话,一个元素的位移动画可以做到几乎零主线程开销。

但这一步用力过猛非常危险。每提升一个合成层,浏览器就要为它单独分配一块纹理缓冲,大小约等于元素的渲染面积乘以 4 字节。一个全屏 1920×1080 的图层就是 1920×1080×4 ≈ 8.3MB。你要是一口气给列表里五十个卡片都加上will-change,那就是四百多兆显存,移动端直接崩给你看。

我踩过最典型的一次坑,是给一个滚动列表的所有项都写了will-change: transform,本意是让它滑得更顺,实测结果是中低端机型上滚动反而更卡,因为内存吃紧后系统开始频繁回收纹理。后来改成只在:hover和焦点态上临时加,问题就没了。

提示:will-change的正确用法是“即将发生变化时加上,变化结束后移除”,而不是常驻。如果实在懒得管,宁可只用transform而不用will-change,现代浏览器对纯transform动画的自动优化已经够聪明了。

4. 形状补间:顶点数量决定了能不能平滑过渡

4.1 为什么有些形状过渡很顺,有些会“抽搐”

形状补间(也叫变形动画)是补间的一个特殊分支:它插值的不是位置、大小这类数值属性,而是形状本身的顶点坐标。听起来只是把数字换成坐标数组,实际难点在于两组形状必须“结构对齐”——顶点数量一致、线段数量一致、起始点位置对应。结构对不上的时候,算法只能靠自动补点去凑,凑出来的中间形态经常扭曲成一团。

举个直观的例子:从一个正方向一个五角星做变形。正方形有 4 个顶点,五角星有 10 个,直接插值根本无从下手,必须先给正方形加上 6 个额外顶点,还得保证这 6 个点落在合理位置,不然中间帧会往外翻。这就是为什么设计师给的图形,很多时候需要程序做一次顶点对齐才能用。

判断一个形状补间能不能做,我的经验是看三件事:子路径数量是否相同、每条路径的段数是否相同、起始顶点的朝向是否大致一致。三条都满足,基本能做出可用的过渡;缺一条,就得先做预处理。

4.2 SVG 路径变形的实操和预处理

网页上做形状补间,主流载体是 SVG 的路径属性。现代浏览器支持对d属性做过渡,前提是路径结构一致:

.icon path { transition: d 400ms cubic-bezier(0.4, 0, 0.2, 1), fill 400ms ease; } .icon.is-open path { d: path("M4 12 L12 20 L20 12 M4 4 L12 12 L20 4"); }

这段代码把两条折线互相变形。能这么写的前提是两条d的命令序列完全一致——都是M加两段L,坐标个数一一对应。如果你换成一个带三次贝塞尔C命令的路径,过渡会直接失效,属性值在动画中间被丢弃。

实际项目里更常见的是用clip-path的多边形做形状过渡,因为它对顶点一致性的要求是显式的,写起来更好控制:

.shape { clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%); transition: clip-path 500ms ease; } .shape:hover { clip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%); }

两条polygon()都是 4 个点,顺序对应——第一个点都在上方,第二点在右方,转的时候顶点各走各的路,不会交叉打架。如果你偷懒把第二个多边形写成 6 个点,浏览器会按“顶点数不同”处理,直接跳变。

形状补间的性能开销比普通补间高一个档次,因为每一帧都要重新计算路径几何、重新光栅化。缓存的收益也很低,因为每一帧的形状都不一样。所以我在项目里有个不成文的规矩:形状补间只做小面积、短时长的过渡,比如图标状态切换、按钮按下反馈,时长控制在 300 到 500 毫秒。全屏级别的形状变形,我宁愿退回遮罩或淡入淡出。

注意:形状补间在低端设备上是掉帧重灾区。如果你的变形元素面积超过视口的三分之一,务必在真机上测,不要只看开发机。测试时要打开性能面板看“光栅化”那一栏,如果每帧都有大块耗时,就该换方案了。

5. 路径动画:让时间沿着一条线走

5.1 弧长参数化:路径动画和补间动画的关键差别

路径动画的核心是:元素的位置由一条曲线决定,时间进度决定它走到曲线的哪个位置。听起来像是补间动画的变体,实际有一个本质区别——补间是在两点之间做线性组合,路径是沿着曲线求值。两点之间的直线插值和沿着弧线走,运动轨迹完全不同。

这里有个容易被忽略的技术细节:直接拿参数 t 去取曲线上的点,走起来是不匀速的。因为参数 t 和实际弧长不是线性关系——在曲率大、控制点密的地方,同样的 t 增量对应的实际距离更短,元素会“走不动”;在直段则会“冲出去”。所以正经的路径动画实现都会做弧长参数化:先把曲线按弧长重新采样成一张查找表,再按进度去查表取值。

浏览器已经把这件事做掉了。SVG 的getPointAtLength()就是按弧长取点,getTotalLength()给出总长:

const path = document.querySelector("#track"); const total = path.getTotalLength(); function move(p) { const pt = path.getPointAtLength(total * p); el.style.transform = `translate(${pt.x}px, ${pt.y}px)`; } // 配合 rAF 驱动 let start = performance.now(); function tick(now) { const p = Math.min((now - start) / 2400, 1); move(p); if (p < 1) requestAnimationFrame(tick); } requestAnimationFrame(tick);

getPointAtLength每次调用都要做一次几何求解,在密集路径上开销不低。如果路径复杂、调用频率又高(比如 60 帧每秒都在调),帧率会被拖下去。我的做法是启动时预采样 200 到 400 个点缓存成数组,运行时只做数组查值和插值,帧率立刻回稳。

5.2 声明式路径动画:一行属性解决大部分需求

如果不需要在动画过程中做逻辑判断,用 CSS 的路径运动属性会简单得多:

.dot { offset-path: path("M20 120 C 120 20, 240 220, 340 100"); offset-distance: 0%; offset-rotate: auto; animation: run 2s linear infinite; } @keyframes run { to { offset-distance: 100%; } }

offset-path接受一条路径,offset-distance表示走到百分之多少,offset-rotate: auto会让元素自动沿切线方向旋转——做小球沿轨道运动、箭头沿曲线指引这类效果,三行就能搞定,不用写一行脚本。

支持度上要留个心眼。这个属性在不同内核的实现程度不一致,尤其是老版本移动端,可能被忽略、元素停在原点。我的兜底写法是给它一个初始的绝对定位坐标,属性不生效时至少位置是对的,只是不动,比飞到屏幕外好收拾。

路径动画还有一个很实用的场景是做��线引导的进度反馈。比如一条 SVG 折线代表流程,用stroke-dasharray加stroke-dashoffset做“线条逐渐画出来”的效果,本质也是沿路径推进,只不过推进的是可见长度而不是元素位置。这两个效果经常一起用,视觉上会有很强的引导感。

6. 遮罩动画:动的不是内容,是可见范围

6.1 遮罩和裁剪的差别,决定了它们的性能表现

遮罩动画的思路换了个角度:内容本身完全静止,动的是“哪里能看见”。它有两种实现,行为差别很大。

一种是裁剪,用矢量形状硬性规定可见区域,区域外的像素直接不渲染。它的边缘是硬边,没有过渡,好处是几何简单、计算便宜,而且浏览器可以只渲染可见部分。

另一种是遮罩,用一张灰度图或渐变当“可见度地图”,白色完全不透明、黑色完全透明、中间的灰色做半透明过渡。因为涉及逐像素的 alpha 混合,它必须先把遮罩层和内容层各自光栅化,再合成,开销明显更大,但它能做出柔和的边缘、渐变擦除、不规则漏出的效果,表现力是裁剪比不了的。

这个区别在选型时非常关键。需要硬边形状(圆形扩散、斜切转场、六边形头像),用裁剪;需要柔和过渡(扫光、渐隐擦除、聚光灯边缘),用遮罩。

6.2 两种写法的实操与常见坑

裁剪的典型用法是做圆形扩散转场:

.reveal { clip-path: circle(0% at 50% 50%); animation: expand 600ms cubic-bezier(0.4, 0, 0.2, 1) forwards; } @keyframes expand { to { clip-path: circle(75% at 50% 50%); } }

这里75%的选择有讲究:它必须足够覆盖视口的对角线半径,否则四个角会露不干净。粗略算法是“视口宽高的平方和开根号再除以二”,再留 5% 到 10% 的余量。很多人在这里随手写个50%,结果大屏上四角始终有一块没盖上。

遮罩做扫光效果更常见:

.shine { position: relative; overflow: hidden; } .shine::after { content: ""; position: absolute; inset: 0; background: linear-gradient(100deg, rgba(255,255,255,0) 40%, rgba(255,255,255,0.55) 50%, rgba(255,255,255,0) 60%); mask-image: linear-gradient(#000, #000); animation: sweep 2.4s ease-in-out infinite; } @keyframes sweep { from { transform: translateX(-120%); } to { transform: translateX(120%); } }

注意这里动的是transform不是遮罩本身的尺寸。这是个很重要的经验:遮罩层的尺寸变化会触发每帧重新光栅化,而平移一个已经存在的遮罩层通常可以走合成路径,性能差距在移动端能到两三倍。同理,如果你想做“遮罩逐渐变大”的效果,先想想能不能改成“内容在固定的遮罩下平移”,实在不行再用缩放,并且把遮罩层的分辨率压到够用就行。

注意:遮罩相关的属性在不同内核下还有前缀写法的问题,做兼容时两套都要写。另外遮罩区域如果超出元素边界,部分实现会做额外的裁剪计算,把遮罩范围限制在元素内部能省一点。

7. 五种动画怎么选:一张表加几条判断经验

7.1 完整对照表

前面拆完五种,这里汇总成一张可以直接拿去用的表。注意“首选”一栏是我按项目经验给的建议,不是绝对结论,实际还得看内容形态。

维度逐帧动画补间动画形状补间路径动画遮罩动画
驱动参数帧序号数值进度顶点坐标进度弧长进度可见区域变换
素材量高,随帧数线性增长极低中,需两组同构形状低,一条路径低
计算开销极低低(走合成更省)中高中中到高
可编辑性差,改一帧要重出图极好中好好
表现力上限极高中中中高
首选场景表情、复杂特效、角色动作位移、缩放、透明度、状态切换图标变形、按钮反馈沿轨迹运动、线条绘制扫光、擦除转场、聚光灯
主要风险体积和显存合成层过多顶点不一致导致跳变老内核属性不生效每帧重光栅化

顺带说一句常被混淆的几类:三维动画、骨骼动画、粒子动画。它们不是和上面五种并列的第六、第七、第八种,而是跨维度的组合形态。骨骼动画本质是大量骨骼节点上的补间,再叠加蒙皮计算;粒子动画是成百上千个微小元素各自跑一段补间或路径;三维动画则是把上述所有机制放到三维空间里再做投影。你把上面五种吃透,这几类拆开看都不陌生。

7.2 我的实际判断顺序

拿到一个动效需求,我通常按这个顺序问自己四个问题:

第一,这个动效的形态是不是“画出来的”?如果是手绘质感、复杂光影、逐格变化的表情动作,别犹豫,直接走逐帧。硬要用代码算,最后大概率是又慢又丑。

第二,动的是不是“同一个元素的位置、大小、透明度”?是的话就走补间,这是成本最低、兼容最好、最容易维护的路。能补间解决的,绝不上别的方案。

第三,动的是不是“形状本身”?那要评估两组形状能不能对齐。对得上就走形状补间,对不上就先做预处理,或者退回“淡入淡出加位移”这种折中方案。我的经验是,形状补间在图标级别的尺寸下效果很好,超过 200 像素见方就容易露怯。

第四,动的是不是“沿一条固定轨迹”或者“可见范围”?前者走路径,后者走遮罩。这两个属于特定场景的专用工具,用对了很省事,用错了很难调。

这里补一条很实用的经验:同一个动效往往可以落到不止一种实现上,这时候按“可维护性优先”来选。比如一个图标从“汉堡菜单”变成“关闭叉号”,既可以用逐帧切两张图,也可以用形状补间做顶点过渡。前者素材成本高但代码简单,后者代码稍复杂但改起来方便。如果这个图标在项目里会反复调形态,我选形状补间;如果它一辈子就这两个状态,逐帧切图反而更省心。

8. 踩坑与排查实录:那些文档里不会写的经验

8.1 常见问题速查表

这些问题我在不同项目里反复遇到过,整理成表方便对照。

现象大概率原因排查动作处理办法
逐帧动画最后一帧多停一下步数参数比实际帧数多一数清图集帧数步数减一,或让关键帧终点对齐
图集在部分设备上显示空白单张纹理超出边长上限看打包后的图片尺寸重排成网格,控制在 4096 以内
补间动画卡顿但 CPU 不高触发的是布局属性而非合成属性性能面板看“布局”耗时改成 transform 和 opacity
加了 will-change 反而更卡合成层过多导致显存吃紧看图层数量和内存占用只在交互瞬间临时加,用完移除
形状变形中间帧扭曲两组路径顶点数或段数不一致打印两条 d 的指令序列补点对齐,或改用统一的多边形结构
路径动画走得不匀速用参数 t 直接取点而非弧长检查是否按长度取值改用按长度取点,或预采样查表
遮罩动画掉帧每帧都在改遮罩尺寸看光栅化耗时改成平移遮罩层,不动尺寸
动画在 120Hz 屏上顿挫用固定时间间隔而非屏幕刷新检查是否用了 rAF改用 requestAnimationFrame 驱动

8.2 三条真金白银换来的经验

第一条,动画的时长不是越短越好,也不是越长越好,而是要和位移距离匹配。同样 200 毫秒,让一个元素移动 20 像素很自然,移动 800 像素就像瞬移,人眼根本跟不上。我自己有个粗略的经验值:短距离(小于 100 像素)用 150 到 200 毫秒,中距离(100 到 400 像素)用 250 到 350 毫秒,跨屏级的位移放到 400 到 500 毫秒,并且一定要配缓动,线性会让人觉得很飘。

第二条,动画的验证必须在真机上做,而且要开性能面板看,不能靠肉眼看。开发机性能富余,很多开销被掩盖了。我的习惯是:只要这个动画面积超过视口三分之一,或者会持续播放超过 3 秒,就必须在低端设备上跑一遍,重点看主线程有没有超过 16 毫秒的长任务。60 帧的预算是 16.67 毫秒一帧,一旦某帧的主线程活儿超过这个数,就会掉帧。这个数字要刻在脑子里,很多“感觉有点卡”的问题一算就清楚了。

第三条,能用一种动画解决的,不要混着用。我见过一个转场同时叠了位移补间、遮罩擦除和路径运动,视觉上确实热闹,但调试的时候根本定位不到是哪个环节拖慢的,改一个参数另外两个的节奏全乱。后来把它压回单一的遮罩擦除,观感反而更干净,代码从两百多行降到三十行。动画的克制不是偷懒,是让每一处运动都有明确的表达目的。

最后分享一个小技巧。如果你拿不准某个动效该用哪种方案,最快的判断方式不是想,而是做一个 30 秒的粗版:逐帧就用几张关键图快速切,补间就写三行 CSS 过渡,遮罩就拉一个渐变试试。这几种的粗版成本都极低,做出来在真机上扫一眼,哪个“感觉对”基本立刻能判断。比坐在那儿推演半小时效率高得多,我这些年大部分动画方案都是这么定下来的。