Files
animejs-source-analysis/index.html
kang 2d87b7bdfd fix: hero 字母不可见 (渐变不继承) + 升级 text.splitText API
- .hero-letter 加自己的渐变 + -webkit-background-clip:text
  原因: 父 h1 的 -webkit-text-fill-color:transparent 继承到 span 但
  background 不继承,span 变成 box 就切断 background-clip:text 的连贯,
  span 里文字就全透明看不见

- text.split(...) → text.splitText(...)
  修掉 v4.3 的 deprecation warning

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 01:18:57 +08:00

1695 lines
94 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>anime.js v4.3.6 源码深度解析</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#0f0f10;--bg2:#17171a;--bg3:#1e1e22;
--fg:#e6e6ea;--muted:#9a9aa4;--border:#2a2a30;
--accent:#ff8a5b;--accent2:#5ed1b7;--accent3:#c792ea;
--yellow:#f0c674;--red:#ff6b6b;--blue:#6ab0f3;
--mono:"SF Mono","JetBrains Mono","Menlo","Consolas",monospace;
}
html{scroll-behavior:smooth}
body{
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
background:var(--bg);color:var(--fg);line-height:1.7;font-size:15px;
overflow-x:hidden;
}
/* Layout */
.wrap{display:grid;grid-template-columns:280px 1fr;min-height:100vh;max-width:1600px;margin:0 auto}
/* Sidebar */
aside{
position:sticky;top:0;height:100vh;overflow-y:auto;
border-right:1px solid var(--border);
padding:2rem 1.5rem;background:var(--bg2);
}
aside::-webkit-scrollbar{width:6px}
aside::-webkit-scrollbar-thumb{background:#333;border-radius:3px}
.brand{
font-size:1.1rem;font-weight:700;margin-bottom:.3rem;
background:linear-gradient(135deg,var(--accent),var(--accent3));
-webkit-background-clip:text;-webkit-text-fill-color:transparent;
}
.brand-sub{color:var(--muted);font-size:.78rem;margin-bottom:1.5rem;font-family:var(--mono)}
nav{font-size:.88rem}
nav a{
display:block;padding:.3rem .6rem;margin:.1rem 0;
color:var(--muted);text-decoration:none;border-left:2px solid transparent;
border-radius:0 6px 6px 0;transition:all .15s;
}
nav a:hover{color:var(--fg);background:#22222a}
nav a.active{color:var(--accent);border-left-color:var(--accent);background:#22222a;font-weight:500}
nav .g{color:#555;font-size:.72rem;text-transform:uppercase;letter-spacing:.08em;margin:1rem 0 .3rem .3rem}
/* Main */
main{padding:3rem 4rem;max-width:1100px}
@media(max-width:1200px){main{padding:2.5rem 2.5rem}}
@media(max-width:900px){
.wrap{grid-template-columns:1fr}
aside{position:static;height:auto;border-right:0;border-bottom:1px solid var(--border)}
main{padding:2rem 1.25rem}
}
header.hero{
padding:2.5rem 0 2rem;border-bottom:1px solid var(--border);margin-bottom:2.5rem;
}
h1{
font-size:2.6rem;font-weight:800;line-height:1.1;margin-bottom:.75rem;
background:linear-gradient(135deg,var(--accent) 0%,var(--accent3) 55%,var(--accent2) 100%);
-webkit-background-clip:text;-webkit-text-fill-color:transparent;
}
.hero-sub{color:var(--muted);font-size:1.05rem;margin-bottom:1.25rem}
.badges{display:flex;flex-wrap:wrap;gap:.5rem;font-size:.78rem;font-family:var(--mono)}
.badge{
padding:.25rem .6rem;border:1px solid var(--border);border-radius:999px;
color:var(--muted);background:var(--bg2);
}
.badge strong{color:var(--accent)}
section{margin-bottom:3.5rem;scroll-margin-top:2rem}
section>h2{
font-size:1.7rem;margin-bottom:1rem;color:var(--fg);
display:flex;align-items:baseline;gap:.75rem;
}
section>h2::before{content:"";width:4px;height:1.4rem;background:var(--accent);border-radius:2px;display:inline-block;margin-right:.4rem;transform:translateY(2px)}
h3{font-size:1.15rem;margin:2rem 0 .75rem;color:var(--accent2)}
h4{font-size:.98rem;margin:1.25rem 0 .5rem;color:var(--yellow);font-weight:600}
p{margin-bottom:.9rem;color:#d5d5da}
p.lead{font-size:1.05rem}
ul,ol{margin:.5rem 0 1rem 1.25rem;color:#d5d5da}
ul li,ol li{margin:.35rem 0}
strong{color:#fff}
em{color:var(--accent2);font-style:normal}
code{
font-family:var(--mono);font-size:.85em;
background:#24242a;padding:.1rem .4rem;border-radius:4px;color:var(--yellow);
}
a.ref{
font-family:var(--mono);font-size:.8em;color:var(--accent);
background:#2a1f1a;padding:.08rem .4rem;border-radius:4px;
border:1px solid #3a2a22;text-decoration:none;white-space:nowrap;
}
a.ref:hover{background:#3a2a22}
pre{
background:#0b0b0d;border:1px solid var(--border);border-radius:8px;
padding:1rem 1.15rem;margin:1rem 0;overflow-x:auto;
font-family:var(--mono);font-size:.84rem;line-height:1.55;
color:#ddd;
}
pre code{background:none;padding:0;color:inherit;font-size:inherit}
.k{color:#c792ea} /* keyword */
.fn{color:#82aaff} /* function */
.s{color:#c3e88d} /* string */
.n{color:#f78c6c} /* number */
.c{color:#546e7a;font-style:italic} /* comment */
.p{color:#89ddff} /* punctuation */
.t{color:#ffcb6b} /* type */
table{
width:100%;border-collapse:collapse;margin:1rem 0;
font-size:.88rem;border:1px solid var(--border);border-radius:8px;overflow:hidden;
}
th,td{padding:.6rem .9rem;text-align:left;border-bottom:1px solid var(--border);vertical-align:top}
th{background:var(--bg3);color:var(--accent2);font-weight:600}
td:first-child{font-family:var(--mono);color:var(--yellow)}
tr:last-child td{border-bottom:0}
.card{
background:var(--bg2);border:1px solid var(--border);border-radius:10px;
padding:1.25rem 1.5rem;margin:1rem 0;
}
.card.accent{border-left:3px solid var(--accent)}
.card.tip{border-left:3px solid var(--accent2)}
.card.warn{border-left:3px solid var(--yellow)}
.card h4{margin-top:0}
.grid{display:grid;gap:1rem;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));margin:1rem 0}
.tldr{
background:linear-gradient(135deg,#2a1f1a,#1a2a26);
border:1px solid #3a2a22;border-radius:10px;padding:1.25rem 1.5rem;margin:1.5rem 0;
}
.tldr h4{color:var(--accent);margin-top:0}
hr{border:none;border-top:1px solid var(--border);margin:3rem 0}
/* Diagram boxes */
.arch{
font-family:var(--mono);font-size:.82rem;background:#0b0b0d;
border:1px solid var(--border);border-radius:8px;padding:1.25rem;
color:#aaa;line-height:1.6;white-space:pre;overflow-x:auto;
}
.arch b{color:var(--accent);font-weight:600}
.arch em{color:var(--accent2)}
footer{
margin-top:4rem;padding:2rem 0;border-top:1px solid var(--border);
color:var(--muted);font-size:.85rem;text-align:center;
}
footer a{color:var(--accent);text-decoration:none}
footer a:hover{text-decoration:underline}
/* ==== Live Demo 样式 ==== */
.demo{
background:linear-gradient(135deg,#121215,#181820);
border:1px solid var(--border);border-radius:10px;
margin:1.5rem 0;overflow:hidden;
}
.demo-head{
display:flex;align-items:center;justify-content:space-between;
padding:.7rem 1rem;border-bottom:1px solid var(--border);
background:#0f0f13;
}
.demo-title{font-size:.82rem;color:var(--accent);font-weight:600;letter-spacing:.02em}
.demo-title::before{content:"▶ ";color:var(--accent2)}
.demo-btns{display:flex;gap:.4rem}
.demo-btn{
background:#26262d;color:var(--fg);border:1px solid var(--border);
padding:.3rem .75rem;border-radius:5px;font-size:.78rem;
font-family:var(--mono);cursor:pointer;transition:all .15s;
}
.demo-btn:hover{background:var(--accent);color:#1a1a1e;border-color:var(--accent)}
.demo-btn.primary{background:var(--accent);color:#1a1a1e;border-color:var(--accent)}
.demo-btn.primary:hover{background:var(--accent3);border-color:var(--accent3)}
.demo-stage{
padding:1.25rem;min-height:180px;display:flex;
align-items:center;justify-content:center;
position:relative;overflow:hidden;
}
.demo-stage.tall{min-height:260px}
/* stagger grid */
.stagger-grid{display:grid;grid-template-columns:repeat(15,1fr);gap:6px;max-width:480px;width:100%}
.stagger-grid > div{
aspect-ratio:1;background:var(--accent);border-radius:50%;
opacity:.3;will-change:transform,opacity;
}
/* timeline demo */
.tl-row{display:flex;gap:1rem;width:100%;max-width:520px;justify-content:space-around}
.tl-row .box{
width:60px;height:60px;border-radius:10px;
display:flex;align-items:center;justify-content:center;
font-family:var(--mono);font-size:.85rem;font-weight:700;color:#1a1a1e;
}
.tl-box-a{background:var(--accent)}
.tl-box-b{background:var(--accent2)}
.tl-box-c{background:var(--accent3)}
/* additive demo */
.additive-stage{display:flex;justify-content:center;align-items:center;gap:2rem}
.additive-btn{
width:120px;height:120px;border-radius:20px;background:var(--accent3);
border:0;cursor:pointer;color:#1a1a1e;font-weight:700;font-size:.9rem;
will-change:transform;
}
.additive-hint{color:var(--muted);font-size:.82rem;font-family:var(--mono);line-height:1.5}
/* SVG demos */
.svg-row{display:flex;gap:1rem;flex-wrap:wrap;justify-content:center;width:100%}
.svg-card{
flex:1;min-width:170px;max-width:240px;
background:#0d0d10;border:1px solid var(--border);border-radius:8px;
padding:1rem;text-align:center;
}
.svg-card svg{display:block;margin:0 auto}
.svg-card-label{
font-size:.72rem;color:var(--accent2);font-family:var(--mono);
margin-top:.5rem;letter-spacing:.04em;
}
/* text split */
.text-demo-target{
font-size:2rem;font-weight:700;line-height:1.3;
background:linear-gradient(135deg,var(--accent),var(--accent3),var(--accent2));
-webkit-background-clip:text;-webkit-text-fill-color:transparent;
text-align:center;
}
.text-demo-target .letter{display:inline-block;will-change:transform,opacity}
/* draggable */
.drag-stage{position:relative;height:220px;background:#0d0d10;border:1px dashed #333;border-radius:8px;width:100%}
.drag-box{
position:absolute;top:50%;left:50%;margin:-40px 0 0 -40px;
width:80px;height:80px;background:var(--accent);border-radius:12px;
cursor:grab;display:flex;align-items:center;justify-content:center;
color:#1a1a1e;font-weight:700;user-select:none;
}
.drag-box:active{cursor:grabbing}
/* animatable mouse-follow */
.follow-stage{position:relative;height:220px;background:#0d0d10;border:1px solid var(--border);border-radius:8px;width:100%;cursor:crosshair}
.follow-dot{
position:absolute;top:0;left:0;width:28px;height:28px;
margin:-14px 0 0 -14px;background:var(--accent2);border-radius:50%;
pointer-events:none;box-shadow:0 0 20px var(--accent2);
}
.follow-hint{
position:absolute;inset:auto 1rem 1rem auto;font-size:.75rem;
color:var(--muted);font-family:var(--mono);
}
/* engine basic translate */
.basic-row{display:flex;gap:.5rem;align-items:center;justify-content:center;width:100%}
.basic-row .dot{
width:50px;height:50px;border-radius:50%;background:var(--accent);
will-change:transform;
}
/* hero animated logo —— 每个字母自带渐变剪影(父级 -webkit-text-fill-color:transparent 不继承背景,这里补回) */
h1 .hero-letter{
display:inline-block;will-change:transform,opacity,color;
background:linear-gradient(135deg,var(--accent) 0%,var(--accent3) 55%,var(--accent2) 100%);
-webkit-background-clip:text;background-clip:text;
-webkit-text-fill-color:transparent;
}
/* online badge */
.live-badge{
display:inline-block;background:var(--accent2);color:#0b0b0d;
font-size:.7rem;font-weight:700;padding:.1rem .5rem;border-radius:999px;
margin-left:.5rem;font-family:var(--mono);letter-spacing:.04em;
animation:pulse 2s ease-in-out infinite;
}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.6}}
</style>
</head>
<body>
<div class="wrap">
<aside>
<div class="brand">anime.js v4.3.6</div>
<div class="brand-sub">source dive · 2026-04-23</div>
<nav id="toc">
<div class="g">快速入门</div>
<a href="#overview">项目概览</a>
<a href="#tldr">一分钟看懂</a>
<a href="#api">对外 API 全景</a>
<div class="g">内核三件套</div>
<a href="#engine">引擎:主循环 + rAF</a>
<a href="#clock">Clock 基类</a>
<a href="#render">渲染管线 render.js</a>
<a href="#values">值系统四象限</a>
<div class="g">生命周期三层</div>
<a href="#timer">Timer 基类</a>
<a href="#animation">JSAnimation 构造器</a>
<a href="#composition">Composition 三态</a>
<a href="#additive">Additive 叠加</a>
<a href="#timeline">Timeline + 位置语法</a>
<div class="g">能力模块</div>
<a href="#stagger">Stagger 复合生成器</a>
<a href="#animatable">Animatable 高频 setter</a>
<a href="#scope">Scope 生命周期桥</a>
<a href="#svg">SVG 三件套</a>
<a href="#text">Text Split</a>
<a href="#waapi">WAAPI 降级</a>
<div class="g">重武器agent 深读)</div>
<a href="#draggable">Draggable (1286 行)</a>
<a href="#layout">Layout FLIP (1607 行)</a>
<a href="#scroll">Scroll (986 行)</a>
<div class="g">总结</div>
<a href="#gems">精彩设计合集</a>
<a href="#pitfalls">坑与可借鉴点</a>
<a href="#sizes">模块行数表</a>
</nav>
</aside>
<main>
<header class="hero">
<h1 id="heroTitle">anime.js v4.3.6 源码深度解析<span class="live-badge">LIVE DEMOS</span></h1>
<p class="hero-sub">Julian Garnier 重写版动画引擎的 11,121 行内部拆解。覆盖 Engine / Tween 值系统 / Composition / Timeline / Draggable / Layout FLIP / Scroll 全栈。<strong style="color:var(--accent2)">每个核心章节嵌入 live demo —— 边看代码边看效果。</strong></p>
<div class="badges">
<span class="badge">上游 <strong>juliangarnier/anime</strong></span>
<span class="badge">版本 <strong>v4.3.6</strong></span>
<span class="badge"><strong>MIT</strong></span>
<span class="badge">规模 <strong>11,121 LOC</strong></span>
<span class="badge">解析日期 <strong>2026-04-23</strong></span>
</div>
</header>
<!-- 概览 -->
<section id="overview">
<h2>项目概览</h2>
<p class="lead">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。</p>
<p>此次解析的目标是 v4.3.6 release2026-04-23 clone<strong>file:line 证据</strong>的内部机制拆解,而不是 API 使用指南。</p>
<div class="grid">
<div class="card tip">
<h4>为什么值得读</h4>
<p>作者用 ~400 行核心Clock + Engine + render撑起整个补间生态。每一层的抽象都有"为什么不能再省"的必要性——是一个教科书级的分层示范。</p>
</div>
<div class="card tip">
<h4>你能学到什么</h4>
<p>按需 rAF、双向链表复用、预解析零运行期正则、WAAPI linear() 降级、Proxy 虚拟属性、阈值自动降级——超过 10 个值得借鉴的工程范式。</p>
</div>
<div class="card tip">
<h4>和 GSAP 的区别</h4>
<p>GSAP 更丰富的插件生态和 getter 语义anime.js v4 胜在 <em>核心代码量小 + Tree-shaking 友好 + 开源 MIT</em>。内部实现更紧凑、更容易读懂。</p>
</div>
</div>
</section>
<!-- TL;DR -->
<section id="tldr">
<h2>一分钟看懂:架构鸟瞰</h2>
<div class="tldr">
<h4>核心是一棵四层继承树 + 一个 rAF 单例</h4>
<div class="arch">
<b>Clock</b> (core/clock.js) ← 107 行,只管 time/fps/speed/deltaTime + 双向链表 children
<b>Timer</b> (timer/timer.js) ← 535 行加生命周期play/pause/seek/reverse/complete/then
├── <b>JSAnimation</b> (animation/animation.js) ← 747 行,管 Tween 链表 + 预解析值
└── <b>Timeline</b> (timeline/timeline.js) ← 362 行,管子项 + 位置语法
<b>Engine</b> (engine/engine.js) ← 181 行extends <em>Clock</em> 的单例,唯一持有 rAF 的节点</div>
</div>
<ul>
<li>渲染全部走 <code>core/render.js</code> 两个函数:<code>render()</code>(单 Tickable+ <code>tick()</code>(递归 Timeline 树)。</li>
<li>所有 Tween 值在创建时就被<strong>预解析</strong>为 number/unit/color-rgba/complex 四类;运行期只有 <code>lerp + clamp + round + 字符串拼接</code><strong>没有正则执行</strong></li>
<li>单一 <code>requestAnimationFrame</code> 驱动全应用所有动画。<code>document.visibilitychange</code> 自动暂停。空队列时自动释放 rAF。</li>
</ul>
</section>
<!-- API -->
<section id="api">
<h2>对外 API 全景</h2>
<p><code>src/index.js</code> 只有 18 行,全是 re-export。每个能力独立子目录<code>index.js</code> 多为空壳,代码在同名 <code>.js</code>)。对外暴露<strong>工厂函数</strong>而非类:</p>
<table>
<thead><tr><th>工厂</th><th></th><th>文件:行</th></tr></thead>
<tbody>
<tr><td>animate(targets, params)</td><td>JSAnimation</td><td><a class="ref">animation.js:L747</a></td></tr>
<tr><td>createTimer(params)</td><td>Timer</td><td><a class="ref">timer.js:L536</a></td></tr>
<tr><td>createTimeline(params)</td><td>Timeline</td><td><a class="ref">timeline.js:L362</a></td></tr>
<tr><td>createAnimatable(targets, params)</td><td>Animatable</td><td><a class="ref">animatable.js:L160</a></td></tr>
<tr><td>createDraggable($el, params)</td><td>Draggable</td><td><a class="ref">draggable.js</a></td></tr>
<tr><td>createScope(params)</td><td>Scope</td><td><a class="ref">scope.js:L259</a></td></tr>
<tr><td>createLayout(root, params)</td><td>AutoLayout</td><td><a class="ref">layout.js:L1607</a></td></tr>
<tr><td>createMotionPath(path, offset)</td><td>对象3 个 FunctionValue</td><td><a class="ref">motionpath.js:L80</a></td></tr>
<tr><td>createDrawable(sel)</td><td>Proxy&lt;SVGGeometryElement&gt;[]</td><td><a class="ref">drawable.js:L111</a></td></tr>
<tr><td>onScroll(params)</td><td>ScrollObserver</td><td><a class="ref">events/scroll.js</a></td></tr>
<tr><td>stagger(val, params)</td><td>StaggerFunction</td><td><a class="ref">utils/stagger.js:L82</a></td></tr>
<tr><td>waapi.animate(targets, params)</td><td>WAAPIAnimation</td><td><a class="ref">waapi/waapi.js</a></td></tr>
<tr><td>svg.morphTo(path2, precision)</td><td>FunctionValue</td><td><a class="ref">morphto.js:L25</a></td></tr>
<tr><td>text.split($el, params)</td><td>三层 proxy</td><td><a class="ref">text/split.js</a></td></tr>
</tbody>
</table>
<p>全部支持链式:<code>.play() / .pause() / .reverse() / .seek() / .stretch() / .revert() / .then()</code></p>
</section>
<hr>
<!-- Engine -->
<section id="engine">
<h2>引擎:主循环 + rAF</h2>
<p><a class="ref">engine.js:L47-165</a> 定义 <code>class Engine extends Clock</code>,导出<strong>模块级单例</strong> <code>engine</code>L155 IIFE外层包 <code>/*#__PURE__*/</code> 让 Rollup / Esbuild tree-shake</p>
<h3>Tick 方法的跨环境选择</h3>
<pre><code><span class="k">const</span> <span class="fn">engineTickMethod</span> = <span class="c">/*#__PURE__*/</span> (() =&gt; isBrowser ? <span class="fn">requestAnimationFrame</span> : <span class="fn">setImmediate</span>)();
<span class="k">const</span> <span class="fn">engineCancelMethod</span> = <span class="c">/*#__PURE__*/</span> (() =&gt; isBrowser ? <span class="fn">cancelAnimationFrame</span> : <span class="fn">clearImmediate</span>)();</code></pre>
<p>浏览器用 rAF非浏览器 fallback 到 <code>setImmediate</code>,以支持 Node.js 测试。<a class="ref">engine.js:L44-45</a></p>
<h3>按需 rAF空队列自动休眠</h3>
<pre><code><span class="k">const</span> <span class="fn">tickEngine</span> = () =&gt; {
<span class="k">if</span> (engine._head) {
engine.reqId = <span class="fn">engineTickMethod</span>(tickEngine);
engine.<span class="fn">update</span>();
} <span class="k">else</span> {
engine.reqId = <span class="n">0</span>; <span class="c">// 链表空 → 自然终止,下次有动画再 wake()</span>
}
}</code></pre>
<p><a class="ref">engine.js:L168-175</a><code>tickEngine</code> 首先判断 <code>_head</code> 是否为空,空就把 <code>reqId = 0</code><strong>下一帧不续 rAF</strong>,循环自然终止。没有正在跑的动画时,整个库零开销。新 <code>animate()</code><code>engine.wake()</code> 重启循环 <a class="ref">engine.js:L93-100</a></p>
<h3>每帧 update遍历链表 + additive 聚合</h3>
<pre><code><span class="fn">update</span>() {
<span class="k">const</span> time = <span class="k">this</span>._currentTime = <span class="fn">now</span>();
<span class="k">if</span> (<span class="k">this</span>.<span class="fn">requestTick</span>(time)) { <span class="c">// Clock.requestTick 做 fps 限速</span>
<span class="k">this</span>.<span class="fn">computeDeltaTime</span>(time);
<span class="k">let</span> activeTickable = <span class="k">this</span>._head;
<span class="k">while</span> (activeTickable) {
<span class="k">const</span> nextTickable = activeTickable._next;
<span class="k">if</span> (!activeTickable.paused) {
<span class="fn">tick</span>(
activeTickable,
(time - activeTickable._startTime) * activeTickable._speed * engineSpeed,
<span class="n">0</span>, <span class="n">0</span>,
activeTickable._fps &lt; engineFps ? activeTickable.<span class="fn">requestTick</span>(time) : tickModes.AUTO
);
} <span class="k">else</span> {
<span class="fn">removeChild</span>(<span class="k">this</span>, activeTickable); <span class="c">// paused 自动脱链</span>
activeTickable._running = <span class="k">false</span>;
<span class="k">if</span> (activeTickable.completed &amp;&amp; !activeTickable._cancelled) {
activeTickable.<span class="fn">cancel</span>();
}
}
activeTickable = nextTickable;
}
additive.<span class="fn">update</span>(); <span class="c">// blend composition 每帧聚合</span>
}
}</code></pre>
<p class="lead">关键点 <a class="ref">engine.js:L62-91</a></p>
<ol>
<li><code>(time - _startTime) * _speed * engineSpeed</code> —— 每个 tickable 有独立 speed可叠加 engine speed。</li>
<li><code>_fps &lt; engineFps</code> 的 child 跑自己的 fps 节流Clock.requestTick 返回 AUTO/NONE</li>
<li>paused 的 child 当场脱链,不是等下一帧 —— <strong>list 状态即真相</strong></li>
</ol>
<h3>visibility 自动暂停</h3>
<pre><code><span class="fn">doc</span>.<span class="fn">addEventListener</span>(<span class="s">'visibilitychange'</span>, () =&gt; {
<span class="k">if</span> (!engine.pauseOnDocumentHidden) <span class="k">return</span>;
doc.hidden ? engine.<span class="fn">pause</span>() : engine.<span class="fn">resume</span>();
});</code></pre>
<p><a class="ref">engine.js:L159-162</a> 一行解决"后台 tab 疯狂吃 CPU"问题。可设 <code>engine.pauseOnDocumentHidden = false</code> 关掉。</p>
<h3>timeUnitms ↔ s 动态切换</h3>
<p><a class="ref">engine.js:L126-142</a> 支持 <code>engine.timeUnit = 's'</code> 切换单位。切换时 <code>globals.timeScale = 0.001</code>,已创建的 duration 会按系数重缩放。代价是<strong>整个代码库用 <code>round(_, 12)</code> 抗浮点误差</strong>。建议应用启动时定一次,不要中途切。</p>
</section>
<!-- Clock -->
<section id="clock">
<h2>Clock 基类:时钟抽象</h2>
<p><a class="ref">clock.js:L23-107</a>,仅 107 行。是所有时间驱动对象的基类。</p>
<h3>requestTickfps 限速的核心</h3>
<pre><code><span class="fn">requestTick</span>(time) {
<span class="k">const</span> scheduledTime = <span class="k">this</span>._scheduledTime;
<span class="k">this</span>._lastTickTime = time;
<span class="k">if</span> (time &lt; scheduledTime) <span class="k">return</span> tickModes.NONE; <span class="c">// 跳帧</span>
<span class="k">const</span> frameDuration = <span class="k">this</span>._frameDuration;
<span class="k">const</span> frameDelta = time - scheduledTime;
<span class="k">this</span>._scheduledTime += frameDelta &lt; frameDuration ? frameDuration : frameDelta;
<span class="k">return</span> tickModes.AUTO;
}</code></pre>
<p><a class="ref">clock.js:L81-94</a> 算法要点:如果 <code>frameDelta</code> 比一个 <code>frameDuration</code> 还长(比如 tab 切回后),<code>_scheduledTime</code> 直接跳到当前时间 —— <strong>不疯狂补帧</strong></p>
<h3>双向链表原语复用</h3>
<p>Clock 的 <code>_head / _tail</code> 就是 Tickable 链表头尾 <a class="ref">clock.js:L47-50</a><code>helpers.js:L255-263</code><code>addChild(parent, child, sortMethod?)</code> 支持<strong>有序插入</strong>—— Tween 在 siblings 链里按 <code>_absoluteStartTime</code> 排序就是用这个。整个库所有 "N 个 child 挂在 parent 下" 的场景都复用同一套 40 行原语。</p>
</section>
<!-- Render -->
<section id="render">
<h2>渲染管线 render.js</h2>
<p>398 行单文件,负责"把当前时间算成写入 DOM 的值"。<code>export const render()</code> 处理单个 Tickable<code>export const tick()</code> 递归 Timeline 树。</p>
<h3>iteration / reverse / alternate 的 XOR 技巧</h3>
<pre><code><span class="c">// 位运算 NOT ~~ 比 Math.floor 略快</span>
<span class="k">const</span> currentIteration = ~~(tickableCurrentTime / (iterationDuration + _loopDelay));
<span class="k">const</span> isOdd = tickable._currentIteration % <span class="n">2</span>;
<span class="c">// XOR 一行处理 reversed × alternate × odd</span>
<span class="k">const</span> isReversed = _reversed ^ (_alternate &amp;&amp; isOdd);</code></pre>
<p><a class="ref">render.js:L88-97</a>:三状态组合用 XOR 合成一个 boolean不需要 if 链。</p>
<h3>值类型四分派60fps 临界路径)</h3>
<pre><code><span class="k">if</span> (tweenIsNumber) {
value = number = <span class="fn">tweenModifier</span>(<span class="fn">round</span>(<span class="fn">lerp</span>(tween._fromNumber, tween._toNumber, tweenProgress), tweenPrecision));
} <span class="k">else if</span> (tweenValueType === valueTypes.UNIT) {
number = <span class="fn">tweenModifier</span>(<span class="fn">round</span>(<span class="fn">lerp</span>(...), tweenPrecision));
value = <span class="s">`${number}${tween._unit}`</span>;
} <span class="k">else if</span> (tweenValueType === valueTypes.COLOR) {
<span class="c">// 对 RGBA 四路分别 lerp + clamp 到 [0,255]</span>
<span class="k">const</span> r = <span class="fn">round</span>(<span class="fn">clamp</span>(<span class="fn">tweenModifier</span>(<span class="fn">lerp</span>(fn[<span class="n">0</span>], tn[<span class="n">0</span>], tweenProgress)), <span class="n">0</span>, <span class="n">255</span>), <span class="n">0</span>);
<span class="c">// ... g, b, a ...</span>
value = <span class="s">`rgba(${r},${g},${b},${a})`</span>;
} <span class="k">else if</span> (tweenValueType === valueTypes.COMPLEX) {
<span class="c">// 预拆好的 s[] + d[] 交错拼回 —— 零正则</span>
value = tween._strings[<span class="n">0</span>];
<span class="k">for</span> (<span class="k">let</span> j = <span class="n">0</span>; j &lt; tween._toNumbers.length; j++) {
<span class="k">const</span> n = <span class="fn">tweenModifier</span>(<span class="fn">round</span>(<span class="fn">lerp</span>(...), tweenPrecision));
<span class="k">const</span> s = tween._strings[j + <span class="n">1</span>];
value += s ? n + s : n;
}
}</code></pre>
<p><a class="ref">render.js:L193-224</a>:四类值的核心插值。<strong>COMPLEX 的精妙在于 s[] 和 d[] 都是创建期预解析</strong>,运行期零正则。</p>
<h3>Transform 批写:一帧一次 style.transform</h3>
<pre><code><span class="k">if</span> (tweenType === tweenTypes.TRANSFORM) {
<span class="k">if</span> (tweenTarget !== tweenTargetTransforms) {
tweenTargetTransforms = tweenTarget;
tweenTargetTransformsProperties = tweenTarget[transformsSymbol]; <span class="c">// Symbol 缓存</span>
}
tweenTargetTransformsProperties[tweenProperty] = value; <span class="c">// 先存 cache</span>
tweenTransformsNeedUpdate = <span class="n">1</span>;
}
<span class="c">// ... 链表末尾的 transform tween构造时标记才触发完整写入 ...</span>
<span class="k">if</span> (tweenTransformsNeedUpdate &amp;&amp; tween._renderTransforms) {
<span class="k">let</span> str = emptyString;
<span class="k">for</span> (<span class="k">let</span> key <span class="k">in</span> tweenTargetTransformsProperties) {
str += <span class="s">`${transformsFragmentStrings[key]}${tweenTargetTransformsProperties[key]}) `</span>;
}
tweenStyle.transform = str; <span class="c">// 每个 target 每帧只写一次</span>
tweenTransformsNeedUpdate = <span class="n">0</span>;
}</code></pre>
<p><a class="ref">render.js:L242-274</a> + <a class="ref">animation.js:L601-616</a>(构造时给链中<strong>最后一个</strong> transform tween 打 <code>_renderTransforms = 1</code>)。对 60 个 transform 的大 stagger每帧每个 target 只有 1 次 <code>style.transform = ...</code></p>
<div class="card tip"><p><strong>可借鉴</strong>:任何需要多 tween 合成单一属性box-shadow、filter、gradient stops的场景都能用这个 cache + 尾端 marker 的模式。</p></div>
</section>
<!-- Values -->
<section id="values">
<h2>值系统:预解析四象限</h2>
<p><code>core/values.js</code> 是整个库的"解析中心"。所有运行期开销都提前到构造时。</p>
<h3>tweenTypes 五分法</h3>
<p><a class="ref">values.js:L94-107</a> 根据"目标类型 × 属性名"把每个 tween 分五档:</p>
<table>
<thead><tr><th>类型</th><th>场景</th><th>写入方式</th></tr></thead>
<tbody>
<tr><td>OBJECT</td><td>普通 JS 对象 / 非 DOM</td><td><code>target[prop] = v</code></td></tr>
<tr><td>ATTRIBUTE</td><td>SVG attribute / 其他 DOM attribute</td><td><code>target.setAttribute(prop, v)</code></td></tr>
<tr><td>CSS</td><td>普通样式属性</td><td><code>target.style[prop] = v</code></td></tr>
<tr><td>TRANSFORM</td><td>translateX/Y/Z、rotate、scale、skew 等</td><td>缓存到 Symbol + 一帧一拼接</td></tr>
<tr><td>CSS_VAR</td><td><code>--my-var</code></td><td><code>style.setProperty('--x', v)</code></td></tr>
</tbody>
</table>
<p>TRANSFORM 单独一类是整个库的点睛之笔——让 <code>translateX</code> / <code>scaleY</code> / <code>rotate</code> 能独立补间,再合成。</p>
<h3>decomposeRawValue四类值预解析</h3>
<pre><code><span class="c">// 解析结果targetObject</span>
{
t: valueType, <span class="c">// NUMBER / UNIT / COLOR / COMPLEX</span>
n: 主数字,
u: 单位 (<span class="s">'px'</span>, <span class="s">'%'</span>, <span class="s">'turn'</span>, ...),
o: 运算符 (<span class="s">'+'</span>, <span class="s">'-'</span>, <span class="s">'*'</span>),
d: 数字数组, <span class="c">// COLOR = [r,g,b,a]; COMPLEX = 所有匹配的数字</span>
s: 字符串片段数组 <span class="c">// COMPLEX 专用</span>
}</code></pre>
<p><a class="ref">values.js:L170-218</a>。典型 COMPLEX 例:<code>"rgb(0,0,0) 10px 10px 20px inset"</code> 被切成 <code>s = ["rgb(", ",", ",", ") ", "px ", "px ", "px inset"]</code><code>d = [0,0,0,10,10,20]</code>。运行期只需对 <code>d</code> 做 lerp + 与 <code>s</code> 交错拼接。<strong>正则只执行一次</strong></p>
<h3>getTweenType 决策树</h3>
<pre><code><span class="c">// 五档优先级分派</span>
<span class="k">return</span> !target[isDomSymbol] ? tweenTypes.OBJECT :
target[isSvgSymbol] &amp;&amp; <span class="fn">isValidSVGAttribute</span>(target, prop) ? tweenTypes.ATTRIBUTE :
validTransforms.<span class="fn">includes</span>(prop) || shortTransforms.<span class="fn">get</span>(prop) ? tweenTypes.TRANSFORM :
<span class="fn">stringStartsWith</span>(prop, <span class="s">'--'</span>) ? tweenTypes.CSS_VAR :
prop <span class="k">in</span> target.style ? tweenTypes.CSS :
prop <span class="k">in</span> target ? tweenTypes.OBJECT :
tweenTypes.ATTRIBUTE;</code></pre>
<p><a class="ref">values.js:L94-107</a>。Symbol 标记的 <code>isDomSymbol / isSvgSymbol</code> 避免每次 <code>instanceof</code>,是在 <code>registerTargets</code> 注册时就打标。</p>
</section>
<hr>
<!-- Timer -->
<section id="timer">
<h2>Timer 基类:生命周期语义</h2>
<p><a class="ref">timer.js:L106-530</a>。继承 Clock 加完整的"播放器"语义。</p>
<h3>seek 时必须 reviveTimer</h3>
<pre><code><span class="k">const</span> <span class="fn">reviveTimer</span> = timer =&gt; {
<span class="k">if</span> (!timer._cancelled) <span class="k">return</span> timer;
<span class="k">if</span> (timer._hasChildren) {
<span class="fn">forEachChildren</span>(timer, reviveTimer);
} <span class="k">else</span> {
<span class="fn">forEachChildren</span>(timer, (tween) =&gt; {
<span class="k">if</span> (tween._composition !== compositionTypes.none) {
<span class="fn">composeTween</span>(tween, <span class="fn">getTweenSiblings</span>(tween.target, tween.property));
}
});
}
timer._cancelled = <span class="n">0</span>;
<span class="k">return</span> timer;
}</code></pre>
<p><a class="ref">timer.js:L86-99</a>cancel 会把 tween 从 siblings 链拔掉。如果直接 <code>seek(0)</code> 会渲染空。所以 seek/reset 前先 revive把所有 tween 重新 compose 回链中。这个细节很容易被忽视。</p>
<h3>.then() 的反递归 hack</h3>
<pre><code><span class="fn">then</span>(callback = noop) {
<span class="k">const</span> then = <span class="k">this</span>.then;
<span class="k">const</span> onResolve = () =&gt; {
<span class="c">// 如果 async function return 这个 thenable会无限递归置空 then 阻断</span>
<span class="k">this</span>.then = <span class="k">null</span>;
<span class="fn">callback</span>(<span class="k">this</span>);
<span class="k">this</span>.then = then;
<span class="k">this</span>._resolve = noop;
}
<span class="k">return</span> <span class="k">new</span> <span class="fn">Promise</span>(r =&gt; {
<span class="k">this</span>._resolve = () =&gt; <span class="fn">r</span>(<span class="fn">onResolve</span>());
<span class="k">if</span> (<span class="k">this</span>.completed) <span class="k">this</span>._resolve();
<span class="k">return</span> <span class="k">this</span>;
});
}</code></pre>
<p><a class="ref">timer.js:L512-528</a>,引用 GitHub issue #26。让 Timer 变成 Promise-compatible 同时避免 <code>await animation</code> 在 async 函数里无限递归。</p>
<h3>stretch等比缩放 duration</h3>
<p><a class="ref">timer.js:L470-482</a>。动态调整整个 Timer 的 duration同时按比例缩放 <code>_offset / _delay / _loopDelay</code>。给 Timeline 用来"压缩已添加的 children"。</p>
</section>
<!-- Animation -->
<section id="animation">
<h2>JSAnimation 构造器747 行流水线</h2>
<p><a class="ref">animation.js:L210-740</a>,构造函数长达 442 行,是一条完整的值归一化管线。</p>
<h4>Stage 1输入归一</h4>
<ol>
<li><code>registerTargets(targets)</code><code>'.btn'</code> / Element / NodeList / ReactRef 统一成数组。</li>
<li><code>keyframes</code> 字段(数组或百分比对象)被 <code>generateKeyframes</code> 展开成 per-property 的数组。<a class="ref">animation.js:L127-208</a></li>
<li>每个 property 的 value 归一成 <code>keyframes[]</code> —— <code>{to: v}</code> / <code>[from, to]</code> / <code>[v1, v2, v3]</code> / <code>{to, from, duration, ease}[]</code> 各种语法最终都化简到同一结构。<a class="ref">animation.js:L305-332</a></li>
</ol>
<h4>Stage 2composition override</h4>
<pre><code><span class="k">if</span> (tweenComposition !== compositionTypes.none) {
<span class="k">if</span> (!siblings) siblings = <span class="fn">getTweenSiblings</span>(target, propName);
<span class="k">let</span> nextSibling = siblings._head;
<span class="k">while</span> (nextSibling &amp;&amp; !nextSibling._isOverridden &amp;&amp; nextSibling._absoluteStartTime &lt;= absoluteStartTime) {
prevSibling = nextSibling;
nextSibling = nextSibling._nextRep;
<span class="c">// 后面的 sibling 直接 override</span>
<span class="k">if</span> (nextSibling &amp;&amp; nextSibling._absoluteStartTime &gt;= absoluteStartTime) {
<span class="k">while</span> (nextSibling) {
<span class="fn">overrideTween</span>(nextSibling);
nextSibling = nextSibling._nextRep;
}
}
}
}</code></pre>
<p><a class="ref">animation.js:L393-409</a>。通过 WeakMap&lt;Target, {prop: siblings}&gt;<code>composition.js:L45-69</code>)查找所有影响该 (target, property) 的 tween 链表,后续 siblings 自动 override。</p>
<h4>Stage 3值解析 + 类型对齐</h4>
<p>关键是 <a class="ref">animation.js:L475-494</a><strong>类型不匹配自动对齐</strong></p>
<ul>
<li>complex vs number → 把 number 拍成 complex</li>
<li>unit 不同 → <code>convertValueUnit</code> 调一次 getComputedStyle 换算</li>
<li>color vs non-color → 占位填 <code>[0,0,0,1]</code></li>
</ul>
<h4>Stage 4Tween 创建(字面对象工厂)</h4>
<p><a class="ref">animation.js:L524-562</a>。每个 Tween 是 plain object29 个字段,非 class —— 省 prototype 开销。一个 tween 同时存在于<strong>三条链表</strong>中:</p>
<ul>
<li><code>_prev / _next</code> —— 所属 Animation 的 tween 链</li>
<li><code>_prevRep / _nextRep</code> —— target-property siblings 链replace</li>
<li><code>_prevAdd / _nextAdd</code> —— additive siblings 链blend</li>
</ul>
<h4>Stage 5性能护栏1000 targets 自动关 composition</h4>
<pre><code><span class="k">const</span> tComposition = <span class="fn">isUnd</span>(composition) &amp;&amp; targetsLength &gt;= K
? compositionTypes.none : ...;</code></pre>
<p><a class="ref">animation.js:L263</a>targets 数量 ≥ 1000 时默认关掉 composition避免大 stagger 时的 sibling lookup 开销。<strong>用户无感的性能兜底</strong></p>
<h4>Stage 6iterationDelay trim pass</h4>
<p><a class="ref">animation.js:L624-635</a>。扫一遍所有 tween 的 startTime把最小的 delay 从整个 Animation 提取出来当 <code>this._delay</code>,其他 tween 减掉。这样 <code>iterationProgress</code> 对应"真实动画时长"而不是含前导 delay 的时长。</p>
</section>
<!-- Composition -->
<section id="composition">
<h2>Composition 三态</h2>
<p>所有 tween 有三种 composition 模式(<code>consts.js:L41-45</code></p>
<table>
<thead><tr><th>模式</th><th></th><th>行为</th></tr></thead>
<tbody>
<tr><td>replace</td><td>0默认</td><td>新 tween override 后续 siblings截短前面 siblings 的 changeDuration 到 overlap 点</td></tr>
<tr><td>none</td><td>1</td><td>不 compose。纯粹写入。超多 targets 或性能关键场景</td></tr>
<tr><td>blend</td><td>2</td><td>叠加式。多个动画的 delta 累加到同一属性</td></tr>
</tbody>
</table>
<h3>replace 的截短逻辑</h3>
<pre><code><span class="k">const</span> prevAbsEndTime = prevSibling._absoluteStartTime + prevSibling._changeDuration;
<span class="k">const</span> absoluteUpdateStartTime = tweenAbsStartTime - tween._delay;
<span class="k">if</span> (prevAbsEndTime &gt; absoluteUpdateStartTime) {
<span class="k">const</span> prevTLOffset = prevAbsEndTime - (prevChangeStartTime + prevSibling._updateDuration);
<span class="k">const</span> updatedPrevChangeDuration = <span class="fn">round</span>(absoluteUpdateStartTime - prevTLOffset - prevChangeStartTime, <span class="n">12</span>);
prevSibling._changeDuration = updatedPrevChangeDuration; <span class="c">// 截短前面 tween</span>
prevSibling._currentTime = updatedPrevChangeDuration;
prevSibling._isOverlapped = <span class="n">1</span>;
<span class="k">if</span> (updatedPrevChangeDuration &lt; minValue) {
<span class="fn">overrideTween</span>(prevSibling); <span class="c">// 完全覆盖</span>
}
}</code></pre>
<p><a class="ref">composition.js:L142-158</a>。精妙之处:并不把前面的 tween 删掉,而是"截短它的 changeDuration"—— 允许它继续停留在末态,只是不再推进。</p>
<h3>多层 siblings 去活跃(父链 cleanup</h3>
<p><a class="ref">composition.js:L162-191</a>:如果某个 Animation 的所有 tween 都被 overlapped那这个 Animation 已经无实际作用;如果它父 Timeline 的所有 children Animations 也都无实际作用,就级联 cancel 父 Timeline。<strong>"僵尸动画"自动回收</strong></p>
</section>
<!-- Additive -->
<section id="additive">
<h2>Additive 叠加blend composition</h2>
<p>blend 模式的实现是 anime 最巧妙的算法之一。<a class="ref">composition.js:L216-258</a> + <a class="ref">additive.js</a></p>
<h3>核心思路:把 tween 变成 delta</h3>
<pre><code><span class="c">// 第一次 blend 时创建 lookupTween累加器</span>
<span class="k">if</span> (!lookupTween) {
lookupTween = { ...tween };
lookupTween._composition = compositionTypes.replace;
lookupTween._updateDuration = minValue;
...
<span class="fn">addChild</span>(additiveAnimation, lookupTween);
}
<span class="c">// 把新 tween 的 from/to 变成相对 delta</span>
<span class="k">const</span> toNumber = tween._toNumber;
tween._fromNumber = lookupTween._fromNumber - toNumber; <span class="c">// 相对起点</span>
tween._toNumber = <span class="n">0</span>; <span class="c">// 终点归零delta 累积完后回归)</span>
lookupTween._fromNumber = toNumber;</code></pre>
<h3>engine 每帧的 additive.update()</h3>
<pre><code>additive.update = () =&gt; {
lookups.<span class="fn">forEach</span>(propertyAnimation =&gt; {
<span class="k">for</span> (<span class="k">let</span> propertyName <span class="k">in</span> propertyAnimation) {
<span class="k">const</span> tweens = propertyAnimation[propertyName];
<span class="k">const</span> lookupTween = tweens._head;
<span class="k">let</span> additiveValue = lookupTween._fromNumber;
<span class="k">let</span> tween = tweens._tail;
<span class="k">while</span> (tween &amp;&amp; tween !== lookupTween) {
additiveValue += tween._number; <span class="c">// 累加所有 tween 的当前值</span>
tween = tween._prevAdd;
}
lookupTween._toNumber = additiveValue;
}
});
<span class="fn">render</span>(animation, <span class="n">1</span>, <span class="n">1</span>, <span class="n">0</span>, tickModes.FORCE);
}</code></pre>
<p><a class="ref">additive.js:L41-81</a>。每帧 <code>engine.update()</code> 末尾调用一次 <a class="ref">engine.js:L89</a>,聚合所有 blend 动画的当前值force-render lookup tween 一次。</p>
<div class="card accent">
<h4>典型场景</h4>
<p>鼠标 hover → scale up+0.1<br>持续 idle wobble → scale ±0.05<br>点击 → shake scale ±0.2<br>三个动画同时生效时scale 自动累加,而非互相覆盖。这正是 Animatable 内部用 blend 的原因。</p>
</div>
<div class="demo">
<div class="demo-head">
<div class="demo-title">blend composition — idle wobble + click shake 叠加不打架</div>
<div class="demo-btns">
<button class="demo-btn primary" data-demo="additive-shake">点击按钮触发 shake</button>
<button class="demo-btn" data-demo="additive-toggle">开关持续抖动</button>
</div>
</div>
<div class="demo-stage tall">
<button class="additive-btn" id="additiveBtn">CLICK ME</button>
<div class="additive-hint" style="margin-left:2rem">
按钮同时在跑<br>· scale idle wobble<br>· rotate idle wobble<br>点击时 shake 叠加不干扰
</div>
</div>
</div>
</section>
<!-- Timeline -->
<section id="timeline">
<h2>Timeline + 位置语法</h2>
<p><a class="ref">timeline.js:L135-356</a>。继承 Timer主要加三件<code>labels</code><code>add(...)</code> 多态、<code>defaults</code> 子项默认值。</p>
<h3>位置语法糖timeline/position.js</h3>
<table>
<thead><tr><th>语法</th><th>含义</th></tr></thead>
<tbody>
<tr><td>(省略)</td><td>追加到末尾(当前 iterationDuration</td></tr>
<tr><td>1000</td><td>绝对位置 1000ms</td></tr>
<tr><td>'labelName'</td><td>跳到 label</td></tr>
<tr><td>'&lt;'</td><td>上一个 child 的<strong>起点</strong>_offset + _delay</td></tr>
<tr><td>'&lt;&lt;'</td><td>上一个 child 的<strong>终点</strong></td></tr>
<tr><td>'+=500'</td><td>末尾 + 500ms</td></tr>
<tr><td>'labelName+=200'</td><td>label + 200ms</td></tr>
<tr><td>'&lt;*=2'</td><td>上一个 child 起点 × 2</td></tr>
</tbody>
</table>
<p>解析逻辑在 <a class="ref">position.js:L50-73</a><code>parseTimelinePosition</code>优先级sibling 定位 &gt; label &gt; tlDuration。</p>
<h3>add 的 stagger 分支</h3>
<pre><code><span class="k">if</span> (<span class="fn">isFnc</span>(a3)) { <span class="c">// 第三参数是 stagger 生成器</span>
<span class="k">const</span> tlDuration = <span class="k">this</span>.duration;
<span class="k">const</span> tlIterationDuration = <span class="k">this</span>.iterationDuration;
parsedTargetsArray.<span class="fn">forEach</span>(target =&gt; {
<span class="k">const</span> staggeredChildParams = { ...childParams };
<span class="c">// 关键:每个 target 加入前重置 duration</span>
<span class="k">this</span>.duration = tlDuration;
<span class="k">this</span>.iterationDuration = tlIterationDuration;
<span class="fn">addTlChild</span>(
staggeredChildParams,
<span class="k">this</span>,
<span class="fn">parseTimelinePosition</span>(<span class="k">this</span>, <span class="fn">staggeredPosition</span>(target, i, parsedLength, <span class="k">this</span>)),
target, i, parsedLength
);
i++;
});
}</code></pre>
<p><a class="ref">timeline.js:L186-215</a>。每个 target 一个独立 JSAnimation起点由 stagger 函数决定。<strong>每次 addChild 前重置 duration/iterationDuration 很关键</strong>,否则后续 stagger 起点会漂移。</p>
<h3>syncWAAPI / 原生 Animation 并入 Timeline</h3>
<pre><code><span class="fn">sync</span>(synced, position) {
synced.<span class="fn">pause</span>();
<span class="k">const</span> duration = synced.effect ? synced.effect.<span class="fn">getTiming</span>().duration : synced.duration;
<span class="k">return</span> <span class="k">this</span>.<span class="fn">add</span>(synced, {
currentTime: [<span class="n">0</span>, duration], <span class="c">// 补间外部对象的 currentTime</span>
duration, delay: <span class="n">0</span>,
ease: <span class="s">'linear'</span>, playbackEase: <span class="s">'linear'</span>
}, position);
}</code></pre>
<p><a class="ref">timeline.js:L256-265</a>。用补间外部对象 <code>currentTime</code> 的方式,把 WAAPI Animation 驱动进 anime Timeline。非常机智的统一。</p>
<div class="demo">
<div class="demo-head">
<div class="demo-title">Timeline 位置语法 — 三个 box 用 &lt; += label 衔接</div>
<div class="demo-btns">
<button class="demo-btn primary" data-demo="tl-play">▶ 播放</button>
<button class="demo-btn" data-demo="tl-restart">从头重播</button>
<button class="demo-btn" data-demo="tl-reverse">反向</button>
</div>
</div>
<div class="demo-stage tall">
<div class="tl-row">
<div class="box tl-box-a" id="tlBoxA">A</div>
<div class="box tl-box-b" id="tlBoxB">B</div>
<div class="box tl-box-c" id="tlBoxC">C</div>
</div>
</div>
</div>
</section>
<hr>
<!-- Stagger -->
<section id="stagger">
<h2>Stagger复合生成器</h2>
<p><a class="ref">utils/stagger.js:L82-142</a>。返回 <code>(target, index, total, timeline?) =&gt; number|string</code> 函数。功能密度非常高。</p>
<table>
<thead><tr><th>参数</th><th>行为</th></tr></thead>
<tbody>
<tr><td>from: 'first' | 'last' | 'center' | 'random' | number</td><td>起始索引</td></tr>
<tr><td>grid: [cols, rows] + axis: 'x' | 'y'</td><td>栅格模式,欧几里得或单轴距离</td></tr>
<tr><td>stagger([0, 100])</td><td>range stagger值域等分而非固定间距</td></tr>
<tr><td>ease</td><td>索引归一化 [0,1] 过 easing 再映射回去</td></tr>
<tr><td>use: 'data-delay'</td><td>从元素属性读 index 值</td></tr>
<tr><td>total</td><td>自定义"虚拟总数"N 个元素但假装是 M 个</td></tr>
<tr><td>start: 'labelName'</td><td>支持 timeline 位置语法作为 base offset</td></tr>
</tbody>
</table>
<h3>栅格距离算法</h3>
<pre><code><span class="k">const</span> fromX = !fromCenter ? fromIndex % grid[<span class="n">0</span>] : (grid[<span class="n">0</span>] - <span class="n">1</span>) / <span class="n">2</span>;
<span class="k">const</span> fromY = !fromCenter ? <span class="fn">floor</span>(fromIndex / grid[<span class="n">0</span>]) : (grid[<span class="n">1</span>] - <span class="n">1</span>) / <span class="n">2</span>;
<span class="k">const</span> toX = index % grid[<span class="n">0</span>];
<span class="k">const</span> toY = <span class="fn">floor</span>(index / grid[<span class="n">0</span>]);
<span class="k">let</span> value = <span class="fn">sqrt</span>(distanceX * distanceX + distanceY * distanceY);
<span class="k">if</span> (axis === <span class="s">'x'</span>) value = -distanceX; <span class="c">// 单轴模式</span>
<span class="k">if</span> (axis === <span class="s">'y'</span>) value = -distanceY;</code></pre>
<p><a class="ref">utils/stagger.js:L117-126</a>。values 数组懒初始化 + 缓存,第一次调用时算完整个距离 map。</p>
<div class="demo">
<div class="demo-head">
<div class="demo-title">stagger({ grid: [15,8], from: 'center' }) 波纹</div>
<div class="demo-btns">
<button class="demo-btn" data-demo="stagger-center">从中心</button>
<button class="demo-btn" data-demo="stagger-first">从第一个</button>
<button class="demo-btn" data-demo="stagger-random">随机</button>
<button class="demo-btn primary" data-demo="stagger-wave">波纹循环</button>
</div>
</div>
<div class="demo-stage tall">
<div class="stagger-grid" id="staggerGrid"></div>
</div>
</div>
</section>
<!-- Animatable -->
<section id="animatable">
<h2>Animatable超高频 setter API</h2>
<p><a class="ref">animatable.js:L41-153</a>。场景鼠标跟随、陀螺仪、3D 控制这类"<strong>逐帧被外部驱动</strong>"的动画。</p>
<h3>用法</h3>
<pre><code><span class="k">const</span> a = <span class="fn">createAnimatable</span>(el, { x: <span class="n">200</span>, y: <span class="n">200</span>, ease: <span class="s">'outQuad'</span> });
document.<span class="fn">addEventListener</span>(<span class="s">'mousemove'</span>, e =&gt; a.<span class="fn">x</span>(e.clientX).<span class="fn">y</span>(e.clientY));</code></pre>
<h3>内部结构</h3>
<ul>
<li>构造时为<strong>每个 property 创建一个独立 autoplay:false JSAnimation</strong>L107</li>
<li>每个 property 的 setter 函数:无参返回当前值;有参更新 from/to → <code>reset(true).resume()</code></li>
<li>额外一个 dummy <code>callbacks</code> Animation<code>{v: 0}</code><code>{v: 1}</code>)统一管理 begin/pause/complete —— 多个独立 Animation 的 "全部完成" 才视为整体 complete。<a class="ref">animatable.js:L52-90</a></li>
</ul>
<p>配合 blend composition可实现"多输入源推同一对象,平滑叠加"—— 典型如粒子系统有重力、鼠标吸引、风力三种力同时作用。</p>
<div class="demo">
<div class="demo-head">
<div class="demo-title">createAnimatable 鼠标跟随 — 不同 easing 对比</div>
<div class="demo-btns">
<button class="demo-btn" data-demo="follow-linear">linear</button>
<button class="demo-btn primary" data-demo="follow-elastic">outElastic</button>
<button class="demo-btn" data-demo="follow-quad">outQuad</button>
</div>
</div>
<div class="demo-stage tall">
<div class="follow-stage" id="followStage">
<div class="follow-dot" id="followDot"></div>
<div class="follow-hint">↑ 在区域内移动鼠标</div>
</div>
</div>
</div>
</section>
<!-- Scope -->
<section id="scope">
<h2>ScopeReact / Angular 生命周期桥</h2>
<p><a class="ref">scope/scope.js:L41-253</a>。核心概念是 <code>scope.execute(cb)</code><strong>压栈式上下文</strong></p>
<pre><code><span class="fn">execute</span>(cb) {
<span class="k">const</span> activeScope = scope.current;
<span class="k">const</span> activeRoot = scope.root;
<span class="k">const</span> activeDefaults = globals.defaults;
<span class="c">// 压栈</span>
scope.current = <span class="k">this</span>;
scope.root = <span class="k">this</span>.root;
globals.defaults = <span class="k">this</span>.defaults;
<span class="k">const</span> returned = <span class="fn">cb</span>(<span class="k">this</span>);
<span class="c">// 还原</span>
scope.current = activeScope;
scope.root = activeRoot;
globals.defaults = activeDefaults;
<span class="k">return</span> returned;
}</code></pre>
<p>Timer constructor 里(<a class="ref">timer.js:L137</a>)自动 <code>scope.current.register(this)</code>,所以在 scope.add 的 cb 里创建的动画会自动被 scope 管理。</p>
<h3>关键 API</h3>
<ul>
<li><code>.add(fn)</code> —— 每次 refresh 时重跑</li>
<li><code>.add(name, fn)</code> —— 注册方法,调用时用 scope context</li>
<li><code>.addOnce(fn)</code> —— 只跑一次cache in <code>constructorsOnce</code></li>
<li><code>.keepTime(fn)</code> —— 返回 tickablerefresh 时<strong>保留 currentTime 不重置</strong></li>
<li><code>.refresh()</code> —— revert + re-run constructorsmedia query 变化时触发)</li>
<li><code>.revert()</code> —— 倒序 revert 所有 revertibles</li>
</ul>
<h3>React 集成</h3>
<pre><code><span class="k">useEffect</span>(() =&gt; {
<span class="k">const</span> scope = <span class="fn">createScope</span>({ root: ref }).<span class="fn">add</span>(() =&gt; {
<span class="fn">animate</span>(<span class="s">'.box'</span>, { translateX: <span class="n">100</span> });
});
<span class="k">return</span> () =&gt; scope.<span class="fn">revert</span>();
}, []);</code></pre>
<p><a class="ref">scope.js:L49</a> 识别 <code>ReactRef.current</code> / <code>AngularRef.nativeElement</code>,自动解包。</p>
<h3>mediaQueries响应式动画</h3>
<pre><code><span class="fn">createScope</span>({
mediaQueries: { mobile: <span class="s">'(max-width: 800px)'</span> }
}).<span class="fn">add</span>(self =&gt; {
<span class="k">if</span> (self.matches.mobile) {
<span class="fn">animate</span>(<span class="s">'.box'</span>, { translateX: <span class="n">50</span> });
} <span class="k">else</span> {
<span class="fn">animate</span>(<span class="s">'.box'</span>, { translateX: <span class="n">200</span> });
}
});</code></pre>
<p><a class="ref">scope.js:L85-91</a>。change 事件触发 <code>refresh()</code>revert 旧动画 + 重跑 constructors不用手写 resize listener + 重启动画。</p>
</section>
<!-- SVG -->
<section id="svg">
<h2>SVG 三件套</h2>
<h3>morphTo —— 不同顶点数的 path 变形</h3>
<pre><code><span class="k">const</span> length1 = $path1.<span class="fn">getTotalLength</span>();
<span class="k">const</span> length2 = $path2.<span class="fn">getTotalLength</span>();
<span class="k">const</span> maxPoints = <span class="fn">max</span>(<span class="fn">ceil</span>(length1 * precision), <span class="fn">ceil</span>(length2 * precision));
<span class="k">for</span> (<span class="k">let</span> i = <span class="n">0</span>; i &lt; maxPoints; i++) {
<span class="k">const</span> t = i / (maxPoints - <span class="n">1</span>);
<span class="k">const</span> p1 = $path1.<span class="fn">getPointAtLength</span>(length1 * t);
<span class="k">const</span> p2 = $path2.<span class="fn">getPointAtLength</span>(length2 * t);
v1 += prefix + <span class="fn">round</span>(p1.x, <span class="n">3</span>) + sep + p1.y + <span class="s">' '</span>;
v2 += prefix + <span class="fn">round</span>(p2.x, <span class="n">3</span>) + sep + p2.y + <span class="s">' '</span>;
}</code></pre>
<p><a class="ref">svg/morphto.js:L25-65</a>。把两条 path 重采样到相同点数precision × max length转成同构的 "M L L L L..." 字符串,交给 anime 做 complex tween。结果缓存到 <code>$path[morphPointsSymbol]</code> 供下次使用。</p>
<h3>createMotionPath —— 沿 path 运动</h3>
<pre><code><span class="k">return</span> {
translateX: <span class="fn">getPathProgess</span>($path, <span class="s">'x'</span>, offset),
translateY: <span class="fn">getPathProgess</span>($path, <span class="s">'y'</span>, offset),
rotate: <span class="fn">getPathProgess</span>($path, <span class="s">'a'</span>, offset),
}
<span class="c">// rotate 用前后两点的 atan2 做中心差分</span>
<span class="k">return</span> <span class="fn">atan2</span>(p1.y - p0.y, p1.x - p0.x) * <span class="n">180</span> / PI;</code></pre>
<p><a class="ref">svg/motionpath.js:L80-88</a>。返回三个 FunctionValue 对象,让 anime 直接 <code>animate(el, motionPath)</code>。SVG 内坐标 vs HTML 坐标通过 CTM 矩阵换算(<code>p.x * ctm.a + p.y * ctm.c + ctm.e</code>L66-69</p>
<h3>createDrawable —— Proxy 实现的画线效果</h3>
<pre><code><span class="k">const</span> proxy = <span class="k">new</span> <span class="fn">Proxy</span>($el, {
<span class="fn">get</span>(target, property) {
<span class="k">if</span> (property === <span class="s">'setAttribute'</span>) {
<span class="k">return</span> (...args) =&gt; {
<span class="k">if</span> (args[<span class="n">0</span>] === <span class="s">'draw'</span>) {
<span class="k">const</span> [v1, v2] = args[<span class="n">1</span>].<span class="fn">split</span>(<span class="s">' '</span>).<span class="fn">map</span>(Number);
<span class="k">const</span> os = v1 * -pathLength * scaleFactor;
<span class="k">const</span> d1 = v2 * pathLength * scaleFactor + os;
<span class="k">const</span> d2 = pathLength * scaleFactor - d1;
target.<span class="fn">setAttribute</span>(<span class="s">'stroke-dashoffset'</span>, <span class="s">`${os}`</span>);
target.<span class="fn">setAttribute</span>(<span class="s">'stroke-dasharray'</span>, <span class="s">`${d1} ${d2}`</span>);
}
<span class="k">return</span> <span class="fn">Reflect.apply</span>(value, target, args);
};
}
...
}
});</code></pre>
<p><a class="ref">svg/drawable.js:L54-95</a><strong>精妙之处</strong>:不引入新 CSS 属性,而是用 Proxy 拦截 <code>setAttribute('draw', '0 0.5')</code>,转译成 <code>stroke-dasharray</code> + <code>stroke-dashoffset</code>。且强行设置 <code>pathLength=1000</code>L47, L96-97让 [0, 1000] 成为规范化空间 —— 不管 path 真实长度多少,画线 API 一致。</p>
<div class="demo">
<div class="demo-head">
<div class="demo-title">SVG 三件套同台 — morphTo / motionPath / drawable</div>
<div class="demo-btns">
<button class="demo-btn primary" data-demo="svg-run">▶ 运行全部</button>
</div>
</div>
<div class="demo-stage tall" style="padding:1.5rem 1rem">
<div class="svg-row">
<div class="svg-card">
<svg width="180" height="180" viewBox="0 0 100 100">
<path id="morphPath" d="M 50 10 L 70 40 L 90 50 L 70 60 L 50 90 L 30 60 L 10 50 L 30 40 Z"
fill="none" stroke="#ff8a5b" stroke-width="2.5" stroke-linejoin="round"/>
</svg>
<div class="svg-card-label">morphTo ← 星/心/圆</div>
</div>
<div class="svg-card">
<svg width="180" height="180" viewBox="0 0 100 100">
<path id="motionTrack" d="M 15 50 Q 35 10, 50 50 T 85 50"
fill="none" stroke="#2a2a30" stroke-width="1" stroke-dasharray="2 2"/>
<circle id="motionBall" cx="0" cy="0" r="6" fill="#5ed1b7"/>
</svg>
<div class="svg-card-label">motionPath 沿曲线跑</div>
</div>
<div class="svg-card">
<svg width="180" height="180" viewBox="0 0 100 100">
<path id="drawablePath" d="M 20 80 Q 20 20 50 20 Q 80 20 80 50 Q 80 80 50 80 L 20 80"
fill="none" stroke="#c792ea" stroke-width="3" stroke-linecap="round"/>
</svg>
<div class="svg-card-label">drawable 画线 0→1</div>
</div>
</div>
</div>
</div>
</section>
<!-- Text -->
<section id="text">
<h2>Text SplitIntl.Segmenter + ARIA</h2>
<p><a class="ref">text/split.js</a>512 行)。把一段文本拆成 line / word / char 三层 span。</p>
<ul>
<li><strong>Unicode 正确分词</strong><code>Intl.Segmenter</code> 可用时用它中日韩、emoji 都正确),否则 fallback 到空格分割。<a class="ref">split.js:L41</a></li>
<li><strong>Accessibility</strong>:原文元素保留可读性,拆出来的副本加 <code>aria-hidden="true"</code><a class="ref">split.js:L81</a> —— 屏幕阅读器看到原文,视觉动画走副本。</li>
<li><strong>Line 重排</strong>:换行逻辑用 <code>filterLineElements</code> 递归剔除不属于此行的元素 + 相邻空白 textNode避免孤零零残留<a class="ref">split.js:L109-126</a></li>
<li><strong>模板占位</strong><code>{value}</code> / <code>{i}</code> 支持用户自定义包装 HTML。<a class="ref">split.js:L42-43</a></li>
</ul>
<div class="demo">
<div class="demo-head">
<div class="demo-title">text.split + stagger — 文字逐字浮现</div>
<div class="demo-btns">
<button class="demo-btn" data-demo="text-up">上移进场</button>
<button class="demo-btn" data-demo="text-wave">波浪</button>
<button class="demo-btn primary" data-demo="text-glitch">故障态</button>
</div>
</div>
<div class="demo-stage tall">
<div class="text-demo-target" id="textTarget">anime.js · 每一个字都会动</div>
</div>
</div>
</section>
<!-- WAAPI -->
<section id="waapi">
<h2>WAAPI 降级:自定义 easing → CSS linear()</h2>
<p>anime.js 的 <code>waapi.animate()</code><a class="ref">waapi/waapi.js</a>)用 Web Animations API 让动画 off-main-thread 跑。最精彩的是<strong>自定义 easing 的降级策略</strong></p>
<pre><code><span class="k">const</span> <span class="fn">easingToLinear</span> = (fn, samples = <span class="n">100</span>) =&gt; {
<span class="k">const</span> points = [];
<span class="k">for</span> (<span class="k">let</span> i = <span class="n">0</span>; i &lt;= samples; i++)
points.<span class="fn">push</span>(<span class="fn">round</span>(<span class="fn">fn</span>(i / samples), <span class="n">4</span>));
<span class="k">return</span> <span class="s">`linear(${points.join(', ')})`</span>;
}</code></pre>
<p><a class="ref">waapi.js:L85-89</a>。anime 的自定义 easing<code>'outElastic(1, 0.3)'</code><code>spring({mass, stiffness})</code>)在 WAAPI 侧没有对应语法。作者采样 100 个点,用 CSS Level 4 的 <code>linear(v0, v1, ..., v100)</code> 合成一条分段线性 easing —— <strong>无需 JS 驱动就能 off-main-thread 跑任意曲线</strong></p>
<p>结果缓存到 <code>WAAPIEasesLookups</code>L91避免重复采样。</p>
<div class="card warn"><p><strong>浏览器兼容</strong>CSS <code>linear()</code> 只在 Chrome 113+ / Safari 17.2+ / Firefox 112+ 支持。更老浏览器会 fallback 到 <code>'linear'</code> 字面量。</p></div>
</section>
<hr>
<!-- Draggable -->
<section id="draggable">
<h2>Draggable1286 行的物理拖拽引擎</h2>
<p><a class="ref">draggable.js</a><strong>不继承 Timer</strong>,而是组合 3 个 Timer + 1 个 Animatable。</p>
<h3>状态机四标志</h3>
<table>
<thead><tr><th>标志</th><th>含义</th><th>位置</th></tr></thead>
<tbody>
<tr><td>grabbed</td><td>指针按下</td><td><a class="ref">draggable.js:L403</a></td></tr>
<tr><td>dragged</td><td>已移动超阈值</td><td><a class="ref">draggable.js:L404</a></td></tr>
<tr><td>updated</td><td>本帧有更新</td><td><a class="ref">draggable.js:L405</a></td></tr>
<tr><td>released</td><td>已松开</td><td><a class="ref">draggable.js:L406</a></td></tr>
</tbody>
</table>
<h3>事件流handleEvent 分发)</h3>
<ol>
<li><strong>pointerdown → handleDown</strong> <a class="ref">draggable.js:L888-953</a>:绑监听 → 初始化 pointer 坐标 → 触发 onGrab</li>
<li><strong>pointermove → handleMove</strong> <a class="ref">draggable.js:L958-1020</a><code>normalizePoint</code> 父元素 transform 反演 → touch 祖先可滚动检测 → drag threshold → 启动 updateTicker → 触发 onDrag</li>
<li><strong>pointerup → handleUp</strong> <a class="ref">draggable.js:L1022-1151</a>:速度采样 → 弹道预测 → spring/easing 二选一驱动回位 → onRelease</li>
</ol>
<h3>物理3 帧速度栈取 max</h3>
<pre><code><span class="c">// L479-495 computeVelocity —— 用循环缓冲</span>
velocityStack[vi] = <span class="fn">clamp</span>(
(<span class="fn">sqrt</span>(dx*dx + dy*dy) / elapsed) * vMul,
minV, maxV
);
<span class="c">// 取栈中最大值,而非线性平均</span>
velocity = <span class="fn">max</span>(...velocityStack);</code></pre>
<p><a class="ref">draggable.js:L479-495</a><strong>取 max 而非 avg</strong> —— 避免最后几帧减速导致惯性偏弱(物理上更接近人的预期)。</p>
<h3>双阶段过冲动画</h3>
<p><a class="ref">draggable.js:L1090-1109</a>。非 spring 模式下若反弹超出边界先动画到物理计算的过冲点65% 时间),再反弹到最终点 —— 让"撞墙回弹"自然。而不是生硬的 easing。</p>
<h3>临时清空祖先 transform 测量</h3>
<p><a class="ref">draggable.js:L593</a><code>transforms.remove()</code> 临时清除父元素 transform 才能用 <code>getBoundingClientRect</code> 拿到准确边界,然后再 revert。<strong>这个技巧在 LayoutL386-L392和 ScrollL774-L781里也重复使用</strong></p>
<div class="demo">
<div class="demo-head">
<div class="demo-title">createDraggable — 拖我、撞墙会回弹</div>
<div class="demo-btns">
<button class="demo-btn" data-demo="drag-reset">回中心</button>
<button class="demo-btn primary" data-demo="drag-release-spring">spring 回弹</button>
<button class="demo-btn" data-demo="drag-snap">开启 50px snap</button>
</div>
</div>
<div class="demo-stage tall">
<div class="drag-stage" id="dragStage">
<div class="drag-box" id="dragBox">拖我</div>
</div>
</div>
</div>
</section>
<!-- Layout -->
<section id="layout">
<h2>Layout FLIP1607 行(库内最大单文件)</h2>
<p>实现 <strong>F</strong>irst / <strong>L</strong>ast / <strong>I</strong>nvert / <strong>P</strong>lay 动画。<a class="ref">layout.js</a></p>
<h3>触发:显式三步 API无 MutationObserver</h3>
<pre><code>layout.<span class="fn">record</span>(); <span class="c">// First</span>
<span class="c">// 用户自己改 DOM</span>
layout.<span class="fn">animate</span>(params); <span class="c">// Last + Invert + Play</span>
<span class="c">// 或一步到位</span>
layout.<span class="fn">update</span>(cb, params);</code></pre>
<p><a class="ref">layout.js:L1059-1099</a><strong>没有自动观测</strong>,调用方显式触发,设计者保持完全控制权。</p>
<h3>六维属性测量</h3>
<p>每个节点记录 <a class="ref">layout.js:L318-328</a><code>transform / x / y / left / top / clientLeft / clientTop / width / height</code> + 用户自定义(<code>opacity / color / fontSize</code>)。</p>
<h3>Invert 双引擎(最精彩处)</h3>
<ul>
<li><strong>位置+尺寸</strong> 用 anime.js Timeline 补间(<code>translate / width / height</code></li>
<li><strong>transform</strong> 用 WAAPI 并行跑,<code>timeline.sync(transformAnimation, 0)</code> 同步</li>
</ul>
<p><a class="ref">layout.js:L1566-1584</a>。作者注释写明:"transform 如果也走 Timeline 会和 translate 抢 style 属性"。必须拆成两个 pipeline 并时间同步 —— 深水炸弹级实现细节。</p>
<h3>五大难点的处理</h3>
<table>
<thead><tr><th>难点</th><th>处理</th><th>位置</th></tr></thead>
<tbody>
<tr><td>嵌套 FLIP 避免双重 invert</td><td>animatedParent 链追踪</td><td><a class="ref">layout.js:L1296-1300</a></td></tr>
<tr><td>display:none ↔ block 切换</td><td>hasVisibilitySwap + measuredDisplay 换脸</td><td><a class="ref">layout.js:L1219-1230</a></td></tr>
<tr><td>内联文本 (inline span)</td><td>hasAdjacentText → isInlined → 跳过位置动画</td><td><a class="ref">layout.js:L368-379</a></td></tr>
<tr><td>display:grid 干扰 transform</td><td>动画期间强行改 block</td><td><a class="ref">layout.js:L1408</a></td></tr>
<tr><td>swapAt 中途属性改变</td><td>双段 easing + tl.call() 50% 切换 DOM</td><td><a class="ref">layout.js:L1505-1545</a></td></tr>
</tbody>
</table>
<h3>灵魂代码:镜像 easing</h3>
<pre><code><span class="c">// L1531-1532 —— swapAt 后半段的反向 easing</span>
<span class="k">const</span> inverseEased = t =&gt; <span class="n">1</span> - <span class="fn">ease</span>(<span class="n">1</span> - t);</code></pre>
<p>对 swapAt 的后半段:如果前半段用 easing <code>f(t)</code>,后半段用 <code>1-f(1-t)</code> 才能让加速度在 50% 处对称。一行代码,需要人脑推导,代码紧凑但<strong>可读性差</strong>—— 典型的"高明但需要注释"的代码。</p>
</section>
<!-- Scroll -->
<section id="scroll">
<h2>Scroll986 行滚动驱动引擎</h2>
<p><a class="ref">events/scroll.js</a><strong>混合架构</strong>:原生 scroll 事件 + 3 层 Timer 合批 + 主动轮询(<em>而非 IntersectionObserver</em>)。</p>
<h3>三层 Timer 分层节流</h3>
<table>
<thead><tr><th>Timer</th><th>频率</th><th>职责</th></tr></thead>
<tbody>
<tr><td>scrollTicker</td><td>rAF</td><td>合批所有 observers 的 handleScroll</td></tr>
<tr><td>dataTimer</td><td>30Hz</td><td>算速度/方向,独立轨道不抢 rAF</td></tr>
<tr><td>wakeTicker</td><td>500ms 防抖</td><td>scroll 停止后延迟休眠 scrollTicker</td></tr>
</tbody>
</table>
<p>scroll 事件本身<strong>只做</strong> <code>wakeTicker.restart()</code>L284-285。真实工作合批到 rAF 帧里。</p>
<h3>Progress 映射纯函数</h3>
<pre><code><span class="fn">get</span> <span class="fn">progress</span>() {
<span class="k">const</span> p = (<span class="k">this</span>.scroll - <span class="k">this</span>.offsetStart) / <span class="k">this</span>.distance;
<span class="k">return</span> <span class="fn">round</span>(<span class="fn">clamp</span>(p, <span class="n">0</span>, <span class="n">1</span>), <span class="n">6</span>);
}</code></pre>
<p><a class="ref">scroll.js:L581-584</a>。scroll → timeline.seek 单向驱动。<strong>不支持</strong>反向驱动(拖 timeline 不会改 scroll</p>
<h3>Offset 字符串解析</h3>
<p>parseBoundValue <a class="ref">scroll.js:L358-387</a> 支持:</p>
<ul>
<li><code>'top' / 'start'</code> → 0</li>
<li><code>'bottom' / 'end'</code> → 100%</li>
<li><code>'center'</code> → 50%</li>
<li><code>'top 50%'</code> → 相对运算符解析</li>
</ul>
<h3>Sticky 临时禁用</h3>
<pre><code><span class="c">// L774-781 遍历祖先,临时禁用 sticky</span>
<span class="k">while</span> ($el &amp;&amp; $el !== container.element) {
<span class="k">const</span> isSticky = <span class="fn">get</span>($el, <span class="s">'position'</span>) === <span class="s">'sticky'</span>
? <span class="fn">set</span>($el, { position: <span class="s">'static'</span> }) : <span class="k">false</span>;
$el = $el.parentElement;
<span class="k">if</span> (isSticky) stickys.<span class="fn">push</span>(isSticky);
}
<span class="c">// ... 测量 ...</span>
stickys.<span class="fn">forEach</span>(s =&gt; s.<span class="fn">revert</span>()); <span class="c">// L840-841 还原</span></code></pre>
<p>测量时临时禁用祖先 sticky 才能拿到正确 offset。计算完 revert 回去。和 Draggable 的 transform 反演同一思路。</p>
<h3>方向感知的四回调</h3>
<p><a class="ref">scroll.js:L873-921</a><code>enterForward / enterBackward / syncEnter / onEnter</code> 四套回调。根据 <code>container.backwardX/Y</code> 判断滚动方向触发对应分支。</p>
<h3>共享容器 + 自动 cleanup</h3>
<p><code>scrollContainers = new Map()</code> 全局缓存(<a class="ref">scroll.js:L116</a>),多个 observer 共享一个 <code>ScrollContainer</code>。最后一个 observer unsubscribe 时容器才真正 revert <a class="ref">scroll.js:L965-978</a></p>
</section>
<hr>
<!-- Gems -->
<section id="gems">
<h2>精彩设计合集15 招)</h2>
<ol>
<li><strong>/*#__PURE__*/ pragma</strong> 满天飞 —— IIFE 创建的 maps / singletons 都打标让 Rollup/Esbuild tree-shake。用户只用 <code>animate()</code>,整个 timeline/draggable/scroll 都能干掉。</li>
<li><strong>双向链表全家桶</strong> —— Engine children / Timeline children / Animation tweens / replace siblings / blend siblings 全复用同一套 <code>_prev/_next</code> 原语。</li>
<li><strong>有序插入</strong><code>addChild(parent, child, sortMethod?)</code> —— Tween 按 <code>_absoluteStartTime</code> 进 siblings 链自动排序。</li>
<li><strong>WeakMap + Symbol 缓存</strong> —— <code>transformsSymbol / morphPointsSymbol / proxyTargetSymbol</code> 不污染正经属性。</li>
<li><strong>预解析 + 运行期零正则</strong> —— COMPLEX 值的 <code>s[]</code> + <code>d[]</code> 创建期拆好60fps 只做数组 lerp + 模板拼接。</li>
<li><strong>Transform 一帧一拼</strong> —— 多个 transform tween 通过 Symbol cache + 链尾 marker 触发最终 <code>style.transform =</code> 写入。</li>
<li><strong>按需 rAF</strong> —— 队列空时 <code>reqId = 0</code> 自然终止resume 时 <code>engine.wake()</code> 重启。空闲时零开销。</li>
<li><strong>visibility 自动 pause</strong> —— 一行 listener 避免后台 tab 吃 CPU。</li>
<li><strong>alternate 的 XOR</strong> —— <code>_reversed ^ (_alternate &amp;&amp; isOdd)</code> 一行合成三状态。</li>
<li><strong>Proxy 虚拟属性</strong>drawable 'draw' —— 不扩展原生 API用 Proxy 转译。</li>
<li><strong>WAAPI easingToLinear(100 samples)</strong> —— 自定义 easing 降级 CSS linear(),让 GPU 跑 spring。</li>
<li><strong>1000 targets 自动关 composition</strong> —— 巨量元素 stagger 的无感性能兜底。</li>
<li><strong>Scope.keepTime</strong> —— media query refresh 时保留 timeline currentTime。</li>
<li><strong>playbackEase</strong> —— Timeline 级别的"时间 warp",不是单 tween easing 而是整个时间流扭曲。</li>
<li><strong>additive delta 累加</strong> —— 多 tween 自动叠加到 lookup accumulatorengine 每帧 force-render。</li>
</ol>
</section>
<!-- Pitfalls -->
<section id="pitfalls">
<h2>坑与可借鉴点</h2>
<h3>踩坑清单</h3>
<div class="card warn">
<ul>
<li><strong>毫秒 vs 秒切换</strong>timeUnit切到 's' 后内部全部乘 0.001,到处 <code>round(_, 12)</code> 抗浮点 —— 应用启动时定好,不要中途切。</li>
<li><strong>seek cancelled 动画要 reviveTimer</strong>cancel 会把 tween 从 siblings 链拔掉。库内部已处理timer.js:L86-99但如果你 hack 内部可能踩。</li>
<li><strong>Timer 构造器依赖 engine._lastTickTime</strong>:冷启动时 L167 特地 <code>engine.requestTick(now())</code> 热身。如果你在模块 load 阶段就 <code>new Animation()</code> 可能拿到冷数据。</li>
<li><strong>Draggable 在 transformed 祖先里</strong>:靠 <code>transforms.remove()</code> 临时清 transform 测量L593。CSS 变量 / 复杂 3D transform 时可能不完美。</li>
<li><strong>CSS <code>linear()</code> 浏览器兼容</strong>:不支持的浏览器上 WAAPI 自定义 easing 会 fallback 到 <code>'linear'</code>,动画曲线看起来像 bug。</li>
</ul>
</div>
<h3>可借鉴到其他项目的模式</h3>
<div class="card tip">
<ul>
<li><strong>linked-list children + addChild sortMethod</strong>40 行原语,可直接抄到任何需要排序链表的地方。</li>
<li><strong>预解析 + 运行期无正则</strong>任何需要字符串补间CSS gradient、SVG path、filter都适用。</li>
<li><strong>按需 rAF空队列自终止</strong>:大多数自研动画循环都漏掉这个。</li>
<li><strong>visibility auto-pause</strong>:标准好习惯。</li>
<li><strong>Proxy 做虚拟属性</strong>:给不能扩展的原生 API 开后门。</li>
<li><strong>WAAPI linear() 降级</strong>:需要 main-thread 动画 + off-main-thread 协同的库都能学。</li>
<li><strong>scope revertibles</strong>框架集成React/Vue/Angular的标准 pattern。</li>
<li><strong>阈值自动降级</strong>(≥ 1000 关 composition性能护栏无需用户决定。</li>
</ul>
</div>
</section>
<!-- Sizes -->
<section id="sizes">
<h2>附录:模块行数分布</h2>
<table>
<thead><tr><th>模块</th><th>行数</th><th>备注</th></tr></thead>
<tbody>
<tr><td>layout/layout.js</td><td>1607</td><td>FLIP 动画,库内最大单文件</td></tr>
<tr><td>draggable/draggable.js</td><td>1286</td><td>物理拖拽</td></tr>
<tr><td>events/scroll.js</td><td>986</td><td>滚动驱动</td></tr>
<tr><td>animation/animation.js</td><td>747</td><td>JSAnimation 构造器</td></tr>
<tr><td>types/index.js</td><td>652</td><td>JSDoc 类型定义</td></tr>
<tr><td>waapi/waapi.js</td><td>540</td><td>WAAPI 适配</td></tr>
<tr><td>timer/timer.js</td><td>535</td><td>Timer 基类</td></tr>
<tr><td>text/split.js</td><td>512</td><td>Text 拆分</td></tr>
<tr><td>core/render.js</td><td>398</td><td>渲染管线</td></tr>
<tr><td>animation/composition.js</td><td>390</td><td>composition 三态</td></tr>
<tr><td>timeline/timeline.js</td><td>362</td><td>Timeline + 位置语法</td></tr>
<tr><td>core/helpers.js</td><td>263</td><td>math/type/linked-list</td></tr>
<tr><td>scope/scope.js</td><td>259</td><td>React/Angular 桥</td></tr>
<tr><td>core/values.js</td><td>235</td><td>decompose + getTweenType</td></tr>
<tr><td>engine/engine.js</td><td>181</td><td>全局单例</td></tr>
<tr><td>utils/chainable.js</td><td>171</td><td>链式 API helpers</td></tr>
<tr><td>animatable/animatable.js</td><td>160</td><td>高频 setter</td></tr>
<tr><td>utils/stagger.js</td><td>142</td><td>Stagger 生成器</td></tr>
<tr><td>core/targets.js</td><td>138</td><td>targets 归一</td></tr>
<tr><td>core/consts.js</td><td>118</td><td>enums + regex + Symbols</td></tr>
<tr><td>core/styles.js</td><td>118</td><td>CSS helpers</td></tr>
<tr><td>svg/drawable.js</td><td>118</td><td>Proxy 画线</td></tr>
<tr><td>core/clock.js</td><td>107</td><td>时钟基类</td></tr>
<tr><td>core/colors.js</td><td>103</td><td>color 解析</td></tr>
<tr><td>svg/motionpath.js</td><td>88</td><td>沿 path 运动</td></tr>
<tr><td>utils/number.js</td><td>84</td><td>数字工具</td></tr>
<tr><td>waapi/composition.js</td><td>84</td><td>WAAPI 组合</td></tr>
<tr><td>animation/additive.js</td><td>81</td><td>blend 聚合</td></tr>
<tr><td>core/globals.js</td><td>74</td><td>defaults + scope.current</td></tr>
<tr><td>timeline/position.js</td><td>72</td><td>位置语法解析</td></tr>
<tr><td>svg/morphto.js</td><td>65</td><td>SVG path 变形</td></tr>
<tr><td>utils/random.js</td><td>63</td><td>shuffle 等</td></tr>
<tr><td>core/units.js</td><td>63</td><td>单位换算</td></tr>
<tr><td>utils/time.js</td><td>57</td><td>keepTime</td></tr>
<tr><td>core/transforms.js</td><td>45</td><td>parseInlineTransforms</td></tr>
<tr><td>svg/helpers.js</td><td>24</td><td>getPath</td></tr>
<tr><td>其他</td><td>27</td><td>各种小 index.js</td></tr>
<tr><td><strong>total</strong></td><td><strong>11,121</strong></td><td></td></tr>
</tbody>
</table>
</section>
<footer>
<p>解析自 <a href="https://github.com/juliangarnier/anime" target="_blank">juliangarnier/anime</a> @ v4.3.6 · 2026-04-23</p>
<p>所有 file:line 引用对应 <code>source/src/</code> 下的路径,可直接在本地项目中 grep 验证</p>
</footer>
</main>
</div>
<script>
// Scrollspy高亮当前可见区域对应的 nav 链接
const sections = Array.from(document.querySelectorAll('section[id]'));
const navLinks = Array.from(document.querySelectorAll('#toc a'));
const map = new Map(navLinks.map(a => [a.getAttribute('href').slice(1), a]));
let current = null;
const io = new IntersectionObserver(entries => {
entries.forEach(e => {
if (e.isIntersecting) {
const id = e.target.id;
if (current !== id) {
current = id;
navLinks.forEach(a => a.classList.remove('active'));
const link = map.get(id);
if (link) {
link.classList.add('active');
link.scrollIntoView({block: 'nearest', behavior: 'smooth'});
}
}
}
});
}, { rootMargin: '-20% 0px -70% 0px' });
sections.forEach(s => io.observe(s));
</script>
<!-- ========= anime.js 运行时 + Live Demos ========= -->
<script src="./anime.umd.min.js"></script>
<script>
(() => {
const A = window.anime;
if (!A) { console.error('anime.js 未加载'); return; }
const { animate, createTimeline, createDraggable, createAnimatable, stagger, svg, text, utils } = A;
// ---- 1. Hero 文字动画:每字逐个浮现 + 颜色流转 ----
const heroTitle = document.getElementById('heroTitle');
if (heroTitle) {
const badge = heroTitle.querySelector('.live-badge');
badge && badge.remove();
const raw = heroTitle.textContent;
heroTitle.textContent = '';
raw.split('').forEach((ch, i) => {
const span = document.createElement('span');
span.className = 'hero-letter';
span.textContent = ch === ' ' ? ' ' : ch;
heroTitle.appendChild(span);
});
badge && heroTitle.appendChild(badge);
animate('.hero-letter', {
translateY: [
{ from: 40, to: -6, ease: 'outQuad' },
{ to: 0, ease: 'outElastic(1, .5)' }
],
opacity: [{ from: 0, to: 1, ease: 'outQuad' }],
duration: 900,
delay: stagger(40),
});
}
// ---- 2. Stagger Grid 波纹 ----
const gridEl = document.getElementById('staggerGrid');
if (gridEl) {
const cols = 15, rows = 8;
for (let i = 0; i < cols * rows; i++) {
gridEl.appendChild(document.createElement('div'));
}
const dots = gridEl.children;
let waveLoop = null;
function stopWave() { if (waveLoop) { waveLoop.cancel(); waveLoop = null; } }
function staggerFrom(from) {
stopWave();
animate(dots, {
scale: [{ from: 1.6, to: 1 }],
opacity: [{ from: 1, to: 0.35 }],
duration: 900,
delay: stagger(45, { grid: [cols, rows], from }),
ease: 'outQuad',
});
}
function waveLoopStart() {
stopWave();
waveLoop = animate(dots, {
scale: [{ from: 1, to: 1.6 }, { to: 1 }],
opacity: [{ from: 0.3, to: 1 }, { to: 0.3 }],
duration: 1400,
delay: stagger(50, { grid: [cols, rows], from: 'center' }),
loop: true,
ease: 'inOutSine',
});
}
// initial
waveLoopStart();
document.querySelectorAll('[data-demo^="stagger-"]').forEach(btn => {
btn.addEventListener('click', () => {
const act = btn.dataset.demo;
if (act === 'stagger-center') staggerFrom('center');
if (act === 'stagger-first') staggerFrom('first');
if (act === 'stagger-random') staggerFrom('random');
if (act === 'stagger-wave') waveLoopStart();
});
});
}
// ---- 3. Animatable 鼠标跟随 ----
const followStage = document.getElementById('followStage');
const followDot = document.getElementById('followDot');
if (followStage && followDot) {
let dot = createAnimatable(followDot, { x: 200, y: 100, ease: 'outElastic(1, .5)', duration: 600 });
let currentEase = 'outElastic(1, .5)';
followStage.addEventListener('mousemove', e => {
const r = followStage.getBoundingClientRect();
dot.x(e.clientX - r.left).y(e.clientY - r.top);
});
document.querySelectorAll('[data-demo^="follow-"]').forEach(btn => {
btn.addEventListener('click', () => {
const ease = btn.dataset.demo === 'follow-linear' ? 'linear'
: btn.dataset.demo === 'follow-elastic' ? 'outElastic(1, .5)'
: 'outQuad';
dot.revert();
dot = createAnimatable(followDot, { x: 200, y: 100, ease, duration: ease === 'linear' ? 200 : 600 });
currentEase = ease;
});
});
}
// ---- 4. Additive buttonblend composition----
const addBtn = document.getElementById('additiveBtn');
if (addBtn) {
// idle 持续抖动blend
animate(addBtn, {
scale: [{ from: 0.96, to: 1.04 }, { to: 0.96 }],
duration: 1400, loop: true, ease: 'inOutSine', composition: 'blend',
});
animate(addBtn, {
rotate: [{ from: -2, to: 2 }, { to: -2 }],
duration: 1800, loop: true, ease: 'inOutSine', composition: 'blend',
});
addBtn.addEventListener('click', () => {
// shake 叠加blend不会干扰 idle
animate(addBtn, {
translateX: [
{ to: 10 }, { to: -10 }, { to: 6 }, { to: -6 }, { to: 0 }
],
duration: 400, ease: 'outQuad', composition: 'blend',
});
animate(addBtn, {
scale: [{ to: 1.15 }, { to: 1 }],
duration: 500, ease: 'outElastic(1, .4)', composition: 'blend',
});
});
document.querySelectorAll('[data-demo^="additive-"]').forEach(btn => {
btn.addEventListener('click', () => {
if (btn.dataset.demo === 'additive-shake') addBtn.click();
});
});
}
// ---- 5. SVG 三件套 ----
const morphPath = document.getElementById('morphPath');
const motionBall = document.getElementById('motionBall');
const motionTrack = document.getElementById('motionTrack');
const drawPath = document.getElementById('drawablePath');
const shapes = {
star: 'M 50 10 L 70 40 L 90 50 L 70 60 L 50 90 L 30 60 L 10 50 L 30 40 Z',
heart: 'M 50 80 C 20 60 10 30 30 20 C 40 15 48 25 50 30 C 52 25 60 15 70 20 C 90 30 80 60 50 80 Z',
circle: 'M 50 10 C 78 10 90 32 90 50 C 90 68 78 90 50 90 C 22 90 10 68 10 50 C 10 32 22 10 50 10 Z',
};
let drawEl = null;
function runSvgDemo() {
if (morphPath) {
animate(morphPath, {
d: [
{ to: shapes.heart, duration: 1200 },
{ to: shapes.circle, duration: 1200 },
{ to: shapes.star, duration: 1200 },
],
ease: 'inOutQuad',
loop: true,
});
}
if (motionBall && motionTrack && svg && svg.createMotionPath) {
const mp = svg.createMotionPath(motionTrack);
animate(motionBall, {
...mp,
duration: 2500, ease: 'inOutSine', loop: true, alternate: true,
});
}
if (drawPath && svg && svg.createDrawable) {
if (!drawEl) drawEl = svg.createDrawable(drawPath)[0];
animate(drawEl, {
draw: ['0 0', '0 1', '1 1'],
duration: 2600, ease: 'inOutQuad', loop: true,
});
}
}
// 默认自动跑(首屏外也可以,因为不影响性能)
runSvgDemo();
document.querySelectorAll('[data-demo="svg-run"]').forEach(b => b.addEventListener('click', runSvgDemo));
// ---- 6. Text split 动画 ----
const textTarget = document.getElementById('textTarget');
let splitInstance = null;
function ensureSplit() {
if (splitInstance) splitInstance.revert && splitInstance.revert();
if (text && text.splitText) {
splitInstance = text.splitText(textTarget, { chars: { class: 'letter' } });
}
}
function textDemo(kind) {
ensureSplit();
const letters = textTarget.querySelectorAll('.letter');
if (!letters.length) return;
if (kind === 'text-up') {
animate(letters, {
translateY: [{ from: '120%', to: 0 }],
opacity: [{ from: 0, to: 1 }],
duration: 900, ease: 'outExpo', delay: stagger(35),
});
} else if (kind === 'text-wave') {
animate(letters, {
translateY: [{ from: -12, to: 12 }, { to: 0 }],
rotate: [{ from: -8, to: 8 }, { to: 0 }],
duration: 1600, ease: 'inOutSine', loop: true,
delay: stagger(60, { from: 'center' }),
});
} else if (kind === 'text-glitch') {
animate(letters, {
translateX: () => utils.random(-8, 8),
translateY: () => utils.random(-8, 8),
rotate: () => utils.random(-15, 15),
opacity: [{ from: 0.3, to: 1 }, { to: 0.3 }, { to: 1 }],
color: '#ffffff',
duration: 1200, ease: 'outQuad', loop: true,
delay: stagger(20, { from: 'random' }),
});
}
}
if (textTarget) {
textDemo('text-up');
document.querySelectorAll('[data-demo^="text-"]').forEach(btn => {
btn.addEventListener('click', () => textDemo(btn.dataset.demo));
});
}
// ---- 7. Timeline 位置语法 ----
let tlInstance = null;
function buildTl() {
if (tlInstance) tlInstance.revert();
tlInstance = createTimeline({ defaults: { duration: 600, ease: 'inOutQuad' }, autoplay: false });
tlInstance
.add('#tlBoxA', { translateX: [0, 120], translateY: [0, -20] })
.add('#tlBoxB', { translateX: [0, 0], translateY: [0, -40], rotate: [0, 360] }, '<') // 与上一个同时开始
.add('#tlBoxC', { translateX: [0, -120], scale: [1, 1.2] }, '+=100') // 延后 100ms
.label('back')
.add('#tlBoxA', { translateX: 0, translateY: 0 }, 'back')
.add('#tlBoxB', { translateX: 0, translateY: 0, rotate: 0 }, 'back')
.add('#tlBoxC', { translateX: 0, scale: 1 }, 'back');
return tlInstance;
}
if (document.getElementById('tlBoxA')) {
buildTl();
document.querySelector('[data-demo="tl-play"]').addEventListener('click', () => buildTl().play());
document.querySelector('[data-demo="tl-restart"]').addEventListener('click', () => buildTl().restart());
document.querySelector('[data-demo="tl-reverse"]').addEventListener('click', () => {
if (!tlInstance) buildTl();
tlInstance.reverse();
});
}
// ---- 8. Draggable ----
const dragBox = document.getElementById('dragBox');
const dragStage = document.getElementById('dragStage');
let draggable = null;
function mountDraggable(params) {
if (draggable) draggable.revert();
draggable = createDraggable(dragBox, {
container: dragStage,
containerPadding: 6,
releaseEase: 'outElastic(1, .5)',
...params,
});
}
if (dragBox) {
mountDraggable({});
document.querySelector('[data-demo="drag-reset"]').addEventListener('click', () => {
draggable && draggable.animate.x(0).y(0);
});
document.querySelector('[data-demo="drag-release-spring"]').addEventListener('click', () => {
mountDraggable({ releaseEase: A.createSpring ? A.createSpring({ stiffness: 80, damping: 10 }) : 'outElastic(1, .3)' });
});
document.querySelector('[data-demo="drag-snap"]').addEventListener('click', () => {
mountDraggable({ snap: 50 });
});
}
console.log('[demos] 已激活', Object.keys(A).filter(k => !k.startsWith('_')).length, '个 anime.js API');
})();
</script>
</body>
</html>