- hero: 逐字浮现 + elastic - stagger grid 15x8 120 点: center/first/random/wave 循环 - animatable 鼠标跟随: 3 种 easing 对比 - additive blend: idle wobble + click shake 不打架 - SVG 三件套: morphTo(星↔心↔圆) + motionPath + drawable - text.split + stagger: 上移/波浪/故障态 - timeline 位置语法: < += label 三 box 演示 - draggable: 容器边界 + spring + snap Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1690 lines
94 KiB
HTML
1690 lines
94 KiB
HTML
<!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 */
|
||
h1 .hero-letter{display:inline-block;will-change:transform,opacity,color}
|
||
|
||
/* 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 release(2026-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<SVGGeometryElement>[]</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> (() => 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> (() => 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> = () => {
|
||
<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 < 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 && !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 < 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>, () => {
|
||
<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>timeUnit:ms ↔ 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>requestTick:fps 限速的核心</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 < 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 < 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 && 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 < 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 && 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] && <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 => {
|
||
<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) => {
|
||
<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 = () => {
|
||
<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 => {
|
||
<span class="k">this</span>._resolve = () => <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 2:composition 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 && !nextSibling._isOverridden && nextSibling._absoluteStartTime <= absoluteStartTime) {
|
||
prevSibling = nextSibling;
|
||
nextSibling = nextSibling._nextRep;
|
||
<span class="c">// 后面的 sibling 直接 override</span>
|
||
<span class="k">if</span> (nextSibling && nextSibling._absoluteStartTime >= 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<Target, {prop: siblings}>(<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 4:Tween 创建(字面对象工厂)</h4>
|
||
<p><a class="ref">animation.js:L524-562</a>。每个 Tween 是 plain object,29 个字段,非 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) && targetsLength >= K
|
||
? compositionTypes.none : ...;</code></pre>
|
||
<p><a class="ref">animation.js:L263</a>:targets 数量 ≥ 1000 时默认关掉 composition,避免大 stagger 时的 sibling lookup 开销。<strong>用户无感的性能兜底</strong>。</p>
|
||
|
||
<h4>Stage 6:iterationDelay 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 > 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 < 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 = () => {
|
||
lookups.<span class="fn">forEach</span>(propertyAnimation => {
|
||
<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 && 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>'<'</td><td>上一个 child 的<strong>起点</strong>(_offset + _delay)</td></tr>
|
||
<tr><td>'<<'</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>'<*=2'</td><td>上一个 child 起点 × 2</td></tr>
|
||
</tbody>
|
||
</table>
|
||
<p>解析逻辑在 <a class="ref">position.js:L50-73</a> 的 <code>parseTimelinePosition</code>,优先级:sibling 定位 > label > 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 => {
|
||
<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>sync:WAAPI / 原生 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 用 < += 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?) => 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 => 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>Scope:React / 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> —— 返回 tickable,refresh 时<strong>保留 currentTime 不重置</strong></li>
|
||
<li><code>.refresh()</code> —— revert + re-run constructors(media query 变化时触发)</li>
|
||
<li><code>.revert()</code> —— 倒序 revert 所有 revertibles</li>
|
||
</ul>
|
||
|
||
<h3>React 集成</h3>
|
||
<pre><code><span class="k">useEffect</span>(() => {
|
||
<span class="k">const</span> scope = <span class="fn">createScope</span>({ root: ref }).<span class="fn">add</span>(() => {
|
||
<span class="fn">animate</span>(<span class="s">'.box'</span>, { translateX: <span class="n">100</span> });
|
||
});
|
||
<span class="k">return</span> () => 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 => {
|
||
<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 < 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) => {
|
||
<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 Split:Intl.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>) => {
|
||
<span class="k">const</span> points = [];
|
||
<span class="k">for</span> (<span class="k">let</span> i = <span class="n">0</span>; i <= 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>Draggable:1286 行的物理拖拽引擎</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>这个技巧在 Layout(L386-L392)和 Scroll(L774-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 FLIP:1607 行(库内最大单文件)</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 => <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>Scroll:986 行滚动驱动引擎</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 && $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 => 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 && 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 accumulator,engine 每帧 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 button(blend 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.split) {
|
||
splitInstance = text.split(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>
|