Lottie Web动效基本原理
前段时间在《Lottie Web动效在React中的构建》一文中和大家聊了如何通过lottie-web将AE导出来的JSON文件自动生成动效。在该文中,聊的主要是设计软件Figma、Sketch和AE软件之间如何通过相关的插件完成设计资源的互通,并从AE导出动效相关的JSON文件。这些都是最基本的操作链路,但在使用lottie-web还有相应的API可用来控制Lottie动效。今天这篇文章我们主要来聊聊Lottie Web动效的基本原理。
回忆Lottie Web动效的实现
其实在《Lottie Web动效在React中的构建》中详细介绍了如何使用Lottie Web实现AE导出来的动效。这里简单地回顾一下。
import bodymovin from "https://cdn.skypack.dev/lottie-web";
var animation = bodymovin.loadAnimation({
// 动效加载到的DOM元素
container: document.getElementById("lottie"), // 必须项
// 包含动效的JSON文件的相对路径,也可以是绝对路径
path:
"https://static.fedev.cn/sites/default/files/blogs/2021/2102/lottie-web.json",
// 必须项,描述动效的JSON文件,一般由AE软件中导出的JSON文件
// 动效渲染出来的格式,可以是svg、canvas和html
renderer: "svg",
// 必须项,除了svg选择之外还可以是canvas和html
// 用于指定动效是否循环播放,true是循环播放,false不是循环播放
loop: true, // 可选项
// 指定动效是不是加载后就立即播放,true是立即播放,false不是立即播放
autoplay: true, // 可选项
// 指定动效的名称
name: "mic animation" // 可选项
// animationData与path互斥,是一个包含导出的动画数据的对象
// animationData: { // ... }
});
效果如下:
特别声明,示例中的动效的JSON文件来自于lottiefiles.com网站上 @Arjun Matail 的作品。这里的示例除了提供的动效相关的JSON文件之外还提供了AE的原始文件。
Lottie Web相关API
尝试着使用console.log()输出loadAnimation()对应的API。基于上面的示例,像下面来输出:
console.log(animation);
在Chrome浏览器开发者工具中,可以看到输出的相关参数:

