anime.js v4.3.6 源码深度解析
Julian Garnier 重写版动画引擎的 11,121 行内部拆解。覆盖 Engine / Tween 值系统 / Composition / Timeline / Draggable / Layout FLIP / Scroll 全栈。
项目概览
anime.js 是知名的轻量级 JavaScript 动画引擎,v4 在 v3 基础上完全重写了内核,拆出模块化架构(17 个子目录,tree-shakable)。它同时支持 CSS / SVG / DOM 属性 / 普通 JS 对象补间,用 6KB gzip 的体积提供了 GSAP 级别的能力覆盖:Tween / Timeline / Stagger / Spring / Draggable / ScrollTrigger / FLIP Layout / SVG Morph / Text Split。
此次解析的目标是 v4.3.6 release(2026-04-23 clone),带 file:line 证据的内部机制拆解,而不是 API 使用指南。
为什么值得读
作者用 ~400 行核心(Clock + Engine + render)撑起整个补间生态。每一层的抽象都有"为什么不能再省"的必要性——是一个教科书级的分层示范。
你能学到什么
按需 rAF、双向链表复用、预解析零运行期正则、WAAPI linear() 降级、Proxy 虚拟属性、阈值自动降级——超过 10 个值得借鉴的工程范式。
和 GSAP 的区别
GSAP 更丰富的插件生态和 getter 语义;anime.js v4 胜在 核心代码量小 + Tree-shaking 友好 + 开源 MIT。内部实现更紧凑、更容易读懂。
一分钟看懂:架构鸟瞰
核心是一棵四层继承树 + 一个 rAF 单例
- 渲染全部走
core/render.js两个函数:render()(单 Tickable)+tick()(递归 Timeline 树)。 - 所有 Tween 值在创建时就被预解析为 number/unit/color-rgba/complex 四类;运行期只有
lerp + clamp + round + 字符串拼接,没有正则执行。 - 单一
requestAnimationFrame驱动全应用所有动画。document.visibilitychange自动暂停。空队列时自动释放 rAF。
对外 API 全景
src/index.js 只有 18 行,全是 re-export。每个能力独立子目录(index.js 多为空壳,代码在同名 .js)。对外暴露工厂函数而非类:
| 工厂 | 类 | 文件:行 |
|---|---|---|
| animate(targets, params) | JSAnimation | animation.js:L747 |
| createTimer(params) | Timer | timer.js:L536 |
| createTimeline(params) | Timeline | timeline.js:L362 |
| createAnimatable(targets, params) | Animatable | animatable.js:L160 |
| createDraggable($el, params) | Draggable | draggable.js |
| createScope(params) | Scope | scope.js:L259 |
| createLayout(root, params) | AutoLayout | layout.js:L1607 |
| createMotionPath(path, offset) | 对象(3 个 FunctionValue) | motionpath.js:L80 |
| createDrawable(sel) | Proxy<SVGGeometryElement>[] | drawable.js:L111 |
| onScroll(params) | ScrollObserver | events/scroll.js |
| stagger(val, params) | StaggerFunction | utils/stagger.js:L82 |
| waapi.animate(targets, params) | WAAPIAnimation | waapi/waapi.js |
| svg.morphTo(path2, precision) | FunctionValue | morphto.js:L25 |
| text.split($el, params) | 三层 proxy | text/split.js |
全部支持链式:.play() / .pause() / .reverse() / .seek() / .stretch() / .revert() / .then()。
引擎:主循环 + rAF
engine.js:L47-165 定义 class Engine extends Clock,导出模块级单例 engine(L155 IIFE,外层包 /*#__PURE__*/ 让 Rollup / Esbuild tree-shake)。
Tick 方法的跨环境选择
const engineTickMethod = /*#__PURE__*/ (() => isBrowser ? requestAnimationFrame : setImmediate)();
const engineCancelMethod = /*#__PURE__*/ (() => isBrowser ? cancelAnimationFrame : clearImmediate)();
浏览器用 rAF,非浏览器 fallback 到 setImmediate,以支持 Node.js 测试。engine.js:L44-45
按需 rAF:空队列自动休眠
const tickEngine = () => {
if (engine._head) {
engine.reqId = engineTickMethod(tickEngine);
engine.update();
} else {
engine.reqId = 0; // 链表空 → 自然终止,下次有动画再 wake()
}
}
看 engine.js:L168-175:tickEngine 首先判断 _head 是否为空,空就把 reqId = 0,下一帧不续 rAF,循环自然终止。没有正在跑的动画时,整个库零开销。新 animate() 调 engine.wake() 重启循环 engine.js:L93-100。
每帧 update:遍历链表 + additive 聚合
update() {
const time = this._currentTime = now();
if (this.requestTick(time)) { // Clock.requestTick 做 fps 限速
this.computeDeltaTime(time);
let activeTickable = this._head;
while (activeTickable) {
const nextTickable = activeTickable._next;
if (!activeTickable.paused) {
tick(
activeTickable,
(time - activeTickable._startTime) * activeTickable._speed * engineSpeed,
0, 0,
activeTickable._fps < engineFps ? activeTickable.requestTick(time) : tickModes.AUTO
);
} else {
removeChild(this, activeTickable); // paused 自动脱链
activeTickable._running = false;
if (activeTickable.completed && !activeTickable._cancelled) {
activeTickable.cancel();
}
}
activeTickable = nextTickable;
}
additive.update(); // blend composition 每帧聚合
}
}
关键点 engine.js:L62-91:
(time - _startTime) * _speed * engineSpeed—— 每个 tickable 有独立 speed,可叠加 engine speed。_fps < engineFps的 child 跑自己的 fps 节流(Clock.requestTick 返回 AUTO/NONE)。- paused 的 child 当场脱链,不是等下一帧 —— list 状态即真相。
visibility 自动暂停
doc.addEventListener('visibilitychange', () => {
if (!engine.pauseOnDocumentHidden) return;
doc.hidden ? engine.pause() : engine.resume();
});
engine.js:L159-162 一行解决"后台 tab 疯狂吃 CPU"问题。可设 engine.pauseOnDocumentHidden = false 关掉。
timeUnit:ms ↔ s 动态切换
engine.js:L126-142 支持 engine.timeUnit = 's' 切换单位。切换时 globals.timeScale = 0.001,已创建的 duration 会按系数重缩放。代价是整个代码库用 round(_, 12) 抗浮点误差。建议应用启动时定一次,不要中途切。
Clock 基类:时钟抽象
clock.js:L23-107,仅 107 行。是所有时间驱动对象的基类。
requestTick:fps 限速的核心
requestTick(time) {
const scheduledTime = this._scheduledTime;
this._lastTickTime = time;
if (time < scheduledTime) return tickModes.NONE; // 跳帧
const frameDuration = this._frameDuration;
const frameDelta = time - scheduledTime;
this._scheduledTime += frameDelta < frameDuration ? frameDuration : frameDelta;
return tickModes.AUTO;
}
clock.js:L81-94 算法要点:如果 frameDelta 比一个 frameDuration 还长(比如 tab 切回后),_scheduledTime 直接跳到当前时间 —— 不疯狂补帧。
双向链表原语复用
Clock 的 _head / _tail 就是 Tickable 链表头尾 clock.js:L47-50。helpers.js:L255-263 的 addChild(parent, child, sortMethod?) 支持有序插入—— Tween 在 siblings 链里按 _absoluteStartTime 排序就是用这个。整个库所有 "N 个 child 挂在 parent 下" 的场景都复用同一套 40 行原语。
渲染管线 render.js
398 行单文件,负责"把当前时间算成写入 DOM 的值"。export const render() 处理单个 Tickable,export const tick() 递归 Timeline 树。
iteration / reverse / alternate 的 XOR 技巧
// 位运算 NOT ~~ 比 Math.floor 略快
const currentIteration = ~~(tickableCurrentTime / (iterationDuration + _loopDelay));
const isOdd = tickable._currentIteration % 2;
// XOR 一行处理 reversed × alternate × odd
const isReversed = _reversed ^ (_alternate && isOdd);
render.js:L88-97:三状态组合用 XOR 合成一个 boolean,不需要 if 链。
值类型四分派(60fps 临界路径)
if (tweenIsNumber) {
value = number = tweenModifier(round(lerp(tween._fromNumber, tween._toNumber, tweenProgress), tweenPrecision));
} else if (tweenValueType === valueTypes.UNIT) {
number = tweenModifier(round(lerp(...), tweenPrecision));
value = `${number}${tween._unit}`;
} else if (tweenValueType === valueTypes.COLOR) {
// 对 RGBA 四路分别 lerp + clamp 到 [0,255]
const r = round(clamp(tweenModifier(lerp(fn[0], tn[0], tweenProgress)), 0, 255), 0);
// ... g, b, a ...
value = `rgba(${r},${g},${b},${a})`;
} else if (tweenValueType === valueTypes.COMPLEX) {
// 预拆好的 s[] + d[] 交错拼回 —— 零正则
value = tween._strings[0];
for (let j = 0; j < tween._toNumbers.length; j++) {
const n = tweenModifier(round(lerp(...), tweenPrecision));
const s = tween._strings[j + 1];
value += s ? n + s : n;
}
}
render.js:L193-224:四类值的核心插值。COMPLEX 的精妙在于 s[] 和 d[] 都是创建期预解析,运行期零正则。
Transform 批写:一帧一次 style.transform
if (tweenType === tweenTypes.TRANSFORM) {
if (tweenTarget !== tweenTargetTransforms) {
tweenTargetTransforms = tweenTarget;
tweenTargetTransformsProperties = tweenTarget[transformsSymbol]; // Symbol 缓存
}
tweenTargetTransformsProperties[tweenProperty] = value; // 先存 cache
tweenTransformsNeedUpdate = 1;
}
// ... 链表末尾的 transform tween(构造时标记)才触发完整写入 ...
if (tweenTransformsNeedUpdate && tween._renderTransforms) {
let str = emptyString;
for (let key in tweenTargetTransformsProperties) {
str += `${transformsFragmentStrings[key]}${tweenTargetTransformsProperties[key]}) `;
}
tweenStyle.transform = str; // 每个 target 每帧只写一次
tweenTransformsNeedUpdate = 0;
}
render.js:L242-274 + animation.js:L601-616(构造时给链中最后一个 transform tween 打 _renderTransforms = 1)。对 60 个 transform 的大 stagger,每帧每个 target 只有 1 次 style.transform = ...。
可借鉴:任何需要多 tween 合成单一属性(box-shadow、filter、gradient stops)的场景,都能用这个 cache + 尾端 marker 的模式。
值系统:预解析四象限
core/values.js 是整个库的"解析中心"。所有运行期开销都提前到构造时。
tweenTypes 五分法
values.js:L94-107 根据"目标类型 × 属性名"把每个 tween 分五档:
| 类型 | 场景 | 写入方式 |
|---|---|---|
| OBJECT | 普通 JS 对象 / 非 DOM | target[prop] = v |
| ATTRIBUTE | SVG attribute / 其他 DOM attribute | target.setAttribute(prop, v) |
| CSS | 普通样式属性 | target.style[prop] = v |
| TRANSFORM | translateX/Y/Z、rotate、scale、skew 等 | 缓存到 Symbol + 一帧一拼接 |
| CSS_VAR | --my-var | style.setProperty('--x', v) |
TRANSFORM 单独一类是整个库的点睛之笔——让 translateX / scaleY / rotate 能独立补间,再合成。
decomposeRawValue:四类值预解析
// 解析结果(targetObject)
{
t: valueType, // NUMBER / UNIT / COLOR / COMPLEX
n: 主数字,
u: 单位 ('px', '%', 'turn', ...),
o: 运算符 ('+', '-', '*'),
d: 数字数组, // COLOR = [r,g,b,a]; COMPLEX = 所有匹配的数字
s: 字符串片段数组 // COMPLEX 专用
}
values.js:L170-218。典型 COMPLEX 例:"rgb(0,0,0) 10px 10px 20px inset" 被切成 s = ["rgb(", ",", ",", ") ", "px ", "px ", "px inset"]、d = [0,0,0,10,10,20]。运行期只需对 d 做 lerp + 与 s 交错拼接。正则只执行一次。
getTweenType 决策树
// 五档优先级分派
return !target[isDomSymbol] ? tweenTypes.OBJECT :
target[isSvgSymbol] && isValidSVGAttribute(target, prop) ? tweenTypes.ATTRIBUTE :
validTransforms.includes(prop) || shortTransforms.get(prop) ? tweenTypes.TRANSFORM :
stringStartsWith(prop, '--') ? tweenTypes.CSS_VAR :
prop in target.style ? tweenTypes.CSS :
prop in target ? tweenTypes.OBJECT :
tweenTypes.ATTRIBUTE;
values.js:L94-107。Symbol 标记的 isDomSymbol / isSvgSymbol 避免每次 instanceof,是在 registerTargets 注册时就打标。
Timer 基类:生命周期语义
timer.js:L106-530。继承 Clock 加完整的"播放器"语义。
seek 时必须 reviveTimer
const reviveTimer = timer => {
if (!timer._cancelled) return timer;
if (timer._hasChildren) {
forEachChildren(timer, reviveTimer);
} else {
forEachChildren(timer, (tween) => {
if (tween._composition !== compositionTypes.none) {
composeTween(tween, getTweenSiblings(tween.target, tween.property));
}
});
}
timer._cancelled = 0;
return timer;
}
timer.js:L86-99:cancel 会把 tween 从 siblings 链拔掉。如果直接 seek(0) 会渲染空。所以 seek/reset 前先 revive,把所有 tween 重新 compose 回链中。这个细节很容易被忽视。
.then() 的反递归 hack
then(callback = noop) {
const then = this.then;
const onResolve = () => {
// 如果 async function return 这个 thenable,会无限递归;置空 then 阻断
this.then = null;
callback(this);
this.then = then;
this._resolve = noop;
}
return new Promise(r => {
this._resolve = () => r(onResolve());
if (this.completed) this._resolve();
return this;
});
}
timer.js:L512-528,引用 GitHub issue #26。让 Timer 变成 Promise-compatible 同时避免 await animation 在 async 函数里无限递归。
stretch:等比缩放 duration
timer.js:L470-482。动态调整整个 Timer 的 duration,同时按比例缩放 _offset / _delay / _loopDelay。给 Timeline 用来"压缩已添加的 children"。
JSAnimation 构造器:747 行流水线
animation.js:L210-740,构造函数长达 442 行,是一条完整的值归一化管线。
Stage 1:输入归一
registerTargets(targets)把'.btn'/ Element / NodeList / ReactRef 统一成数组。keyframes字段(数组或百分比对象)被generateKeyframes展开成 per-property 的数组。animation.js:L127-208- 每个 property 的 value 归一成
keyframes[]——{to: v}/[from, to]/[v1, v2, v3]/{to, from, duration, ease}[]各种语法最终都化简到同一结构。animation.js:L305-332
Stage 2:composition override
if (tweenComposition !== compositionTypes.none) {
if (!siblings) siblings = getTweenSiblings(target, propName);
let nextSibling = siblings._head;
while (nextSibling && !nextSibling._isOverridden && nextSibling._absoluteStartTime <= absoluteStartTime) {
prevSibling = nextSibling;
nextSibling = nextSibling._nextRep;
// 后面的 sibling 直接 override
if (nextSibling && nextSibling._absoluteStartTime >= absoluteStartTime) {
while (nextSibling) {
overrideTween(nextSibling);
nextSibling = nextSibling._nextRep;
}
}
}
}
animation.js:L393-409。通过 WeakMap<Target, {prop: siblings}>(composition.js:L45-69)查找所有影响该 (target, property) 的 tween 链表,后续 siblings 自动 override。
Stage 3:值解析 + 类型对齐
关键是 animation.js:L475-494 的 类型不匹配自动对齐:
- complex vs number → 把 number 拍成 complex
- unit 不同 →
convertValueUnit调一次 getComputedStyle 换算 - color vs non-color → 占位填
[0,0,0,1]
Stage 4:Tween 创建(字面对象工厂)
animation.js:L524-562。每个 Tween 是 plain object,29 个字段,非 class —— 省 prototype 开销。一个 tween 同时存在于三条链表中:
_prev / _next—— 所属 Animation 的 tween 链_prevRep / _nextRep—— target-property siblings 链(replace)_prevAdd / _nextAdd—— additive siblings 链(blend)
Stage 5:性能护栏(1000 targets 自动关 composition)
const tComposition = isUnd(composition) && targetsLength >= K
? compositionTypes.none : ...;
animation.js:L263:targets 数量 ≥ 1000 时默认关掉 composition,避免大 stagger 时的 sibling lookup 开销。用户无感的性能兜底。
Stage 6:iterationDelay trim pass
animation.js:L624-635。扫一遍所有 tween 的 startTime,把最小的 delay 从整个 Animation 提取出来当 this._delay,其他 tween 减掉。这样 iterationProgress 对应"真实动画时长"而不是含前导 delay 的时长。
Composition 三态
所有 tween 有三种 composition 模式(consts.js:L41-45):
| 模式 | 值 | 行为 |
|---|---|---|
| replace | 0(默认) | 新 tween override 后续 siblings,截短前面 siblings 的 changeDuration 到 overlap 点 |
| none | 1 | 不 compose。纯粹写入。超多 targets 或性能关键场景 |
| blend | 2 | 叠加式。多个动画的 delta 累加到同一属性 |
replace 的截短逻辑
const prevAbsEndTime = prevSibling._absoluteStartTime + prevSibling._changeDuration;
const absoluteUpdateStartTime = tweenAbsStartTime - tween._delay;
if (prevAbsEndTime > absoluteUpdateStartTime) {
const prevTLOffset = prevAbsEndTime - (prevChangeStartTime + prevSibling._updateDuration);
const updatedPrevChangeDuration = round(absoluteUpdateStartTime - prevTLOffset - prevChangeStartTime, 12);
prevSibling._changeDuration = updatedPrevChangeDuration; // 截短前面 tween
prevSibling._currentTime = updatedPrevChangeDuration;
prevSibling._isOverlapped = 1;
if (updatedPrevChangeDuration < minValue) {
overrideTween(prevSibling); // 完全覆盖
}
}
composition.js:L142-158。精妙之处:并不把前面的 tween 删掉,而是"截短它的 changeDuration"—— 允许它继续停留在末态,只是不再推进。
多层 siblings 去活跃(父链 cleanup)
composition.js:L162-191:如果某个 Animation 的所有 tween 都被 overlapped,那这个 Animation 已经无实际作用;如果它父 Timeline 的所有 children Animations 也都无实际作用,就级联 cancel 父 Timeline。"僵尸动画"自动回收。
Additive 叠加:blend composition
blend 模式的实现是 anime 最巧妙的算法之一。composition.js:L216-258 + additive.js
核心思路:把 tween 变成 delta
// 第一次 blend 时创建 lookupTween(累加器)
if (!lookupTween) {
lookupTween = { ...tween };
lookupTween._composition = compositionTypes.replace;
lookupTween._updateDuration = minValue;
...
addChild(additiveAnimation, lookupTween);
}
// 把新 tween 的 from/to 变成相对 delta
const toNumber = tween._toNumber;
tween._fromNumber = lookupTween._fromNumber - toNumber; // 相对起点
tween._toNumber = 0; // 终点归零(delta 累积完后回归)
lookupTween._fromNumber = toNumber;
engine 每帧的 additive.update()
additive.update = () => {
lookups.forEach(propertyAnimation => {
for (let propertyName in propertyAnimation) {
const tweens = propertyAnimation[propertyName];
const lookupTween = tweens._head;
let additiveValue = lookupTween._fromNumber;
let tween = tweens._tail;
while (tween && tween !== lookupTween) {
additiveValue += tween._number; // 累加所有 tween 的当前值
tween = tween._prevAdd;
}
lookupTween._toNumber = additiveValue;
}
});
render(animation, 1, 1, 0, tickModes.FORCE);
}
additive.js:L41-81。每帧 engine.update() 末尾调用一次 engine.js:L89,聚合所有 blend 动画的当前值,force-render lookup tween 一次。
典型场景
鼠标 hover → scale up(+0.1)
持续 idle wobble → scale ±0.05
点击 → shake scale ±0.2
三个动画同时生效时,scale 自动累加,而非互相覆盖。这正是 Animatable 内部用 blend 的原因。
Timeline + 位置语法
timeline.js:L135-356。继承 Timer,主要加三件:labels、add(...) 多态、defaults 子项默认值。
位置语法糖(timeline/position.js)
| 语法 | 含义 |
|---|---|
| (省略) | 追加到末尾(当前 iterationDuration) |
| 1000 | 绝对位置 1000ms |
| 'labelName' | 跳到 label |
| '<' | 上一个 child 的起点(_offset + _delay) |
| '<<' | 上一个 child 的终点 |
| '+=500' | 末尾 + 500ms |
| 'labelName+=200' | label + 200ms |
| '<*=2' | 上一个 child 起点 × 2 |
解析逻辑在 position.js:L50-73 的 parseTimelinePosition,优先级:sibling 定位 > label > tlDuration。
add 的 stagger 分支
if (isFnc(a3)) { // 第三参数是 stagger 生成器
const tlDuration = this.duration;
const tlIterationDuration = this.iterationDuration;
parsedTargetsArray.forEach(target => {
const staggeredChildParams = { ...childParams };
// 关键:每个 target 加入前重置 duration
this.duration = tlDuration;
this.iterationDuration = tlIterationDuration;
addTlChild(
staggeredChildParams,
this,
parseTimelinePosition(this, staggeredPosition(target, i, parsedLength, this)),
target, i, parsedLength
);
i++;
});
}
timeline.js:L186-215。每个 target 一个独立 JSAnimation,起点由 stagger 函数决定。每次 addChild 前重置 duration/iterationDuration 很关键,否则后续 stagger 起点会漂移。
sync:WAAPI / 原生 Animation 并入 Timeline
sync(synced, position) {
synced.pause();
const duration = synced.effect ? synced.effect.getTiming().duration : synced.duration;
return this.add(synced, {
currentTime: [0, duration], // 补间外部对象的 currentTime
duration, delay: 0,
ease: 'linear', playbackEase: 'linear'
}, position);
}
timeline.js:L256-265。用补间外部对象 currentTime 的方式,把 WAAPI Animation 驱动进 anime Timeline。非常机智的统一。
Stagger:复合生成器
utils/stagger.js:L82-142。返回 (target, index, total, timeline?) => number|string 函数。功能密度非常高。
| 参数 | 行为 |
|---|---|
| from: 'first' | 'last' | 'center' | 'random' | number | 起始索引 |
| grid: [cols, rows] + axis: 'x' | 'y' | 栅格模式,欧几里得或单轴距离 |
| stagger([0, 100]) | range stagger,值域等分而非固定间距 |
| ease | 索引归一化 [0,1] 过 easing 再映射回去 |
| use: 'data-delay' | 从元素属性读 index 值 |
| total | 自定义"虚拟总数",N 个元素但假装是 M 个 |
| start: 'labelName' | 支持 timeline 位置语法作为 base offset |
栅格距离算法
const fromX = !fromCenter ? fromIndex % grid[0] : (grid[0] - 1) / 2;
const fromY = !fromCenter ? floor(fromIndex / grid[0]) : (grid[1] - 1) / 2;
const toX = index % grid[0];
const toY = floor(index / grid[0]);
let value = sqrt(distanceX * distanceX + distanceY * distanceY);
if (axis === 'x') value = -distanceX; // 单轴模式
if (axis === 'y') value = -distanceY;
utils/stagger.js:L117-126。values 数组懒初始化 + 缓存,第一次调用时算完整个距离 map。
Animatable:超高频 setter API
animatable.js:L41-153。场景:鼠标跟随、陀螺仪、3D 控制这类"逐帧被外部驱动"的动画。
用法
const a = createAnimatable(el, { x: 200, y: 200, ease: 'outQuad' });
document.addEventListener('mousemove', e => a.x(e.clientX).y(e.clientY));
内部结构
- 构造时为每个 property 创建一个独立 autoplay:false JSAnimation(L107)。
- 每个 property 的 setter 函数:无参返回当前值;有参更新 from/to →
reset(true).resume()。 - 额外一个 dummy
callbacksAnimation({v: 0}→{v: 1})统一管理 begin/pause/complete —— 多个独立 Animation 的 "全部完成" 才视为整体 complete。animatable.js:L52-90
配合 blend composition,可实现"多输入源推同一对象,平滑叠加"—— 典型如粒子系统有重力、鼠标吸引、风力三种力同时作用。
Scope:React / Angular 生命周期桥
scope/scope.js:L41-253。核心概念是 scope.execute(cb) 的压栈式上下文:
execute(cb) {
const activeScope = scope.current;
const activeRoot = scope.root;
const activeDefaults = globals.defaults;
// 压栈
scope.current = this;
scope.root = this.root;
globals.defaults = this.defaults;
const returned = cb(this);
// 还原
scope.current = activeScope;
scope.root = activeRoot;
globals.defaults = activeDefaults;
return returned;
}
Timer constructor 里(timer.js:L137)自动 scope.current.register(this),所以在 scope.add 的 cb 里创建的动画会自动被 scope 管理。
关键 API
.add(fn)—— 每次 refresh 时重跑.add(name, fn)—— 注册方法,调用时用 scope context.addOnce(fn)—— 只跑一次(cache inconstructorsOnce).keepTime(fn)—— 返回 tickable,refresh 时保留 currentTime 不重置.refresh()—— revert + re-run constructors(media query 变化时触发).revert()—— 倒序 revert 所有 revertibles
React 集成
useEffect(() => {
const scope = createScope({ root: ref }).add(() => {
animate('.box', { translateX: 100 });
});
return () => scope.revert();
}, []);
scope.js:L49 识别 ReactRef.current / AngularRef.nativeElement,自动解包。
mediaQueries:响应式动画
createScope({
mediaQueries: { mobile: '(max-width: 800px)' }
}).add(self => {
if (self.matches.mobile) {
animate('.box', { translateX: 50 });
} else {
animate('.box', { translateX: 200 });
}
});
scope.js:L85-91。change 事件触发 refresh(),revert 旧动画 + 重跑 constructors,不用手写 resize listener + 重启动画。
SVG 三件套
morphTo —— 不同顶点数的 path 变形
const length1 = $path1.getTotalLength();
const length2 = $path2.getTotalLength();
const maxPoints = max(ceil(length1 * precision), ceil(length2 * precision));
for (let i = 0; i < maxPoints; i++) {
const t = i / (maxPoints - 1);
const p1 = $path1.getPointAtLength(length1 * t);
const p2 = $path2.getPointAtLength(length2 * t);
v1 += prefix + round(p1.x, 3) + sep + p1.y + ' ';
v2 += prefix + round(p2.x, 3) + sep + p2.y + ' ';
}
svg/morphto.js:L25-65。把两条 path 重采样到相同点数(precision × max length),转成同构的 "M L L L L..." 字符串,交给 anime 做 complex tween。结果缓存到 $path[morphPointsSymbol] 供下次使用。
createMotionPath —— 沿 path 运动
return {
translateX: getPathProgess($path, 'x', offset),
translateY: getPathProgess($path, 'y', offset),
rotate: getPathProgess($path, 'a', offset),
}
// rotate 用前后两点的 atan2 做中心差分
return atan2(p1.y - p0.y, p1.x - p0.x) * 180 / PI;
svg/motionpath.js:L80-88。返回三个 FunctionValue 对象,让 anime 直接 animate(el, motionPath)。SVG 内坐标 vs HTML 坐标通过 CTM 矩阵换算(p.x * ctm.a + p.y * ctm.c + ctm.e,L66-69)。
createDrawable —— Proxy 实现的画线效果
const proxy = new Proxy($el, {
get(target, property) {
if (property === 'setAttribute') {
return (...args) => {
if (args[0] === 'draw') {
const [v1, v2] = args[1].split(' ').map(Number);
const os = v1 * -pathLength * scaleFactor;
const d1 = v2 * pathLength * scaleFactor + os;
const d2 = pathLength * scaleFactor - d1;
target.setAttribute('stroke-dashoffset', `${os}`);
target.setAttribute('stroke-dasharray', `${d1} ${d2}`);
}
return Reflect.apply(value, target, args);
};
}
...
}
});
svg/drawable.js:L54-95。精妙之处:不引入新 CSS 属性,而是用 Proxy 拦截 setAttribute('draw', '0 0.5'),转译成 stroke-dasharray + stroke-dashoffset。且强行设置 pathLength=1000(L47, L96-97),让 [0, 1000] 成为规范化空间 —— 不管 path 真实长度多少,画线 API 一致。
Text Split:Intl.Segmenter + ARIA
text/split.js(512 行)。把一段文本拆成 line / word / char 三层 span。
- Unicode 正确分词:
Intl.Segmenter可用时用它(中日韩、emoji 都正确),否则 fallback 到空格分割。split.js:L41 - Accessibility:原文元素保留可读性,拆出来的副本加
aria-hidden="true"。split.js:L81 —— 屏幕阅读器看到原文,视觉动画走副本。 - Line 重排:换行逻辑用
filterLineElements递归剔除不属于此行的元素 + 相邻空白 textNode(避免孤零零残留)。split.js:L109-126 - 模板占位:
{value}/{i}支持用户自定义包装 HTML。split.js:L42-43
WAAPI 降级:自定义 easing → CSS linear()
anime.js 的 waapi.animate()(waapi/waapi.js)用 Web Animations API 让动画 off-main-thread 跑。最精彩的是自定义 easing 的降级策略:
const easingToLinear = (fn, samples = 100) => {
const points = [];
for (let i = 0; i <= samples; i++)
points.push(round(fn(i / samples), 4));
return `linear(${points.join(', ')})`;
}
waapi.js:L85-89。anime 的自定义 easing('outElastic(1, 0.3)'、spring({mass, stiffness}))在 WAAPI 侧没有对应语法。作者采样 100 个点,用 CSS Level 4 的 linear(v0, v1, ..., v100) 合成一条分段线性 easing —— 无需 JS 驱动就能 off-main-thread 跑任意曲线。
结果缓存到 WAAPIEasesLookups(L91)避免重复采样。
浏览器兼容:CSS linear() 只在 Chrome 113+ / Safari 17.2+ / Firefox 112+ 支持。更老浏览器会 fallback 到 'linear' 字面量。
Draggable:1286 行的物理拖拽引擎
draggable.js。不继承 Timer,而是组合 3 个 Timer + 1 个 Animatable。
状态机四标志
| 标志 | 含义 | 位置 |
|---|---|---|
| grabbed | 指针按下 | draggable.js:L403 |
| dragged | 已移动超阈值 | draggable.js:L404 |
| updated | 本帧有更新 | draggable.js:L405 |
| released | 已松开 | draggable.js:L406 |
事件流(handleEvent 分发)
- pointerdown → handleDown draggable.js:L888-953:绑监听 → 初始化 pointer 坐标 → 触发 onGrab
- pointermove → handleMove draggable.js:L958-1020:
normalizePoint父元素 transform 反演 → touch 祖先可滚动检测 → drag threshold → 启动 updateTicker → 触发 onDrag - pointerup → handleUp draggable.js:L1022-1151:速度采样 → 弹道预测 → spring/easing 二选一驱动回位 → onRelease
物理:3 帧速度栈取 max
// L479-495 computeVelocity —— 用循环缓冲
velocityStack[vi] = clamp(
(sqrt(dx*dx + dy*dy) / elapsed) * vMul,
minV, maxV
);
// 取栈中最大值,而非线性平均
velocity = max(...velocityStack);
draggable.js:L479-495。取 max 而非 avg —— 避免最后几帧减速导致惯性偏弱(物理上更接近人的预期)。
双阶段过冲动画
draggable.js:L1090-1109。非 spring 模式下若反弹超出边界,先动画到物理计算的过冲点(65% 时间),再反弹到最终点 —— 让"撞墙回弹"自然。而不是生硬的 easing。
临时清空祖先 transform 测量
draggable.js:L593:transforms.remove() 临时清除父元素 transform 才能用 getBoundingClientRect 拿到准确边界,然后再 revert。这个技巧在 Layout(L386-L392)和 Scroll(L774-L781)里也重复使用。
Layout FLIP:1607 行(库内最大单文件)
实现 First / Last / Invert / Play 动画。layout.js
触发:显式三步 API(无 MutationObserver)
layout.record(); // First
// 用户自己改 DOM
layout.animate(params); // Last + Invert + Play
// 或一步到位
layout.update(cb, params);
layout.js:L1059-1099。没有自动观测,调用方显式触发,设计者保持完全控制权。
六维属性测量
每个节点记录 layout.js:L318-328:transform / x / y / left / top / clientLeft / clientTop / width / height + 用户自定义(opacity / color / fontSize)。
Invert 双引擎(最精彩处)
- 位置+尺寸 用 anime.js Timeline 补间(
translate / width / height) - transform 用 WAAPI 并行跑,
timeline.sync(transformAnimation, 0)同步
layout.js:L1566-1584。作者注释写明:"transform 如果也走 Timeline 会和 translate 抢 style 属性"。必须拆成两个 pipeline 并时间同步 —— 深水炸弹级实现细节。
五大难点的处理
| 难点 | 处理 | 位置 |
|---|---|---|
| 嵌套 FLIP 避免双重 invert | animatedParent 链追踪 | layout.js:L1296-1300 |
| display:none ↔ block 切换 | hasVisibilitySwap + measuredDisplay 换脸 | layout.js:L1219-1230 |
| 内联文本 (inline span) | hasAdjacentText → isInlined → 跳过位置动画 | layout.js:L368-379 |
| display:grid 干扰 transform | 动画期间强行改 block | layout.js:L1408 |
| swapAt 中途属性改变 | 双段 easing + tl.call() 50% 切换 DOM | layout.js:L1505-1545 |
灵魂代码:镜像 easing
// L1531-1532 —— swapAt 后半段的反向 easing
const inverseEased = t => 1 - ease(1 - t);
对 swapAt 的后半段:如果前半段用 easing f(t),后半段用 1-f(1-t) 才能让加速度在 50% 处对称。一行代码,需要人脑推导,代码紧凑但可读性差—— 典型的"高明但需要注释"的代码。
Scroll:986 行滚动驱动引擎
events/scroll.js。混合架构:原生 scroll 事件 + 3 层 Timer 合批 + 主动轮询(而非 IntersectionObserver)。
三层 Timer 分层节流
| Timer | 频率 | 职责 |
|---|---|---|
| scrollTicker | rAF | 合批所有 observers 的 handleScroll |
| dataTimer | 30Hz | 算速度/方向,独立轨道不抢 rAF |
| wakeTicker | 500ms 防抖 | scroll 停止后延迟休眠 scrollTicker |
scroll 事件本身只做 wakeTicker.restart()(L284-285)。真实工作合批到 rAF 帧里。
Progress 映射纯函数
get progress() {
const p = (this.scroll - this.offsetStart) / this.distance;
return round(clamp(p, 0, 1), 6);
}
scroll.js:L581-584。scroll → timeline.seek 单向驱动。不支持反向驱动(拖 timeline 不会改 scroll)。
Offset 字符串解析
parseBoundValue scroll.js:L358-387 支持:
'top' / 'start'→ 0'bottom' / 'end'→ 100%'center'→ 50%'top 50%'→ 相对运算符解析
Sticky 临时禁用
// L774-781 遍历祖先,临时禁用 sticky
while ($el && $el !== container.element) {
const isSticky = get($el, 'position') === 'sticky'
? set($el, { position: 'static' }) : false;
$el = $el.parentElement;
if (isSticky) stickys.push(isSticky);
}
// ... 测量 ...
stickys.forEach(s => s.revert()); // L840-841 还原
测量时临时禁用祖先 sticky 才能拿到正确 offset。计算完 revert 回去。和 Draggable 的 transform 反演同一思路。
方向感知的四回调
scroll.js:L873-921:enterForward / enterBackward / syncEnter / onEnter 四套回调。根据 container.backwardX/Y 判断滚动方向触发对应分支。
共享容器 + 自动 cleanup
scrollContainers = new Map() 全局缓存(scroll.js:L116),多个 observer 共享一个 ScrollContainer。最后一个 observer unsubscribe 时容器才真正 revert scroll.js:L965-978。
精彩设计合集(15 招)
- /*#__PURE__*/ pragma 满天飞 —— IIFE 创建的 maps / singletons 都打标让 Rollup/Esbuild tree-shake。用户只用
animate(),整个 timeline/draggable/scroll 都能干掉。 - 双向链表全家桶 —— Engine children / Timeline children / Animation tweens / replace siblings / blend siblings 全复用同一套
_prev/_next原语。 - 有序插入(
addChild(parent, child, sortMethod?)) —— Tween 按_absoluteStartTime进 siblings 链自动排序。 - WeakMap + Symbol 缓存 ——
transformsSymbol / morphPointsSymbol / proxyTargetSymbol不污染正经属性。 - 预解析 + 运行期零正则 —— COMPLEX 值的
s[]+d[]创建期拆好,60fps 只做数组 lerp + 模板拼接。 - Transform 一帧一拼 —— 多个 transform tween 通过 Symbol cache + 链尾 marker 触发最终
style.transform =写入。 - 按需 rAF —— 队列空时
reqId = 0自然终止,resume 时engine.wake()重启。空闲时零开销。 - visibility 自动 pause —— 一行 listener 避免后台 tab 吃 CPU。
- alternate 的 XOR ——
_reversed ^ (_alternate && isOdd)一行合成三状态。 - Proxy 虚拟属性(drawable 'draw') —— 不扩展原生 API,用 Proxy 转译。
- WAAPI easingToLinear(100 samples) —— 自定义 easing 降级 CSS linear(),让 GPU 跑 spring。
- 1000 targets 自动关 composition —— 巨量元素 stagger 的无感性能兜底。
- Scope.keepTime —— media query refresh 时保留 timeline currentTime。
- playbackEase —— Timeline 级别的"时间 warp",不是单 tween easing 而是整个时间流扭曲。
- additive delta 累加 —— 多 tween 自动叠加到 lookup accumulator,engine 每帧 force-render。
坑与可借鉴点
踩坑清单
- 毫秒 vs 秒切换(timeUnit):切到 's' 后内部全部乘 0.001,到处
round(_, 12)抗浮点 —— 应用启动时定好,不要中途切。 - seek cancelled 动画要 reviveTimer:cancel 会把 tween 从 siblings 链拔掉。库内部已处理(timer.js:L86-99),但如果你 hack 内部可能踩。
- Timer 构造器依赖 engine._lastTickTime:冷启动时 L167 特地
engine.requestTick(now())热身。如果你在模块 load 阶段就new Animation()可能拿到冷数据。 - Draggable 在 transformed 祖先里:靠
transforms.remove()临时清 transform 测量(L593)。CSS 变量 / 复杂 3D transform 时可能不完美。 - CSS
linear()浏览器兼容:不支持的浏览器上 WAAPI 自定义 easing 会 fallback 到'linear',动画曲线看起来像 bug。
可借鉴到其他项目的模式
- linked-list children + addChild sortMethod:40 行原语,可直接抄到任何需要排序链表的地方。
- 预解析 + 运行期无正则:任何需要字符串补间(CSS gradient、SVG path、filter)都适用。
- 按需 rAF(空队列自终止):大多数自研动画循环都漏掉这个。
- visibility auto-pause:标准好习惯。
- Proxy 做虚拟属性:给不能扩展的原生 API 开后门。
- WAAPI linear() 降级:需要 main-thread 动画 + off-main-thread 协同的库都能学。
- scope revertibles:框架集成(React/Vue/Angular)的标准 pattern。
- 阈值自动降级(≥ 1000 关 composition):性能护栏无需用户决定。
附录:模块行数分布
| 模块 | 行数 | 备注 |
|---|---|---|
| layout/layout.js | 1607 | FLIP 动画,库内最大单文件 |
| draggable/draggable.js | 1286 | 物理拖拽 |
| events/scroll.js | 986 | 滚动驱动 |
| animation/animation.js | 747 | JSAnimation 构造器 |
| types/index.js | 652 | JSDoc 类型定义 |
| waapi/waapi.js | 540 | WAAPI 适配 |
| timer/timer.js | 535 | Timer 基类 |
| text/split.js | 512 | Text 拆分 |
| core/render.js | 398 | 渲染管线 |
| animation/composition.js | 390 | composition 三态 |
| timeline/timeline.js | 362 | Timeline + 位置语法 |
| core/helpers.js | 263 | math/type/linked-list |
| scope/scope.js | 259 | React/Angular 桥 |
| core/values.js | 235 | decompose + getTweenType |
| engine/engine.js | 181 | 全局单例 |
| utils/chainable.js | 171 | 链式 API helpers |
| animatable/animatable.js | 160 | 高频 setter |
| utils/stagger.js | 142 | Stagger 生成器 |
| core/targets.js | 138 | targets 归一 |
| core/consts.js | 118 | enums + regex + Symbols |
| core/styles.js | 118 | CSS helpers |
| svg/drawable.js | 118 | Proxy 画线 |
| core/clock.js | 107 | 时钟基类 |
| core/colors.js | 103 | color 解析 |
| svg/motionpath.js | 88 | 沿 path 运动 |
| utils/number.js | 84 | 数字工具 |
| waapi/composition.js | 84 | WAAPI 组合 |
| animation/additive.js | 81 | blend 聚合 |
| core/globals.js | 74 | defaults + scope.current |
| timeline/position.js | 72 | 位置语法解析 |
| svg/morphto.js | 65 | SVG path 变形 |
| utils/random.js | 63 | shuffle 等 |
| core/units.js | 63 | 单位换算 |
| utils/time.js | 57 | keepTime |
| core/transforms.js | 45 | parseInlineTransforms |
| svg/helpers.js | 24 | getPath |
| 其他 | 27 | 各种小 index.js |
| total | 11,121 |