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

Julian Garnier 重写版动画引擎的 11,121 行内部拆解。覆盖 Engine / Tween 值系统 / Composition / Timeline / Draggable / Layout FLIP / Scroll 全栈。每个核心章节嵌入 live demo —— 边看代码边看效果。

上游 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. _fps < engineFps 的 child 跑自己的 fps 节流(Clock.requestTick 返回 AUTO/NONE)。
  3. 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-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. keyframes 字段(数组或百分比对象)被 generateKeyframes 展开成 per-property 的数组。animation.js:L127-208
  3. 每个 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):

模式行为
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 的原因。

blend composition — idle wobble + click shake 叠加不打架
按钮同时在跑
· scale idle wobble
· rotate idle wobble
点击时 shake 叠加不干扰

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。非常机智的统一。

Timeline 位置语法 — 三个 box 用 < += label 衔接
A
B
C

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。

stagger({ grid: [15,8], from: 'center' }) 波纹

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,可实现"多输入源推同一对象,平滑叠加"—— 典型如粒子系统有重力、鼠标吸引、风力三种力同时作用。

createAnimatable 鼠标跟随 — 不同 easing 对比

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 一致。

SVG 三件套同台 — morphTo / motionPath / drawable
morphTo ← 星/心/圆
motionPath 沿曲线跑
drawable 画线 0→1

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
text.split + stagger — 文字逐字浮现
anime.js · 每一个字都会动

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. pointermove → handleMove draggable.js:L958-1020normalizePoint 父元素 transform 反演 → touch 祖先可滚动检测 → drag threshold → 启动 updateTicker → 触发 onDrag
  3. 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:L593transforms.remove() 临时清除父元素 transform 才能用 getBoundingClientRect 拿到准确边界,然后再 revert。这个技巧在 Layout(L386-L392)和 Scroll(L774-L781)里也重复使用

createDraggable — 拖我、撞墙会回弹
拖我

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. 双向链表全家桶 —— Engine children / Timeline children / Animation tweens / replace siblings / blend siblings 全复用同一套 _prev/_next 原语。
  3. 有序插入addChild(parent, child, sortMethod?)) —— Tween 按 _absoluteStartTime 进 siblings 链自动排序。
  4. WeakMap + Symbol 缓存 —— transformsSymbol / morphPointsSymbol / proxyTargetSymbol 不污染正经属性。
  5. 预解析 + 运行期零正则 —— COMPLEX 值的 s[] + d[] 创建期拆好,60fps 只做数组 lerp + 模板拼接。
  6. Transform 一帧一拼 —— 多个 transform tween 通过 Symbol cache + 链尾 marker 触发最终 style.transform = 写入。
  7. 按需 rAF —— 队列空时 reqId = 0 自然终止,resume 时 engine.wake() 重启。空闲时零开销。
  8. visibility 自动 pause —— 一行 listener 避免后台 tab 吃 CPU。
  9. alternate 的 XOR —— _reversed ^ (_alternate && isOdd) 一行合成三状态。
  10. Proxy 虚拟属性(drawable 'draw') —— 不扩展原生 API,用 Proxy 转译。
  11. WAAPI easingToLinear(100 samples) —— 自定义 easing 降级 CSS linear(),让 GPU 跑 spring。
  12. 1000 targets 自动关 composition —— 巨量元素 stagger 的无感性能兜底。
  13. Scope.keepTime —— media query refresh 时保留 timeline currentTime。
  14. playbackEase —— Timeline 级别的"时间 warp",不是单 tween easing 而是整个时间流扭曲。
  15. 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.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