From 0bb5c6b1c353b323bac6b24db45487fde31b9c18 Mon Sep 17 00:00:00 2001 From: kang Date: Thu, 23 Apr 2026 23:27:28 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=AE=8C=E6=88=90=20anime.js=20v4.3.6?= =?UTF-8?q?=20=E6=BA=90=E7=A0=81=E6=B7=B1=E5=BA=A6=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E4=B8=8E=E5=8D=95=E9=A1=B5=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E7=AB=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .memory/source-analysis.md: 671 行 21 章节,带 file:line 证据覆盖所有模块 - index.html: 1166 行单页,左侧 scrollspy 目录 + 17 个深度章节 · engine/clock/render/values 四大内核 · Timer/Animation/Timeline 生命周期三层 · composition 三态 + additive 叠加 · stagger/animatable/scope/svg/text/waapi 能力模块 · draggable/layout/scroll 三个 1000+ 行重武器 · 15 招精彩设计合集 + 踩坑清单 Co-Authored-By: Claude Opus 4.7 (1M context) --- .memory/worklog.json | 7 + index.html | 1194 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 1164 insertions(+), 37 deletions(-) diff --git a/.memory/worklog.json b/.memory/worklog.json index 82d6d61..960ea39 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -41,6 +41,13 @@ "message": "auto-save 2026-04-23 23:19 (~2)", "hash": "6acf582", "files_changed": 2 + }, + { + "ts": "2026-04-23T23:24:58+08:00", + "type": "commit", + "message": "auto-save 2026-04-23 23:24 (~1)", + "hash": "188728f", + "files_changed": 1 } ] } diff --git a/index.html b/index.html index cb73156..ca06808 100644 --- a/index.html +++ b/index.html @@ -1,46 +1,1166 @@ - - - anime.js 源码解析 - + + +anime.js v4.3.6 源码深度解析 + -
-

anime.js 源码解析

-

anime.js v4 动画库源码深度拆解(Timeline/Tween/SVG morphing/Stagger)

+
+
+
生命周期三层
+ Timer 基类 + JSAnimation 构造器 + Composition 三态 + Additive 叠加 + Timeline + 位置语法 + +
能力模块
+ Stagger 复合生成器 + Animatable 高频 setter + Scope 生命周期桥 + SVG 三件套 + Text Split + WAAPI 降级 + +
重武器(agent 深读)
+ Draggable (1286 行) + Layout FLIP (1607 行) + Scroll (986 行) + +
总结
+ 精彩设计合集 + 坑与可借鉴点 + 模块行数表 + + + +
+
+

anime.js v4.3.6 源码深度解析

+

Julian Garnier 重写版动画引擎的 11,121 行内部拆解。覆盖 Engine / Tween 值系统 / Composition / Timeline / Draggable / Layout FLIP / Scroll 全栈。

+
+ 上游 juliangarnier/anime + 版本 v4.3.6 + MIT + 规模 11,121 LOC + 解析日期 2026-04-23 +
+
+ + +
+

项目概览

+

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 单例

+
+Clock (core/clock.js) ← 107 行,只管 time/fps/speed/deltaTime + 双向链表 children + ↓ +Timer (timer/timer.js) ← 535 行,加生命周期(play/pause/seek/reverse/complete/then) + ↓ +├── JSAnimation (animation/animation.js) ← 747 行,管 Tween 链表 + 预解析值 +└── Timeline (timeline/timeline.js) ← 362 行,管子项 + 位置语法 + +Engine (engine/engine.js) ← 181 行,extends Clock 的单例,唯一持有 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)JSAnimationanimation.js:L747
createTimer(params)Timertimer.js:L536
createTimeline(params)Timelinetimeline.js:L362
createAnimatable(targets, params)Animatableanimatable.js:L160
createDraggable($el, params)Draggabledraggable.js
createScope(params)Scopescope.js:L259
createLayout(root, params)AutoLayoutlayout.js:L1607
createMotionPath(path, offset)对象(3 个 FunctionValue)motionpath.js:L80
createDrawable(sel)Proxy<SVGGeometryElement>[]drawable.js:L111
onScroll(params)ScrollObserverevents/scroll.js
stagger(val, params)StaggerFunctionutils/stagger.js:L82
waapi.animate(targets, params)WAAPIAnimationwaapi/waapi.js
svg.morphTo(path2, precision)FunctionValuemorphto.js:L25
text.split($el, params)三层 proxytext/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-175tickEngine 首先判断 _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