其中.loadAnimation()有几个重要的参数:
container:用来放置Lottie动效的容器,即渲染容器,一般可以使用一个带有id的div元素,也要以是其他的有效HTML容器元素renderer:渲染器,即渲染的动画是什么格式,目前主要支持svg、canvas和html三种格式,其中svg的兼容性最佳。上面的示例就是以svg的格式渲染的name:动画名称,主要用于reference,名称建议尽要能的命名成具有语义化的loop:用来定义渲染出来的Lottie Web播放次数,是一个布尔值,如果取值为true,表示无限次的播放,反之则只播放一次autoplay:定义动效是不是自动播放,也是一个布尔值,如果取值为true,则表示加载之后就立即播放,反之则不是立即播放path:用来指定Lottie Web动效的JSON文件路径animationData:可以用来替代path引入的JSON数据,简单地说,可以将path引入的JSON数据放置到animationData中,但不建议这样做,更建议使用path来引入动效对应的JSON数据,因为animaionData会将数据打包进来,使得JS的Bundle过大
另外还提供了一些控制动效的API。比如:
stop:用来停止动画play:用来播放动画pause:停止动画播放setSpeed(speed): 数据类型是Number,设置动效播放速度,1表示1倍速度,0.5表示0.5倍速度setDirection(direction): 数据类型是Number,设置播放方向,1表示正向播放,-1表示反向播放goToAndStop(value, isFrame):跳到某一帧或某一秒停止,第二个参数iFrame为是否基于帧模式还是时间,默认为falsegoToAndPlay(value, isFrame):跳到某一帧或某一秒开始,第二个参数iFrame为是否基于帧模式还是时间,默认为falseplaySegments(segments, forceFlag): 播放片段,参数1为数组,两个元素为开始帧和结束帧;参数2指的是否立即播放片段,还是等之前的动画播放完成destroy: 动效销毁setSubframe(useSubFrames):如果useSubFrames值为false,它将使用AE原始帧数;如果为true,它将在每个requestAnimationFrame上用中间值更新。默认为truegetDuration(inFrames):inFrames为true,则以帧为单位返回持续时间;如果为false,则以秒为单位返回持续时间
另外有几个全局的lottie方法会影响所有的动画:
lottie.play():使用可选的name参数,用于指定一个特定的动画播放lottie.stop():使用可选的name参数,用于指定一个特定的动画停止lottie.goToAndStop(value, isFrame, name):将指定名称播放的动画移动到定义的时间。如果name被省略,将移动所有的动画实例lottie.setSpeed(): 第一个参数speed(1表示正常事度),另外有一个name为可选参数,用来指定动画名称lottie.setDirection(): 第一个参数direction(1表示正常方向,即正向),另外有一个name为可选参数,用来指定动画名称lottie.searchAnimations(): 查找带有lottie或bodymovin类的元素lottie.loadAnimation(): 单独返回一个控件的动画实例lottie.destroy(name): 销毁指定名称(name)的动画。如果省略name,则销毁所有动画实例。DOM元素将被清空lottie.registerAnimation(): 可以直接使用registerAnimation注册一个元素,它必须有data-animation-path属性指向的JSON数据(JSON对应的url)lottie.getRegisteredAnimations(): 返回所有动画实例lottie.setQuality():用来设置动画的质量,默认为high,要以传high、medium、low或>1来提高性能lottie.setLocationHref():设置带有id的<svg>元素的相对位置lottie.freeze(): 冻结所有正在播放的动画或将要加载的动画lottie.unfreeze(): 解冻所有动画lottie.inBrowser(): 如果库正在浏览器中运行,则为truelottie.resize(): 整所有动画实例的大小
Lottie Web动画也有一些事件方法,比如onComplete、onLoopComplete、onEnterFrame、onSegmentStart,除此之外还可以通过addEventListener来监听以下事件:
completeloopCompleteenterFramesegmentStartconfig_ready(初始配置完成时)data_ready(当动画的所有部分都已加载)data_failed(当部分动画无法加载时)loaded_images(当所有的图像加载都成功或错误时)DOMLoaded(元素已添加到DOM)destroy
有关于这部分更详细的介绍,可以参阅Lottie Web的官方文档。
在上面的示例基础上,我们添加三个按钮,在click事件中分别给animation动画加上.play()(播放动画)、.pause()(中止动画播放,暂停)和.stop()(停止播放动画):
PlayHander.addEventListener("click", () => {
animation.play();
});
PauseHander.addEventListener("click", () => {
animation.pause();
});
StopHander.addEventListener("click", () => {
animation.stop();
});
Lottie Web的JSON文件
在前面的示例中,.loadAnimation(obj)的obj中有一个重要的参数,那就是path或animationData,用来指定Lottie Web动画所需的数据,即JSON。那么JSON文件如何获得呢?
如果你只是想尝试着使用Lottie Web动效,需要一个测试性的JSON文件,那么很简单,只需要在LottieFiles市场上获取AE动效的JSON文件。如果你是在项目是使用Lottie Web制作动效,则需要AE导出的JSON文件。因为你或你的设计师很有可能是使用AE软件来制作动效。然后使用AE的插件导出JSON文件:

你可以点击这里下载AE导出来的JSON文件。打开该JSON文件,可以看到JSON数据:

看到上图这样的JSON数据,一脸懵逼吧!
简单地来说一下JSON数据中的重要参数。在AE软件中,我们可以在创建合层或已有的合层上进行设置,如下图所示:

在弹出的合成配置面板中,可以看到一些配置参数的设置,比如“宽度”、“高度”、“帧率”等:

这些基本配置参数和JSON数据中的一些Key是有对应关系的:

