动画即数据:timeline 序列化成 descriptor
前面各页都是「写代码调用 animate」的命令式动画。换一种范式:把动画写成一份纯数据(descriptor),再写一个解释器读它、用引擎播出来。
这正是 Lottie 的本质——它不是「更复杂的 SVG path 动画」,而是把关键帧这套「时间到值」的中间表示序列化成 JSON,再交给运行时渲染器逐帧插值。
1 · 一份可编辑的 descriptor
descriptor 描述的不是「画一条什么 path」,而是有哪些图层、每个图层的哪些属性、在哪些帧取什么值:layers[] 每层一个图形加若干属性轨,每条轨是一串 { f, v, ease }——帧号、值、进入这一段的缓动。单帧轨即静止值。
2 · 解释器只做两件事
其一,把每条属性轨喂给 createKeyframes(values, { times, ease }) 得到一个采样器——帧号按总长归一化成 times 落在
,逐段缓动给到 ease。其二,建一个主 Animation 当播放头,它的 onUpdate(p) 把进度喂给每条轨的采样器,合成 transform 与 opacity 写到对应图层。
| descriptor 字段 | 含义 | @vega/anim 原语 |
|---|---|---|
tracks.x = [{f,v,ease}, …] |
一条属性的关键帧轨 | createKeyframes(values, { times, ease }) |
关键帧的 ease |
进入这一段的缓动曲线 | 逐段 ease 数组,长度等于段数 |
fps 加 duration |
总时长 | duration = 帧 / fps × 1000 |
多个 layers[] |
独立图层各跑各的轨 | 主 Animation 的 onUpdate 内分发 |
| 整体播放 / 变速 / 跳转 | 播放器能力 | play / speed / seek / reverse 现成 |
注 · ease 数组的长度是段数而非关键帧数,所以要 kfs.slice(0, -1) 取前
个,且每一项取的是后一个关键帧上写的 ease——「进入这一段用什么曲线」的语义写在段的终点上。这个错位是照抄 Lottie 的约定:After Effects 里缓动手柄挂在关键帧上,而一段的形状由它两端的手柄共同决定。
3 · 动画是数据换来什么
可被程序改写:换色只是改 shape.fill 一个值再重编译,编辑器里改任意数字亦然。
可 seek、反向、变速:整份数据编译成一个主 Animation,而「时间到值」是纯函数,播放控制全部白送——Lottie 的 setSpeed 与 playSegments 同理。
矢量且轻:数据里没有像素,任意分辨率不糊。
import { Animation, createKeyframes } from '@vega/anim';
// 一条属性轨 → 采样器: 帧号归一化成 times, 逐段 ease
function buildTrack(kfs, total) {
const values = kfs.map(k => k.v);
const times = kfs.map(k => k.f / total); // 帧 → [0,1]
const ease = kfs.slice(0, -1).map((_, i) => kfs[i + 1].ease || 'linear');
return createKeyframes(values, { times, ease }); // ← 引擎原语
}
// descriptor → 一个主 Animation 当播放头
function compile(desc) {
const layers = desc.layers.map(layer => {
const g = makeSvgGroup(layer.shape);
const samplers = {};
for (const prop in layer.tracks)
samplers[prop] = buildTrack(layer.tracks[prop], desc.duration);
return { g, samplers };
});
const render = (p) => layers.forEach(({ g, samplers }) => {
const at = (k, d) => samplers[k] ? samplers[k](p) : d;
g.setAttribute('transform',
`translate(${at('x',0)} ${at('y',0)}) rotate(${at('rotate',0)}) scale(${at('scale',1)})`);
g.setAttribute('opacity', at('opacity', 1));
});
return new Animation({ // ← seek / reverse / speed 全免费
keyframes: [0, 1], duration: desc.duration / desc.fps * 1000,
ease: 'linear', repeat: Infinity,
onUpdate: render,
});
}
每条轨的值如何插出来见插值,逐段快慢由缓动塑形,多轨多层如何排在一条轴上见关键帧与编排。与浏览器原生能力的边界对照见与原生对照;真实工业格式的细节见 CSS 之外的动画做法。