+
    +
  1. (time - _startTime) * _speed * engineSpeed —— 每个 tickable 有独立 speed,可叠加 engine speed。
  2. +
  3. _fps < engineFps 的 child 跑自己的 fps 节流(Clock.requestTick 返回 AUTO/NONE)。
  4. +
  5. paused 的 child 当场脱链,不是等下一帧 —— list 状态即真相
  6. +
+ +

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-50helpers.js:L255-263addChild(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 对象 / 非 DOMtarget[prop] = v
ATTRIBUTESVG attribute / 其他 DOM attributetarget.setAttribute(prop, v)
CSS普通样式属性target.style[prop] = v
TRANSFORMtranslateX/Y/Z、rotate、scale、skew 等缓存到 Symbol + 一帧一拼接
CSS_VAR--my-varstyle.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:输入归一

+
    +
  1. registerTargets(targets)'.btn' / Element / NodeList / ReactRef 统一成数组。
  2. +
  3. keyframes 字段(数组或百分比对象)被 generateKeyframes 展开成 per-property 的数组。animation.js:L127-208
  4. +
  5. 每个 property 的 value 归一成 keyframes[] —— {to: v} / [from, to] / [v1, v2, v3] / {to, from, duration, ease}[] 各种语法最终都化简到同一结构。animation.js:L305-332
  6. +
+ +

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):