- ①
nm:合层的名称 - ②
w:宽度 - ③
h:高度 - ④
fr:帧速率 - ⑤
ip: 开始帧 - ⑥
op: 结束帧 - ⑦
ddd: 是事为3d - ⑧
assets: 静态资源信息 - ⑨
layers:图层信息 - ⑩
v:Bodymovin插件版本号
其中fr、ip 和 op 是Lottie动画中最为重要的参数,只不过Lottie动画中是有帧率来计算动画时间的,比如JSON文件中的fr是29.6409606933594,op是48.9999350215785。
另外,Lottie动画还有assets和layers两个关键属性,这两个关键属性对应AE设计软件中的:

其中assets(静态资源)主要包括:
imagecharsprecomp
这三种静态资源中,chars是不常用的。对于image静态资源,其对应的类型压缩:
{
"id": "1",
"w": 130,
"h": 487,
"u": "",
"p": "",
"e": 1
}
这里的关键属性是p,指的是图像资源的路径,它可以是相对路径,也可以是绝对路径,还可以是Base64。个人更建议使用Base64,更利于JSON文件的携带,不过会增加JSON文件大小;如果采用图片的路径(相对或绝对),不会额外增加JSON文件大小,但会增加额外的网络请求。
layers在AE设计软件中主要有三个区域:
- 内容区域:包括形状图层的大小、位置、圆度等信息
- 变换区域:包含五个变换属性(锚点、位置、缩放、旋转和不透明度)
- 帧

可以在对应的JSON数据中的layers:

Lottie如何将JSON数据动起来
使用Lottie Web制作动效最大的优势是能将AE导出的JSON数据(描述动效的JSON数据)转换成Web动效。那么是怎么将JSON数据动起来呢?我们分成几个阶段来介绍。
初始化渲染器
从前面的示例中我们可以得知Lottie Web通过loadAnimation方法来初始化动画。渲染器初始化流程经过下图这样的过程:

可以查看lottie-web中player/js/animation中的AnimationManager.js:
// 注册动画
function setupAnimation(animItem, element) {
// 事件监听
animItem.addEventListener('destroy', removeElement);
animItem.addEventListener('_active', addPlayingCount);
animItem.addEventListener('_idle', subtractPlayingCount);
// 注册动画
registeredAnimations.push({ elem: element, animation: animItem });
len += 1;
}
// 加载动画(加载AE导出的JSON数据)
function loadAnimation(params) {
// 生成当前动画实例
var animItem = new AnimationItem();
// 注册动画
setupAnimation(animItem, null);
// 初始化动画实例参数
animItem.setParams(params);
return animItem;
}
Lottie Web渲染器的代码(查看lottie-web中player/js/animation中的AnimationItem.js):
AnimationItem.prototype.setParams = function (params) {
if (params.wrapper || params.container) {
this.wrapper = params.wrapper || params.container;
}
var animType = 'svg';
if (params.animType) {
animType = params.animType;
} else if (params.renderer) {
animType = params.renderer;
}
// 根据开发者配置选择渲染器
switch (animType) {
case 'canvas':
this.renderer = new CanvasRenderer(this, params.rendererSettings);
break;
case 'svg':
this.renderer = new SVGRenderer(this, params.rendererSettings);
break;
default:
// HTML类型
this.renderer = new HybridRenderer(this, params.rendererSettings);
break;
}
this.imagePreloader.setCacheType(animType, this.renderer.globalData.defs);
this.renderer.setProjectInterface(this.projectInterface);
this.animType = animType;
if (params.loop === ''
|| params.loop === null
|| params.loop === undefined
|| params.loop === true) {
this.loop = true;
} else if (params.loop === false) {
this.loop = false;
} else {
this.loop = parseInt(params.loop, 10);
}
this.autoplay = 'autoplay' in params ? params.autoplay : true;
this.name = params.name ? params.name : '';
this.autoloadSegments = Object.prototype.hasOwnProperty.call(params, 'autoloadSegments') ? params.autoloadSegments : true;
this.assetsPath = params.assetsPath;
this.initialSegment = params.initialSegment;
if (params.audioFactory) {
this.audioController.setAudioFactory(params.audioFactory);
}
// 渲染器初始化参数
if (params.animationData) {
this.configAnimation(params.animationData);
} else if (params.path) {
if (params.path.lastIndexOf('\\') !== -1) {
this.path = params.path.substr(0, params.path.lastIndexOf('\\') + 1);
} else {
this.path = params.path.substr(0, params.path.lastIndexOf('/') + 1);
}
this.fileName = params.path.substr(params.path.lastIndexOf('/') + 1);
this.fileName = this.fileName.substr(0, this.fileName.lastIndexOf('.json'));
assetLoader.load(params.path, this.configAnimation.bind(this), function () {
this.trigger('data_failed');
}.bind(this));
}
};
来看流程图中几个重要的部分:
AnimationItem: 它是Lottie Web动画的基类,loadAnimation方法会生成一个AnimationItem实例并返回,我们在使用loadAnimation方法配置Lottie Web动画相关的参数都是来于这个类animItem:使用new AnimationItem()生成animItem实例后,调用setupAnimation方法setupAnimation: 该方法首先监听了destroy、_active和_idle三个事件等待被触发。由于可以多个动画并行,因此定义了全局的变量len、registeredAnimations等,用于判断和缓存已注册的动画实例setParams: 接下来调用animItem实例的setParams方法来初始化动画参数,其中最重要的是选择动画的渲染器
在AnimationItem.prototype.setParams设置了三种渲染模式,因此Lottie Web提供了 SVG、Canvas 和 HTML 三种渲染模式,其中默认是HTML模式。不过,一般使用SVG或Canvas渲染模式。
switch (animType) {
case 'canvas':
this.renderer = new CanvasRenderer(this, params.rendererSettings);
break;
case 'svg':
this.renderer = new SVGRenderer(this, params.rendererSettings);
break;
default:
this.renderer = new HybridRenderer(this, params.rendererSettings);
break;
}
Lottie Web中每个渲染器都有各自的实现方式,而且复杂度也各有不同,但是动画越复杂,其对性有的消耗也就越高。我们可以在lottie-web的player/js/renderers找到对应渲染器的源码:

初始化动画属性,加载静态资源
这里我们只看SVG渲染器。lottie-web调用SVGRenderer渲染器之后,会调用configAnimation方法来初始化SVG渲染器。我们可以在lottie-web的player/js/animation文件夹的AnimationItem.js文件中找到configAnimation方法对应的代码:
// 渲染器初始化
AnimationItem.prototype.configAnimation = function (animData) {
if (!this.renderer) {
return;
}
try {
this.animationData = animData;
// 总帧数
if (this.initialSegment) {
this.totalFrames = Math.floor(this.initialSegment[1] - this.initialSegment[0]);
this.firstFrame = Math.round(this.initialSegment[0]);
} else {
this.totalFrames = Math.floor(this.animationData.op - this.animationData.ip);
this.firstFrame = Math.round(this.animationData.ip);
}
// 渲染器初始化参数
this.renderer.configAnimation(animData);
if (!animData.assets) {
animData.assets = [];
}
this.assets = this.animationData.assets;
// 帧率
this.frameRate = this.animationData.fr;
this.frameMult = this.animationData.fr / 1000;
this.renderer.searchExtraCompositions(animData.assets);
this.trigger('config_ready');
// 加载静态资源
this.preloadImages();
this.loadSegments();
this.updaFrameModifier();
// 等待静态资源加载完毕
this.waitForFontsLoaded();
if (this.isPaused) {
this.audioController.pause();
}
} catch (error) {
this.triggerConfigError(error);
}
};
// 等待资源加载
AnimationItem.prototype.waitForFontsLoaded = function () {
if (!this.renderer) {
return;
}
if (this.renderer.globalData.fontManager.isLoaded) {
// 检查加载完毕
this.checkLoaded();
} else {
setTimeout(this.waitForFontsLoaded.bind(this), 20);
}
};
AnimationItem.prototype.checkLoaded = function () {
if (!this.isLoaded
&& this.renderer.globalData.fontManager.isLoaded
&& (this.imagePreloader.loaded() || this.renderer.rendererType !== 'canvas')
) {
this.isLoaded = true;
dataManager.completeData(this.animationData, this.renderer.globalData.fontManager);
if (expressionsPlugin) {
expressionsPlugin.initExpressions(this);
}
// 初始化所有元素
this.renderer.initItems();
setTimeout(function () {
this.trigger('DOMLoaded');
}.bind(this), 0);
// 渲染第一帧
this.gotoFrame();
// 自动播放
if (this.autoplay) {
this.play();
}
}
};
在这个方法中将会初始化更多动画对象的属性,比如总帧数totalFrames、帧率frameMult等。然后加载一些其他资源,比如图像、字体等。
同时在waitForFontsLoaded方法中等待静态资源加载完毕,加载完毕后便会调用SVG渲染器的initItems方法(初始化所有图层),接着绘制动画。
在checkLoaded方法中可以看到,通过initItems初始化所有元素后,便通过gotoFrame渲染第一帧,如果开发者在loadAnimation配置了autoplay为true,则会直接调用play方法播放动画。
整个流程如下图所示:

绘制动画初始图层
初始化图层方法initItems主要调用buildAllItems创建所有图层。buildItem方法又会调用createItem确定具体图层类型。在lottie-web的player/js/renderers文件夹的BaseRenderer.js文件,找到buildAllItems和createItem对应的源码:
BaseRenderer.prototype.initItems = function () {
if (!this.globalData.progressiveLoad) {
// 初始化图层
this.buildAllItems();
}
};
BaseRenderer.prototype.buildAllItems = function () {
var i;
var len = this.layers.length;
for (i = 0; i < len; i += 1) {
this.buildItem(i);
}
this.checkPendingElements();
};
BaseRenderer.prototype.createItem = function (layer) {
// 根据图层类型,创建相应的SVG元素类的实例
switch (layer.ty) {
case 0:
// 合层
return this.createComp(layer);
case 1:
// 固态
return this.createSolid(layer);
case 2:
// 图片
return this.createImage(layer);
case 3:
// 兜底元素
return this.createNull(layer);
case 4:
// 形状
return this.createShape(layer);
case 5:
// 文字
return this.createText(layer);
case 6:
// 音频
return this.createAudio(layer);
case 13:
// 摄像机
return this.createCamera(layer);
default:
return this.createNull(layer);
}
};
在制作动画时,设计师操作的图层元素有很多种,比如图像、形状、文字、音频等。所以layers中每个图层会有一个ty字段。比如示例的JSON数据layer中的0和1的ty都是4,对应就是“形状”:

如果用流程图来描述的话,大致如下图所示:

对于每种图层类型的渲染,可以在lottie-web的player/js/elements中找到:

