Lottie 很好,但问题出在哪
Lottie 几乎是现在做前端动效的一个默认选项。设计师在 After Effects 里排好关键帧,用 Bodymovin 导出一个 JSON 文件,前端拿到后在页面上渲染成矢量动画。相比 GIF 或者视频,Lottie 看起来体积小、清晰度高,也能响应交互。可实际上,只要项目里的动画一多,或者某一帧比较复杂,Web 端就会开始出现掉帧、内存暴涨、首屏渲染被拖慢的情况。

很多团队会遇到一个问题:设计稿里的动画效果很漂亮,导出的 JSON 也只有几十 KB,但扔到页面上就是卡。这时候大家往往先去怀疑渲染器,怀疑代码写法,却很少有人注意到,真正的瓶颈从 AE 的工程文件里就已经开始了。Lottie 的优化链路不是从代码里开始的,而是从设计师的图层结构里开始的。
这篇文章不打算讲 Lottie 的基础用法,我想把从 AE 导出到运行时渲染这条完整链路梳理一遍,聊一聊每一阶段里真正影响性能的因素,以及不同规模的项目该怎么做取舍。
先说清楚 Lottie 在 Web 端的渲染链路
Lottie 在 Web 端的工作流程大概是这样:设计师在 AE 里做动画,借助 Bodymovin 插件把关键帧、形状路径、表达式、图层变换这些信息转换成一份 JSON 描述文档。前端拿到 JSON 后,由 lottie-web(或者 lottie-player 这类封装)把它解析成内部的数据结构,然后选择一种渲染器生成画面。
目前的渲染器主要有三种:SVG、Canvas、HTML。SVG 渲染器会把每一帧动画都创建成对应的 DOM 节点,缩放和交互很好做,但节点数量一多,DOM 开销就上去了;Canvas 渲染器是把每一帧画在画布上,适合图形复杂的场景,但代价是失去部分 DOM 交互能力,还要自己处理高清屏的缩放。HTML 渲染器现在用得很少,通常只适用于一些简单的 CSS 属性动画。
这里的一个关键认知是:Lottie 的性能瓶颈,往往不是 JSON 文件大小,而是每一帧需要绘制的路径点数量、图层数量,以及渲染器对图形的拆分方式。文件小只说明压缩得好,不代表渲染起来轻松。
举个例子,一个用位图图层做的动画,可能只有几百帧,但每一帧都是一张位图,在 Web 端会产生大量内存占用;而一个用纯形状图层画的写实人物动画,路径点可能有几万个,SVG 渲染时就会明显卡顿。这两种问题完全不在一个维度上。
从 AE 源文件开始:先学会给动画减重
很多人拿到设计稿后的第一反应是直接调导出参数,但如果 AE 工程本身就带着大量不健康的图层,后面再怎么优化也是有限的。我开始接 Lottie 之后的一个习惯,是先打开工程看时间轴上的图层结构。一个需要做复杂动效的 Lottie 动画,图层数量通常应该控制在 30 个以内,特殊情况下可以多一点。如果超过了五十个甚至上百个,就要考虑是不是设计上做了太多碎片化的小元素。
有几个常见的问题在 Web 端会被放大。
- 大量位图图层:Bodymovin 虽然支持位图,但每张图都会在运行时占用内存,而且缩放时容易发虚。能用形状画的尽量用形状。
- 复杂路径和大量锚点:一个圆角矩形生成的路径可能只需要很少的锚点,但如果设计师用了手绘笔刷或者贝塞尔曲线拖拽,路径上的锚点可能非常密集,渲染时节点数量会直接翻倍。
- 过长的合成和过多关键帧:关键帧数量对性能影响相对小,但重复的、无变化的关键帧会冗余在 JSON 里,白白增加解析时间。
- 各种效果器、表达式、粒子:部分 AE 效果 Bodymovin 根本不会导出,有些会导成另一种等价实现,但代价可能是性能爆炸。例如粒子系统。
所以我把这项工作称为 UI 动效的降维:把设计师习惯的像素级思维,转换成可描述的矢量图形。这里有个不一定受欢迎的结论:不是所有 AE 动画都适合做成 Lottie。如果某个效果真的需要粒子系统,或者大量位图合成,那直接用视频或者序列帧可能是更好的选择。
一个相对清晰的取舍标准可以这样判断:如果这个动画是 UI 交互反馈,持续事件短,元素不超过 20 个,并且是纯矢量图形,那 Lottie 非常适合;如果是一个需要全屏播放的品牌宣传动画,背景还带粒子,我建议你和设计师再聊一聊。
Bodymovin 导出:不是点一下导出就结束了
Bodymovin 插件的导出面板里有不少设置项,但实际项目里大部分人都只用了默认配置。其实这里藏着不少优化空间。
比较关键的几个设置:
- 压缩 JSON:Bodymovin 可以输出压缩过的 JSON,去掉多余的空格和换行。虽然对运行影响不大,但能减少网络传输体积。
- 禁用未使用的属性:如果某个图层完全没有使用表达式,可以关闭 expression 的导出。实际上 Bodymovin 会对表达式做二次解析,如果不需要,最好在导出前让设计师清理掉。
- 限制帧率:Bodymovin 默认会保留 AE 工程的帧率。有些设计稿用到了 60fps,但动画本身就是缓动效果,24fps 甚至 30fps 就能达到同样视觉体验。降低帧率对运行时性能有直接帮助。
- 去掉多余的 marker:如果你不需要用 marker 来触发事件,可以关掉。
需要额外注意的是,Bodymovin 导出的 JSON 文件里,每一个 shape 图层都会带有一组 paths 和 transforms,如果设计师用遮罩或者修剪路径做效果,实际上会生成大量额外的节点。所以导出前最好检查一下是否存在隐藏的高复杂度层。
这里给一个简单的示例,展示怎么在导出前检查 JSON 文件里的节点数量,至少心里有个数:
const fs = require('fs');
const data = JSON.parse(fs.readFileSync('animation.json', 'utf8'));
const layers = data.layers || [];
let shapeCount = 0;
layers.forEach(layer => {
if (layer.shapes) shapeCount += layer.shapes.length;
});
console.log('图层数:', layers.length, '形状数:', shapeCount);
如果形状数远超图层数,说明每个图层内部还有很多独立子路径,性能大概率会出问题。这时候应该回到 AE 里把图形合并,或者把多个形状合并到一个图层里用组管理。
运行时渲染:选对渲染器,比什么都重要
前面说的都是动画本身的复杂度,而当动画已经拿到手上以后,剩下的优化就都发生在浏览器里。这里第一个决策就是选 SVG 还是 Canvas。
SVG 的好处是清晰、可交互、样式可控,复杂一些的交互动画(例如 hover 变色、局部点击)都能轻松实现;Canvas 则胜在绘制大量锚点和复杂图形时开销低,因为它在单个画布上绘制,不会产生大量 DOM 节点。下面是两种渲染器在 Web 端实际使用中的差异:
| 对比维度 | SVG 渲染器 | Canvas 渲染器 |
|---|---|---|
| DOM 节点数 | 每个形状都会变成节点,动画复杂时容易卡 | 整个动画在一个画布上绘制,节点数量很少 |
| 视觉清晰度 | 矢量缩放,任何分辨率都清晰 | 位图绘制,需要处理 DPR,可能边缘发虚(Lottie Canvas 通常会自动处理) |
| 交互能力 | 方便绑定 DOM 事件,支持 CSS 操作 | 需要自己计算坐标,交互实现成本高 |
| 内存占用 | 节点多时容易触发重排,内存上升快 | 整体内存占用相对稳定,但单个图形大时也可能会高 |
| 适用场景 | 图标、按钮反馈、节点数少的简单动画 | 复杂形状、全屏动画、大量元素同时运动 |
从工程实践上说,我一般会把动画复杂度作为第一判断标准。如果形状数量在 500 以内,用 SVG 通常没有问题;但如果是一个有着上千路径的插画级动画,SVG 的 DOM 一定会拖垮页面。此时 Canvas 是更稳的选择。
lottie-web 切换渲染器很简单,甚至可以在运行时动态切换。不过我不建议一上来就动态切换,最好初始化时根据动画复杂度去选。
lottie.loadAnimation({
container: document.getElementById('anim'),
renderer: 'svg',
loop: true,
autoplay: true,
path: 'animation-1.json',
rendererSettings: {
scaleMode: 'noScale',
clearCanvas: true
}
});
这里有一个容易踩的坑:用 Canvas 时如果 clearCanvas 设为 false,动画的每一帧都会叠加在前一帧上,看起来会像残影。但如果你需要做一个轨迹展现,反而可以利用这一点。通常我们保持默认 true 就好。
真正的性能陷阱:内存泄漏与调度
很多时候动画本身的复杂度并不高,但页面还是会卡,问题往往出在动画的生命周期管理上。尤其在一个后台系统或者中大型单页应用里,路由切换时如果没有正确销毁 Lottie 实例,动画会一直占用内存,节点也不会回收。
lottie.destroy() 会释放动画实例,但前提是你在正确的地方调用它。我见过很多代码只在组件卸载时调用 destroy,却没有处理定时器、事件监听和播放器内部的状态。如果你的单页应用框架是 React 或者 Vue,最好在生命周期函数里做清理。
另一个常见问题是不可见的动画仍然在播放。页面滚动到下方,Lottie 还在后台逐帧渲染,这显然是一种浪费。可以用 IntersectionObserver 监视容器是否可见,可见时播放,不可见时暂停。
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
anim.play();
} else {
anim.pause();
}
});
});
observer.observe(container);
还有一点容易被忽略:Lottie 动画解析 JSON 是同步的。如果你的 JSON 文件有几百 KB,又恰好在主线程上同步加载,首屏渲染的时间会被明显拖长。解决方案是把 JSON 放在异步请求里,或者用 Web Worker 预先解析。lottie-web 官方也支持传入 animationData,配合异步加载可以获得更好的体验。
再往前一步:更务实的优化路径
如果已经尝试了上面的方法,动画仍然跑得不够顺,那就需要从更宏观的角度来优化了。
第一种思路是拆。把一个大动画拆成几个小动画,分别控制它们的播放时机。比如一个引导页有三个阶段,你完全可以让设计师把它们导成三份 JSON,页面滚动到相应区域时再去加载对应动画。这样既避免了首屏一次加载过多,又能在交互上更灵活。
第二种思路是降级。对低端设备或弱网环境,可以暂时用静态图或者 CSS 动画替代 Lottie。做降级的时候不需要复杂的技术,你只需要在初始化时判断一下设备的硬件信息或者网络状态。比如用 navigator.deviceMemory 或者 network.effectiveType 做简单判断。
第三种思路是预加载。动画通常在用户操作后才会出现,比如点赞后的粒子效果。你可以在页面空闲的时候提前拉取 JSON 并解析好,等用户真正点击时直接实例化。这对交互体验的提升非常明显。
如果项目里有很多 Lottie 动画,我建议建立一套统一的加载和缓存机制。同一份 JSON 不要每次都被重新请求和解析,可以把 animationData 缓存到一个全局 Map 里,下次使用时直接传入。这能省掉大量重复的解析开销。
总结:Lottie 优化的链路是一个整体
回头看整条链路,你会发现在 AE 里少画一个不必要的图层,比在代码里做一百次微优化都有效。这也是我写这篇文章最想表达的一个观点:Lottie 动画优化不是某一段的事,而是设计、导出、渲染、运行时生命周期管理整个链条的系统工程。
有几个基本的判断标准,你可以直接用在项目里:如果动画复杂但只播放几次,请考虑 Canvas 或者视频;如果动画很轻但需要频繁交互,SVG 往往更好;如果页面里有十多个 Lottie 实例,优先做好懒加载和缓存,而不是逐个去抠渲染参数。
最后想说,Lottie 本身只是一套工具,它不会让烂动画变好,只会让好动画更高效地运行。把这套链路里的每一个环节都理顺了,你手上拿着的就不再是一个黑盒,而是真正可控的动效资产。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/952/