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 |