比如图像类型:
// lottie-web/player/js/elements/ImageElement.js
function IImageElement(data, globalData, comp) {
this.assetData = globalData.getAssetData(data.refId);
this.initElement(data, globalData, comp);
this.sourceRect = {
top: 0,
left: 0,
width: this.assetData.w,
height: this.assetData.h,
};
}
extendPrototype([
BaseElement,
TransformElement,
SVGBaseElement,
HierarchyElement,
FrameElement,
RenderableDOMElement
], IImageElement);
IImageElement.prototype.createContent = function () {
var assetPath = this.globalData.getAssetsPath(this.assetData);
this.innerElem = createNS('image');
this.innerElem.setAttribute('width', this.assetData.w + 'px');
this.innerElem.setAttribute('height', this.assetData.h + 'px');
this.innerElem.setAttribute('preserveAspectRatio', this.assetData.pr || this.globalData.renderConfig.imagePreserveAspectRatio);
this.innerElem.setAttributeNS('http://www.w3.org/1999/xlink', 'href', assetPath);
this.layerElement.appendChild(this.innerElem);
};
IImageElement.prototype.sourceRectAtTime = function () {
return this.sourceRect;
};
Lottie Web动画播放
Lottie Web动画播放主要是使用AnimationItem实例的play方法。如果开发者在loadAnimation配置了autoplay为true时会在所有初始化工作准备完毕后直接调用play方法。否则由开发者主动调用play方法播放。
// lottie-web/player/js/animation/AnimationItem.js
AnimationItem.prototype.play = function (name) {
if (name && this.name !== name) {
return;
}
if (this.isPaused === true) {
this.isPaused = false;
this.audioController.resume();
if (this._idle) {
this._idle = false;
this.trigger('_active');
}
}
};
play方法主要触发了_active事件,这个_active事件在动画初始化时注册的:
// lottie-web/blob/master/player/js/animation/AnimationManager.js):
// 注册动画
function setupAnimation(animItem, element) {
// 事件监听
animItem.addEventListener('destroy', removeElement);
animItem.addEventListener('_active', addPlayingCount);
animItem.addEventListener('_idle', subtractPlayingCount);
// 注册动画
registeredAnimations.push({ elem: element, animation: animItem });
len += 1;
}
function addPlayingCount() {
playingAnimationsNum += 1;
activate();
}
function activate() {
if (!_isFrozen && playingAnimationsNum) {
if (_stopped) {
// 触发第一帧渲染
window.requestAnimationFrame(first);
_stopped = false;
}
}
}
触发后通过调用requestAnimationFrame方法,不断的调用resume方法来控制动画。
// lottie-web/player/js/animation/AnimationManager.js
function first(nowTime) {
initTime = nowTime;
// requestAnimationFrame 每次都进行计算修改 DOM
window.requestAnimationFrame(resume);
}
而resume方法的主要逻辑如下:
// lottie-web/player/js/animation/AnimationManager.js
function resume(nowTime) {
// 两次 requestAnimationFrame 间隔时间
var elapsedTime = nowTime - initTime;
var i;
for (i = 0; i < len; i += 1) {
registeredAnimations[i].animation.advanceTime(elapsedTime);
}
initTime = nowTime;
if (playingAnimationsNum && !_isFrozen) {
window.requestAnimationFrame(resume);
} else {
_stopped = true;
}
}
// lottie-web/player/js/animation/AnimationItem.js
AnimationItem.prototype.setCurrentRawFrameValue = function (value) {
this.currentRawFrame = value;
this.gotoFrame();
};
AnimationItem.prototype.gotoFrame = function () {
this.currentFrame = this.isSubframeEnabled ? this.currentRawFrame : ~~this.currentRawFrame; // eslint-disable-line no-bitwise
if (this.timeCompleted !== this.totalFrames && this.currentFrame > this.timeCompleted) {
this.currentFrame = this.timeCompleted;
}
this.trigger('enterFrame');
this.renderFrame();
};
AnimationItem.prototype.renderFrame = function () {
if (this.isLoaded === false) {
return;
}
try {
this.renderer.renderFrame(this.currentFrame + this.firstFrame);
} catch (error) {
this.triggerRenderFrameError(error);
}
};
注意,在 Lottie Web 中,把 AE 设置的帧数作为一个计算单位,Lottie 并不是根据设计师设置的 30fps(每隔 33.3ms) 进行每一次变化,而是根据 requestAnimationFrame 的间隔(每隔 16.7ms 左右)计算了更细致的变化,保证动画的流畅运行。
小结
文章中简单地介绍了Lottie Web动画实现的基本原理,有关于Lottie Web相关的源代码可以在Github的仓库中查阅。由于自己也是初学Lottie Web相关的,如果文章介绍有误之处,还请路过的大神拍正。如果你在这方面有更多的经验或建议,欢迎在下面的评论中与我们一起共享。