+ + + + + + + +
模式行为
replace0(默认)新 tween override 后续 siblings,截短前面 siblings 的 changeDuration 到 overlap 点
none1不 compose。纯粹写入。超多 targets 或性能关键场景
blend2叠加式。多个动画的 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,主要加三件:labelsadd(...) 多态、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-73parseTimelinePosition,优先级: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 callbacks Animation({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 in constructorsOnce
  • +
  • .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 分发)

+
    +
  1. pointerdown → handleDown draggable.js:L888-953:绑监听 → 初始化 pointer 坐标 → 触发 onGrab
  2. +
  3. pointermove → handleMove draggable.js:L958-1020normalizePoint 父元素 transform 反演 → touch 祖先可滚动检测 → drag threshold → 启动 updateTicker → 触发 onDrag
  4. +
  5. pointerup → handleUp draggable.js:L1022-1151:速度采样 → 弹道预测 → spring/easing 二选一驱动回位 → onRelease
  6. +
+ +

物理: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:L593transforms.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-328transform / 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 避免双重 invertanimatedParent 链追踪layout.js:L1296-1300
display:none ↔ block 切换hasVisibilitySwap + measuredDisplay 换脸layout.js:L1219-1230
内联文本 (inline span)hasAdjacentText → isInlined → 跳过位置动画layout.js:L368-379
display:grid 干扰 transform动画期间强行改 blocklayout.js:L1408
swapAt 中途属性改变双段 easing + tl.call() 50% 切换 DOMlayout.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频率职责
scrollTickerrAF合批所有 observers 的 handleScroll
dataTimer30Hz算速度/方向,独立轨道不抢 rAF
wakeTicker500ms 防抖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-921enterForward / enterBackward / syncEnter / onEnter 四套回调。根据 container.backwardX/Y 判断滚动方向触发对应分支。

+ +

共享容器 + 自动 cleanup

+

scrollContainers = new Map() 全局缓存(scroll.js:L116),多个 observer 共享一个 ScrollContainer。最后一个 observer unsubscribe 时容器才真正 revert scroll.js:L965-978

+
+ +
+ + +
+

精彩设计合集(15 招)

+
    +
  1. /*#__PURE__*/ pragma 满天飞 —— IIFE 创建的 maps / singletons 都打标让 Rollup/Esbuild tree-shake。用户只用 animate(),整个 timeline/draggable/scroll 都能干掉。
  2. +
  3. 双向链表全家桶 —— Engine children / Timeline children / Animation tweens / replace siblings / blend siblings 全复用同一套 _prev/_next 原语。
  4. +
  5. 有序插入addChild(parent, child, sortMethod?)) —— Tween 按 _absoluteStartTime 进 siblings 链自动排序。
  6. +
  7. WeakMap + Symbol 缓存 —— transformsSymbol / morphPointsSymbol / proxyTargetSymbol 不污染正经属性。
  8. +
  9. 预解析 + 运行期零正则 —— COMPLEX 值的 s[] + d[] 创建期拆好,60fps 只做数组 lerp + 模板拼接。
  10. +
  11. Transform 一帧一拼 —— 多个 transform tween 通过 Symbol cache + 链尾 marker 触发最终 style.transform = 写入。
  12. +
  13. 按需 rAF —— 队列空时 reqId = 0 自然终止,resume 时 engine.wake() 重启。空闲时零开销。
  14. +
  15. visibility 自动 pause —— 一行 listener 避免后台 tab 吃 CPU。
  16. +
  17. alternate 的 XOR —— _reversed ^ (_alternate && isOdd) 一行合成三状态。
  18. +
  19. Proxy 虚拟属性(drawable 'draw') —— 不扩展原生 API,用 Proxy 转译。
  20. +
  21. WAAPI easingToLinear(100 samples) —— 自定义 easing 降级 CSS linear(),让 GPU 跑 spring。
  22. +
  23. 1000 targets 自动关 composition —— 巨量元素 stagger 的无感性能兜底。
  24. +
  25. Scope.keepTime —— media query refresh 时保留 timeline currentTime。
  26. +
  27. playbackEase —— Timeline 级别的"时间 warp",不是单 tween easing 而是整个时间流扭曲。
  28. +
  29. additive delta 累加 —— 多 tween 自动叠加到 lookup accumulator,engine 每帧 force-render。
  30. +
+
+ + +
+

坑与可借鉴点

+ +

踩坑清单

+
+
    +
  • 毫秒 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.js1607FLIP 动画,库内最大单文件
draggable/draggable.js1286物理拖拽
events/scroll.js986滚动驱动
animation/animation.js747JSAnimation 构造器
types/index.js652JSDoc 类型定义
waapi/waapi.js540WAAPI 适配
timer/timer.js535Timer 基类
text/split.js512Text 拆分
core/render.js398渲染管线
animation/composition.js390composition 三态
timeline/timeline.js362Timeline + 位置语法
core/helpers.js263math/type/linked-list
scope/scope.js259React/Angular 桥
core/values.js235decompose + getTweenType
engine/engine.js181全局单例
utils/chainable.js171链式 API helpers
animatable/animatable.js160高频 setter
utils/stagger.js142Stagger 生成器
core/targets.js138targets 归一
core/consts.js118enums + regex + Symbols
core/styles.js118CSS helpers
svg/drawable.js118Proxy 画线
core/clock.js107时钟基类
core/colors.js103color 解析
svg/motionpath.js88沿 path 运动
utils/number.js84数字工具
waapi/composition.js84WAAPI 组合
animation/additive.js81blend 聚合
core/globals.js74defaults + scope.current
timeline/position.js72位置语法解析
svg/morphto.js65SVG path 变形
utils/random.js63shuffle 等
core/units.js63单位换算
utils/time.js57keepTime
core/transforms.js45parseInlineTransforms
svg/helpers.js24getPath
其他27各种小 index.js
total11,121
+
+ +
+

解析自 juliangarnier/anime @ v4.3.6 · 2026-04-23

+

所有 file:line 引用对应 source/src/ 下的路径,可直接在本地项目中 grep 验证

+
+
+
+ +