diff --git a/.memory/source-analysis.md b/.memory/source-analysis.md index a8233c9..5008b14 100644 --- a/.memory/source-analysis.md +++ b/.memory/source-analysis.md @@ -1,16 +1,671 @@ -# anime.js 源码解析 源码解析 +# anime.js 源码深度解析 -> 创建日期:2026-04-23 -> 上游版本:待填写 +> **上游**:https://github.com/juliangarnier/anime.git +> **版本**:v4.3.6(2026-04-23 clone) +> **作者**:Julian Garnier +> **License**:MIT +> **规模**:11,121 行(不含 dist/test),17 个模块 +> **定位**:轻量级多用途 JavaScript 动画引擎(CSS / SVG / DOM attrs / JS objects),v4 重写了整个内核 -## 概览 +--- -待补充 +## 0. TL;DR 一分钟看懂 -## 核心模块 +anime.js v4 内核是一棵**四层继承树**: -待补充 +``` +Clock (core/clock.js) ← 107 行,只管 time/fps/speed/deltaTime 和双向链表 children + ↓ +Timer (timer/timer.js) ← 535 行,加生命周期(play/pause/seek/reverse/complete/then) + ↓ +├── JSAnimation (animation/animation.js) ← 747 行,管 Tween 链表 + 预解析值 +└── Timeline (timeline/timeline.js) ← 362 行,管 Timer/Animation children + 位置语法 -## 关键流程 +Engine (engine/engine.js) ← 181 行,也 extends Clock 的单例,唯一持有 rAF 的节点 +``` -待补充 +渲染全部走 `core/render.js` 的两个函数:`render()`(单 Tickable)+ `tick()`(递归 timeline)。所有 Tween 值在创建时就被**预解析**为 number/unit/color-rgba/complex 四类,运行期只做 `lerp + clamp + round + 字符串拼接`,没有正则。 + +单一 `requestAnimationFrame` 驱动整个应用所有动画(engine 单例),通过 `document.visibilitychange` 自动暂停。 + +--- + +## 1. 对外 API 架构 + +`src/index.js` 只有 18 行,全是 re-export。每个能力是独立子目录(`index.js` 多为空文件,真实代码在同名 `.js`)。外部 API 按"**工厂函数**"风格暴露: + +| 工厂 | 类 | 文件 | +|---|---|---| +| `animate(targets, params)` | `JSAnimation` | animation.js:L747 | +| `createTimer(params)` | `Timer` | timer.js:L536 | +| `createTimeline(params)` | `Timeline` | timeline.js:L362 | +| `createAnimatable(targets, params)` | `Animatable` | animatable.js:L160 | +| `createDraggable($el, params)` | `Draggable` | draggable.js | +| `createScope(params)` | `Scope` | scope.js:L259 | +| `createLayout(root, params)` | `AutoLayout` | layout.js:L1607 | +| `createMotionPath(path, offset)` | 对象(3 个 FunctionValue) | motionpath.js:L80 | +| `createDrawable(sel)` | `Proxy[]` | drawable.js:L111 | +| `onScroll(params)` | `ScrollObserver` | events/scroll.js | +| `stagger(val, params)` | `StaggerFunction` | utils/stagger.js:L82 | +| `waapi.animate(targets, params)` | `WAAPIAnimation` | waapi/waapi.js | +| `svg.morphTo(path2, precision)` | `FunctionValue` | morphto.js:L25 | +| `text.split($el, params)` | 三套 proxy | text/split.js | + +全部支持链式:`.play() / .pause() / .reverse() / .seek() / .stretch() / .revert() / .then()`。 + +--- + +## 2. 核心循环:Engine + rAF + +**engine.js:L47-165** 定义 `class Engine extends Clock`,导出一个**模块级单例** `engine`(L155 IIFE,包 `/*#__PURE__*/` 让 bundler tree-shake)。关键: + +- **Tick 方法选择**(L44-45):浏览器用 `requestAnimationFrame`,非浏览器 fallback `setImmediate`,以支持 Node.js 测试。 +- **Tick 入口**(L168-175):闭包函数 `tickEngine` 判断 `engine._head`(链表非空)才续命 rAF;空了就把 `reqId = 0` 让循环自然终止。这是**按需暂停**策略 —— 没有正在跑的动画就不消耗帧。 +- **每帧逻辑**(L62-91 `update`): + 1. `this.requestTick(time)` 走 Clock 的 fps 限速(L81-94)。 + 2. 遍历双向链表 `_head → _next`,每个 active tickable 调 `tick(activeTickable, localTime, ...)`。 + 3. `localTime = (globalTime - _startTime) * _speed * engineSpeed`(L74) —— 每个 tickable 有独立 speed。 + 4. paused 的 child 直接 `removeChild(this, activeTickable)` 退出循环(L80-85)。 + 5. 最后 `additive.update()` 跑一次叠加动画的 recompose(L89)。 +- **可见性联动**(L159-162):`doc.hidden` 自动 pause/resume,可通过 `pauseOnDocumentHidden` 关掉。 +- **timeUnit 切换**(L126-142):支持 `'ms' | 's'`,切换时对所有 existing durations 按 `globals.timeScale=0.001` 缩放。这是 v4 新增特性,作者为此付出了整个代码库 `round(_, 12)` 抗浮点误差的代价。 + +--- + +## 3. Clock 基类:时钟抽象 + +**clock.js:L23-107**,仅 107 行。是所有时间驱动对象的基类。 + +- **状态**:`_currentTime / _lastTickTime / _startTime / _lastTime / _scheduledTime`(L28-38)。区分这么多时间戳是为了在 fps 限速、speed 变更、seek 跳转时都能正确推进。 +- **`requestTick(time)` L81-94**:fps 限速的核心。如果 `time < _scheduledTime` 直接返回 `tickModes.NONE`(跳帧),否则把 `_scheduledTime` 前推至少一个 `frameDuration`。算法可以跳多帧(L92 `frameDelta < frameDuration ? frameDuration : frameDelta`),避免 tab 切回后疯狂补帧。 +- **链表**:`_head / _tail` 就是直接的 Tickable 链表头尾(L47-50)。helpers.js:L255-263 的 `addChild(parent, child, sortMethod?)` 支持**有序插入**(`sortMethod` 用于让 Tween 在 siblings 链里按 `_absoluteStartTime` 排序)。 + +Clock 本身不感知动画,纯粹就是"会走的钟"。Engine 继承它就自动有了 child 管理;Timer 继承它再加生命周期;Animation/Timeline 再继承 Timer 加业务语义。**一条继承线四级语义分层** —— 这是 v4 架构最干净的地方。 + +--- + +## 4. Tween 值系统:预解析四象限 + +`core/values.js` 是整个库的"解析中心"。 + +### 4.1 tweenTypes 五分法(consts.js:L17-23) + +根据**目标类型 × 属性类型**分五档:`OBJECT`(普通 JS 对象)/ `ATTRIBUTE`(SVG/非 CSS 的 DOM 属性)/ `CSS`(样式属性)/ `TRANSFORM`(transform 六件套)/ `CSS_VAR`(CSS 变量)。 + +决策在 `getTweenType` values.js:L94-107: +```js +!target[isDomSymbol] ? OBJECT : // 非 DOM +target[isSvgSymbol] && isValidSVGAttr ? ATTRIBUTE : // SVG attr +validTransforms.includes(prop) || shortTransforms.get(prop) ? TRANSFORM : // x/y/z/rotate/scale/... +prop.startsWith('--') ? CSS_VAR : +prop in style ? CSS : +prop in target ? OBJECT : +ATTRIBUTE +``` + +注意 **TRANSFORM 独立分支**(不走 CSS)—— 这样 `translateX / scaleY / rotate` 可以独立补间,再在 render.js:L268-274 合并成一条 `transform: translateX(...) scaleY(...) rotate(...)` 字符串。缓存在 DOM 节点的 Symbol 属性(`transformsSymbol`, consts.js:L52)上,避免每帧重读。 + +### 4.2 valueTypes 四分法(consts.js:L26-31) + +- `NUMBER` —— 纯数字 +- `UNIT` —— 数字+单位,如 `100px` `30%` `2turn`(unitsExecRgx 解析,consts.js:L114) +- `COLOR` —— hex/rgb/hsl(colors.js 转 RGBA 数组 `[r,g,b,a]`) +- `COMPLEX` —— 任意混合字符串如 `rgb(0,0,0) 10px 10px 20px / inset calc(100% - 10px)` + +`decomposeRawValue` values.js:L170-218 就是把原始值拆成: +``` +{ t: valueType, n: 主数字, u: 单位, o: 运算符(+=/-=/*=), d: 数字数组, s: 字符串片段数组 } +``` + +**精妙之处**:COMPLEX 类型会把字符串按数字切分成 `s[]`(字符串碎片)+ `d[]`(数字数组),运行期只需 `lerp(d_from[i], d_to[i], t)` 然后和 `s[]` 交错拼接(render.js:L214-224)—— **正则只在创建期执行一次,帧率临界路径是纯算术**。 + +### 4.3 Operators(+=/-=/*=) + +values.js:L146-150 的 `getRelativeValue` 处理 `+=10` `*=2` 这类相对值,在 composition 阶段(animation.js:L460-473)基于 prevSibling 的 `_toNumber` 或原值计算。 + +### 4.4 Tween 结构(animation.js:L524-562) + +每个 Tween 是个 plain 对象(非 class,省 prototype 开销),29 个字段。关键: +- `_ease`:已经 parseEase 过的函数,运行期直接 `_ease(progress)` +- `_fromNumbers / _toNumbers / _strings`:预解析数组 +- `_startTime / _changeDuration / _updateDuration / _absoluteStartTime`:时间四元组(支持 composition 剪切) +- `_prev / _next`:在 Animation 内部链表 +- `_prevRep / _nextRep`:在 target-property siblings 链表里(用于 replace composition) +- `_prevAdd / _nextAdd`:additive 链表(用于 blend composition) + +一个 tween 同时存在于**三条链表**里。 + +--- + +## 5. 渲染管线 render.js + +398 行单文件。`export const render = (tickable, time, muteCallbacks, internalRender, tickMode)` 是内核中的内核。 + +### 5.1 时间推进(L66-104) + +- L88 `~~(tickableCurrentTime / (iterationDuration + _loopDelay))` —— **位运算 NOT 取整**比 `Math.floor` 略快(作者注释 L87)。 +- L97 `isReversed = _reversed ^ (_alternate && isOdd)` —— XOR 一行处理 `reversed`、`alternate`、`isOdd` 三状态组合。 +- L99 `iterationTime = isReversed ? iterationDuration - iterationElapsedTime : iterationElapsedTime` +- L100 playbackEase(Timeline 级别的总体 easing)作用于**整个时间轴**:`iterationTime = iterationDuration * _ease(iterationTime / iterationDuration)` +- L101 反向方向感知:`parent.backwards` 和 `time - prevTime` 两条路径推断方向 + +### 5.2 Tween 循环(L156-278) + +- **跳帧优化**(L166-174):只有当前时间还没走完 + 当前 tween 没被 overridden + 前/后 siblings 没覆盖 + `forcedRender` 时才写入。 +- **值类型分支**(L193-224): + - NUMBER:`lerp + modifier + round` + - UNIT:NUMBER 的基础上加 `${n}${_unit}` + - COLOR:对 RGBA 四路分别 lerp 再 clamp(0,255) + - COMPLEX:遍历 `_toNumbers` lerp + 交错拼接 `_strings` +- **Transform 批写**(L242-275): + - 每个 transform tween 写入 `target[transformsSymbol][property]`(缓存属性) + - Animation 构造时标记链中**最后一个 transform tween** 的 `_renderTransforms = 1`(animation.js:L601-616) + - 只有 `_renderTransforms=1` 的 tween 被处理时才拼完整 `transform:` 字符串(L268-274)—— **一帧一写**,而不是每个 tween 写一次 +- **DOM 写入分派**(L236-254):OBJECT 走属性赋值;ATTRIBUTE 走 `setAttribute`;CSS 走 `style[prop]`;CSS_VAR 走 `style.setProperty('--x', ...)`;TRANSFORM 先存 Symbol cache + +### 5.3 tick 递归(L338-398) + +- L341 如果是 Timeline,按 children 链表递归 +- L353-372 跨 iteration 时的 skipped callback 补触发(forward 时强制 onComplete,backward 时补 onComplete) +- L376 `childTime = (tlChildrenTime - child._offset) * child._speed` —— child 有自己的 offset 和 speed +- L386-395 所有 children `completed` 才触发 Timeline `onComplete` + +--- + +## 6. Timer 基类:生命周期 + +**timer.js:L106-530**。 + +- **scope 注册**(L137):构造时自动注册到 `scope.current`(如有),这让 Scope.revert() 能级联清理所有 Timer。 +- **offset 计算**(L162-170):Timeline child 用 parent-relative 位置;顶层 Timer 用 `(engine._lastTickTime - engine._startTime) * globals.timeScale` 作为初始偏移,保证"创建即生效"的动画时间线连续。 +- **play/pause/resume/reverse/alternate**(L371-450):语义完备。`play()` 必要时 alternate 一次再 resume;`alternate()` 通过 XOR 切 `_reversed` 然后 seek 到对称时刻。 +- **seek**(L410-420):关键是 `reviveTimer(this)` 先把取消的 tween 重新 compose 回 siblings 链(L86-99)。否则 seek 一个已 cancel 的动画会渲染到空。 +- **stretch**(L470-482):等比缩放 duration / offset / delay —— 用于 timeline 动态"压缩/拉伸"已添加的 children。 +- **Promise 集成**(L512-528):`timer.then(cb)` 返回 Promise,resolve 时机是 onComplete。L516-518 的 `this.then = null` hack 防 `async/await` 返回这个 thenable 导致的无限递归(引用 GitHub issue #26)。 + +--- + +## 7. JSAnimation 构造器:747 行大工程 + +**animation.js:L210-740**,整个构造函数就是 442 行的流水线: + +### 7.1 输入归一 + +1. `registerTargets(targets)` 把 `'.btn'` / Element / NodeList / ReactRef 等归一成数组(targets.js)。 +2. 如果有 `keyframes`,generateKeyframes(L127-208)把**数组形式**或**百分比对象形式**的 keyframes 展开成 per-property 的数组。 +3. `getTweenType` 分类每个属性。 +4. 每个 property 的 value 归一成 `keyframes[]` —— `{to: v}` / `[from, to]` / `[v1, v2, v3]` / `{to, from, duration, ease}[]` 等各种语法最终都化简到同一结构(L305-332)。 + +### 7.2 composition 处理(L393-409) + +若 `composition === replace`: +- 先 `siblings = getTweenSiblings(target, propName)` —— WeakMap(composition.js:L45-69) +- 遍历 siblings 链找插入点,**后续 siblings 直接 override**(标记 `_isOverridden=1` + `_changeDuration=minValue`)。这让**重复对同一属性的动画自动接管前一个**(类 GSAP 的 overwrite)。 + +### 7.3 值解析(L411-512) + +- `decomposeRawValue(fromValue, fromTargetObject)` / `decomposeTweenValue(prevTween, ...)`(先用 sibling 作 from 省一次 getComputedStyle) +- **类型不匹配自动对齐**(L475-494): + - complex vs number → 把 number 拍成 complex(所有数字位都填这个 number) + - unit 不同 → `convertValueUnit` 调一次 getComputedStyle 换算(units.js) + - color vs non-color → 假色填 `[0,0,0,1]` 占位 +- **长度不等的 complex**(L506-511):把短的补齐到长的长度,空位填 0。 + +### 7.4 Tween 创建(L524-576) + +字面对象工厂,非 class,省 prototype 开销;创建后 `addChild(this, tween)` 挂到 Animation 链表,若需要 compose 则 `composeTween(tween, siblings)` 挂到 siblings 链。 + +### 7.5 大量 targets 的优化(L263) + +```js +const tComposition = isUnd(composition) && targetsLength >= K + ? compositionTypes.none : ... +``` +当 targets >= 1000,默认关掉 composition(不做 overwrite 处理)。这是"**stagger 3000 个点**"这种场景的救命配置。 + +### 7.6 修正 iterationDelay(L624-635) + +所有 tween 都扫一遍 `startTime`,把最小的 delay 从整个 Animation 提取出来当 `this._delay`,其他 tween 减掉这个值。这样 Animation.duration 等于"真实的有效动画时长",不把前导 delay 算进去。**这个 trim pass 非常重要**,否则 `iterationProgress` 会不准。 + +--- + +## 8. Composition 三态 + Additive + +**composition.js:L95-262**。三种模式(consts.js:L41-45): + +- **replace (0)**:默认。新 tween 进 siblings 链,override 掉后续 siblings + 把前面 siblings 的 `_changeDuration` 剪到 overlap 点(L142-158)—— "前面的动画被截短,后面的被覆盖"。 +- **none (1)**:不 compose,纯粹写入。超多 targets 或性能关键场景用。 +- **blend (2)**:叠加式。多个动画同时影响一个属性(Animatable 用这个实现"推送力合成")。 + +### blend 的精妙(composition.js:L216-258) + +叠加模式下: +1. 给 target-prop 建一个额外的 `_add` WeakMap siblings 链。 +2. 第一个 tween 时创建 lookupTween(相当于"累加器")。 +3. 每个新 tween 的 `_fromNumber = lookup._fromNumber - toNumber`,`_toNumber = 0` —— 本 tween 补间的其实是**相对于上次状态的 delta**。 +4. engine loop 每帧结束调 `additive.update()`(engine.js:L89)—— 遍历所有 additive siblings,把 `_number` 累加到 lookup tween 的 `_toNumber`,然后 force-render lookup tween 一次(additive.js:L53-78)。 + +这就是**多动画叠加不互相覆盖**的实现机理。典型应用:鼠标悬停 scale up + 持续 idle wobble + 点击 shake,三者同时生效。 + +--- + +## 9. Timeline:位置语法 + stagger add + +**timeline.js:L135-356**。继承 Timer,主要加三件: +- `labels: Record` —— 具名锚点 +- `add(targetsOrTimerParams, paramsOrPosition, positionOrStagger)` —— 多态 add +- `defaults: DefaultsParams` —— 子项默认值 + +### 位置语法(timeline/position.js:L50-73) + +`parseTimelinePosition` 支持: +- 数字 → 绝对位置 +- `undefined` → tlDuration(追加到末尾) +- `'label'` → `labels[label]` +- `'<'` → 上一个 child 的**起点**(`_offset + _delay`) +- `'<<'` → 上一个 child 的**终点**(`_offset + _delay + duration`) +- `'+=500'` `'label-=200'` `'<*=2'` → 运算符 + 基准 + - 基准选择:有 sibling 就 sibling 起点;有 label 就 label;否则 tlDuration + +### add 的 stagger 分支(timeline.js:L186-215) + +当 `a3` 是函数(stagger 返回值)时,遍历 targets,每个 target 都: +1. 新建 `staggeredChildParams`(避免 mutate 原 params) +2. **重置** `this.duration = tlDuration; this.iterationDuration = tlIterationDuration`(关键,否则每轮 stagger 起点漂移) +3. 用 stagger 函数计算这个 target 的位置,调 `addTlChild` +4. 每个 target 都创建一个独立 JSAnimation + +这样 `tl.add('.item', { opacity: [0,1] }, stagger(100, { from: 'center' }))` 就为 N 个元素创建 N 个 Animation,每个有独立的 start time。 + +### sync(L256-265) + +能同步原生 `globalThis.Animation`(WAAPI)和自己的 Tickable —— 创建一个 tweening `currentTime` from 0 to duration 的 Animation 驱动外部对象。非常机智。 + +--- + +## 10. Stagger:复合生成器 + +**utils/stagger.js:L82-142**。返回一个 `(target, index, total, timeline?) => number|string` 函数。支持: + +- **起始位置**:`'first' | 'last' | 'center' | 'random' | index number`(L94-97) +- **栅格模式**:`grid: [cols, rows]` + `axis: 'x' | 'y'` —— 欧几里得距离或单轴距离(L117-126) +- **range stagger**:`stagger([0, 100])` 值域等分而非固定间距(L100-103) +- **easing**:把索引归一化 [0,1] 后过 easing 再映射回去(L130) +- **单位继承**:自动从 val 提取单位 `'2s'` → `'2s'`(L139) +- **custom use**:`params.use: 'data-delay'` 从元素属性读 index 值(L108) +- **从属 timeline**:传入 `timeline` 时会把 stagger 的 base offset 结算为 timeline 位置语法(L135,`parseTimelinePosition`) —— 支持 `tl.add('.item', ..., stagger(100, { start: 'label1' }))` + +values 数组是**懒初始化 + 缓存**(L112),整个 stagger 生成器只算一次距离 map。 + +--- + +## 11. Animatable:超高频 setter + +**animatable.js:L41-153**。场景:鼠标跟随、陀螺仪、3D 控制这类"**逐帧被外部驱动**"的动画。 + +用法: +```js +const a = createAnimatable(el, { x: 200, y: 200, ease: 'outQuad' }) +document.addEventListener('mousemove', e => a.x(e.clientX).y(e.clientY)) +``` + +实现: +- 构造时为**每个 property 创建一个独立的 autoplay:false JSAnimation**(L107) +- 每个 property 的 setter 函数(L110-139): + - 无参时 return 当前值(`_number / _numbers`) + - 有参时更新所有 children tween 的 `_fromNumber = 当前值`,`_toNumber = 新目标`(L129-130) + - `animation.reset(true).resume()` —— 从头开始补间(L136) +- 有个隐藏的 `callbacks` JSAnimation(`{v: 0, v: 1}` dummy,L90)用来统一触发 begin/pause/complete —— 因为真实动画有 N 个独立 Animation,哪一个跑完不代表全部完成。`pauseHandler` L52-65 扫所有 children paused 状态,全 paused 才 complete。 + +这个 API 配合 blend composition,就能实现"多输入源推同一个对象,平滑叠加"。 + +--- + +## 12. Scope:React/Angular 生命周期桥 + +**scope/scope.js:L41-253**。核心概念是 `scope.execute(cb)` 压栈式**上下文**: + +```js +scope.current = this +scope.root = this.root // DOM 查询 fallback root +globals.defaults = this.defaults // 这个 scope 内创建的动画继承此默认 +cb(this) // 回调里创建的 animation 全注册到 this.revertibles +// 还原 +``` + +Timer constructor 里(timer.js:L137)自动 `scope.current.register(this)`,所以在 scope.add 的 cb 里创建的动画会自动被 scope 管理。 + +### 关键 API +- `.add(fn)` —— 每次 refresh 时重跑 +- `.add(name, fn)` —— 注册方法(调用时会用 scope context) +- `.addOnce(fn)` —— 只跑一次(cache in `constructorsOnce`) +- `.keepTime(fn)` —— 返回一个 tickable,refresh 时**保留 currentTime**不重置(L201-213,配合 utils/time.js:keepTime) +- `.refresh()` —— revert + re-run constructors(media query 变化时触发) +- `.revert()` —— 倒序 revert 所有 revertibles(L226-252) + +React 集成:`root: useRef(ref)` 会被识别(L49,`ReactRef.current`)—— 在 `useEffect(() => { const scope = createScope({ root: ref }).add(...); return () => scope.revert() }, [])` 就完成了。 + +### mediaQueries(L85-91 / L218-223) +`{ mediaQueries: { mobile: '(max-width: 800px)' } }` + `change` event → refresh → 不同 media 下重建动画。不用手写 resize listener + 重启动画。 + +--- + +## 13. SVG 三件套 + +### 13.1 morphTo(svg/morphto.js:L25-65) + +变形不同数量顶点的 path。算法: +1. 两个 path 的 `getTotalLength()` +2. 目标采样点数 `maxPoints = Math.ceil(max(L1, L2) * precision)`(默认 precision=0.33) +3. 按 t ∈ [0,1] 等间距调 `getPointAtLength(L*t)` 得到新顶点 +4. 返回 `[v1Str, v2Str]` 给 anime 做字符串 tween +5. 把 v2 缓存到 `$path[morphPointsSymbol]`(consts.js:L53,Symbol 属性)供下次使用 + +**precision 权衡**:小 = 更平滑但更贵;0 = 用原始 d 属性(命中相同 command 结构时最省)。 + +### 13.2 motionPath(svg/motionpath.js:L80-88) + +返回三个 FunctionValue:`{ translateX, translateY, rotate }`。每个都是**闭包**绑定 path,运行期 `modifier: progress => getPathPoint + ctm 变换`。 + +- L61-64:rotate 算法是采样前后两点 `atan2(dy, dx) * 180/PI`(中心差分) +- L66-69:坐标变换处理 SVG vs HTML —— 如果目标在 SVG 内就用 path 局部坐标;否则乘 CTM 投到 viewport 坐标(`p.x * ctm.a + p.y * ctm.c + ctm.e`) + +### 13.3 drawable(svg/drawable.js) + +画线效果(模拟 Vivus.js)。精妙之处: +- 强行设置 `pathLength=1000`(L47 `K=1e3`,L96-97) —— 这样 `stroke-dasharray` / `stroke-dashoffset` 不管 path 真实长度多少,都在 [0, 1000] 规范化空间里操作。 +- 用 **ES6 Proxy 拦截** `setAttribute('draw', '0 0.5')`(L58-85)—— 不是 polyfill 新属性,而是运行时代理:用户写 `drawable.setAttribute('draw', '0 0.5')`,实际写入的是 `stroke-dasharray` + `stroke-dashoffset`。 +- `vector-effect: non-scaling-stroke` 的补偿(L31-35,getScaleFactor):从 CTM 解出缩放系数,补偿回去。 + +Proxy 让 anime 直接动画 `draw: [0, 1]` 像 CSS 属性一样用(`proxyTargetSymbol` 在 consts.js:L54,让 anime 能认出这是 proxy)。 + +--- + +## 14. Text Split + +**text/split.js**(512 行)。把一段文本拆成 line / word / char 三层元素: + +- **Intl.Segmenter**(L41)可用时用 Unicode 正确分词(中日韩、emoji),否则 fallback 到空格分割。 +- `setAriaHidden` 给拆出来的副本加 `aria-hidden="true"`(L81) —— **可访问性考虑**:原文保留,拆的是副本供屏幕阅读器看不到的动画视觉层。 +- `filterLineElements`(L109-126)按 line 重排时,把不属于此行的 elements 以及相邻的空白 textNode 加入 bin(避免重组后出现孤零零空白)。 +- 模板字符串 `{value}` / `{i}` 占位(L42-43),允许用户自定义包装模板。 + +--- + +## 15. WAAPI 适配 + +**waapi/waapi.js:L85-120**,540 行总量。核心亮点是 **custom easing → CSS linear() 降级**: + +```js +const easingToLinear = (fn, samples = 100) => { + const points = []; + for (let i = 0; i <= samples; i++) points.push(round(fn(i / samples), 4)); + return `linear(${points.join(', ')})`; +} +``` + +anime 的自定义 easing(比如 `'outElastic(1, 0.3)'`、`spring({mass, stiffness})`)在 WAAPI 侧没有对应语法。作者采样 100 个点,用 CSS Level 4 的 `linear(v0, v1, ..., v100)` 合成一条分段线性 easing —— **无需 JS 驱动就能 off-main-thread 跑任意曲线**。WAAPIEasesLookups 缓存结果(L91)避免重复采样。 + +这招只在支持 `linear()` 的浏览器上有效(Safari 17.2+ / Chrome 113+)。 + +--- + +## 16. Draggable(agent 深读,1286 行) + +**不继承 Timer**,而是**组合 3 个 Timer + 1 个 Animatable**(draggable.js:L384-400)。 + +### 状态机 +- `grabbed` (L403):指针按下 +- `dragged` (L404):已移动超阈值 +- `released` (L406):已松开 +- `updated` (L405):本帧有更新 + +### 事件流(`handleEvent` 分发 L1248-1278) +- `pointerdown → handleDown` L888-953:绑所有监听 → 初始化 pointer 坐标 → onGrab +- `pointermove → handleMove` L958-1020:`normalizePoint` 父元素 transform 反演 → touch 祖先可滚动检测(L972-984)→ drag threshold 门限 → 启动 updateTicker → onDrag +- `pointerup → handleUp` L1022-1151:速度采样 → 弹道预测 → spring/easing 二选一驱动回位 → onRelease + +### 物理 +- **速度栈 3 帧循环缓冲取最大值**(L363-365 + L489-493)—— 不是线性平均,是取 max,避免最后几帧减速导致惯性偏弱。 +- **spring 模式**(L274-284):mass=1, stiffness=80, damping=20 默认。 +- **过冲双阶段**(L1090-1109):非 spring 模式下若反弹超边界,先动画到物理计算的过冲点(65% 时间),再反弹到最终点 —— 让"撞墙回弹"自然。 + +### 约束 +- **临时清空祖先 transform**(L593 `transforms.remove()`)才能用 `getBoundingClientRect` 拿到准确边界,然后再 revert。同 scroll.js 处理 sticky 一样的思路。 +- **snap**(L509, L527, L702-703):number/array/function 三态。 +- **containerFriction**(L700-701):默认 0.8,应用在边界外推压计算(`cf = (1 - friction) * dragSpeed`,L789)。 + +### 性能 +- `updateTicker` 只在 dragged 期间 resume(L1010),release 后改由 Animatable 的 onRender 驱动(L426),减少无用 rAF。 +- ResizeObserver 节流到 150ms `resizeTicker`(L453)。 + +--- + +## 17. Layout FLIP(agent 深读,1607 行) + +是 anime.js 里**最大的单文件**,实现的是 **F**irst / **L**ast / **I**nvert / **P**lay 动画。 + +### 触发 +**显式三步 API**(无 MutationObserver): +```js +layout.record() // First +// 用户自己改 DOM +layout.animate(params) // Last + Invert + Play +``` +或一步到位:`layout.update(cb, params)`(L1595-1599)。 + +### 测量 +每个节点 `properties` 六维(layout.js:L318-328): +- `transform` 字符串 +- `x/y` 相对父坐标 +- `left/top` 绝对屏幕坐标 +- `width/height` 盒模型尺寸 +- 用户自定义的 `opacity/color/fontSize` 等 + +**测量前临时清除 transform**(L382-392 `style.transform = 'none'`)才能拿到"未变形"的 bbox。 + +### Invert 双引擎(最精彩处) +- **位置+尺寸** 用 anime.js Timeline(animejs 原生补间) +- **transform** 用 WAAPI **并行跑**,`timeline.sync(transformAnimation, 0)` 同步(L1566-1584) + +注释写明:`transform` 如果也走 Timeline 会和 `translate` 抢 style 属性,所以必须拆开。这是一条深水炸弹级的实现细节。 + +### 难点处理 +- **嵌套 FLIP**:`animatedParent` 链(L1296-1300)—— 子元素继承最近被动画的祖先的 timing,自动"跟随"祖先而不独立 invert。 +- **display:none ↔ block**:`hasVisibilitySwap` + `measuredDisplay` 换脸(L1219-1230)。 +- **内联文本**:`hasAdjacentText` → `isInlined=true` → 跳过位置动画(L368-379, L1406)—— 否则 inline span 包 translate 会全乱。 +- **display:grid 干扰**:动画期间强行改 block(L1408)。 +- **swapAt 中途改变**:双段 easing,后半段用 `inverseEased = t => 1 - ease(1 - t)`(L1530-1545),在 50% 时用 `tl.call()` 切换 DOM 值(L1505-1528)。 + +--- + +## 18. Scroll / 滚动驱动(agent 深读,986 行) + +**混合架构**:原生 scroll 事件 + 3 层 Timer 合批 + **主动轮询**而非 IntersectionObserver。 + +### 三层 Timer(events/scroll.js) +- `scrollTicker` L162-170:rAF Timer,合批 all observers +- `dataTimer` L172-189:30Hz 独立定时器算速度/方向,不抢 rAF 带宽 +- `wakeTicker` L201-210:500ms 防抖,scroll 事件**不直接**触发 scrollTicker,而是 restart wakeTicker,它再启动 scrollTicker —— 滚动停止后延迟休眠 + +scroll 事件本身只做 `wakeTicker.restart()`(L284-285),真实工作在 rAF 帧里批量处理。 + +### Scrub +进度映射纯函数(L581-584): +```js +const p = (this.scroll - this.offsetStart) / this.distance +return round(clamp(p, 0, 1), 6) +``` +单向:scroll → timeline.seek,**不支持反向驱动 scroll**。 + +### Offset 解析 +字符串模板 `'end start'` / `'top 50%'` 等 —— parseBoundValue(L358-387)支持百分比、单位、相对运算符;`'top 50%'` → `offset = rect.top + viewportHeight * 0.5`。 + +### Sticky 处理 +updateBounds 里**临时禁用祖先 sticky**(L774-781)→ 准确 getBoundingClientRect → revert(L840-841)。同一思路在 Draggable 里也用(父 transform 反演)。 + +### 方向感知 +`enterForward / enterBackward / syncEnter` 四套回调(L873-921),根据 container.backwardX/Y 判断方向,触发对应分支。 + +### 共享容器 +`scrollContainers = new Map()` 全局缓存(L116),多个 observer 共享一个 `ScrollContainer`,unsubscribe 到空才 revert 容器(L965-978)。 + +--- + +## 19. 精彩设计合集 + +### 1. `/*#__PURE__*/` pragma 满天飞 +IIFE 创建的 maps / singletons 都带 `/*#__PURE__*/`(engine.js:L44-45, consts.js:L68-74)让 Rollup / Esbuild tree-shake —— 你只用 `animate()`,整个 timeline/draggable/scroll 都能被干掉。 + +### 2. 双向链表全家桶 +Engine children / Timeline children / Animation tweens / siblings replace / siblings add / Timer 队列全部用同一套 `_prev / _next` 链表(helpers.js:L217-263)。helpers.js 里 80 行实现的原语复用整个库。**每次 addChild 可选 sortMethod 做有序插入**(L255-263),Tween 按 `_absoluteStartTime` 排序也是用这个。 + +### 3. WeakMap 缓存 + Symbol 标记 +- `transformsSymbol` 存 transform tween 缓存(不污染正经属性) +- `isDomSymbol / isSvgSymbol` 标记 target 类型(不每次 instanceof) +- `morphPointsSymbol` 缓存上次 morph 结果 +- `proxyTargetSymbol` 让代理能被识破(drawable) +- `lookups._rep = new WeakMap()` / `_add = new Map()` —— 主动回收 + +### 4. decomposeRawValue 的四类 + 运行期零正则 +创建期解析一次,运行期只算术。最典型是 COMPLEX:字符串 `rgb(0,0,0) 10px 10px 20px` 被切成 `s[]` + `d[]`,60fps 下只做数组 lerp + 字符串模板拼接。 + +### 5. render.js:L268 的 transform 字符串一帧只写一次 +多个 transform tween 通过 `transformsSymbol` cache + 链尾 `_renderTransforms=1` 的 marker tween 触发最终写入。对 60 个 transform 的大 stagger,每帧只有 1 次 `style.transform = ...`。 + +### 6. Engine 按需 wake +rAF 不空转:tickEngine 看 `_head` 空就把 `reqId=0` 自然终止(engine.js:L168-175)。新 Animation resume 时 `engine.wake()` 才重启 rAF。**没有动画时零开销**。 + +### 7. visibility 自动 pause +`visibilitychange` listener 自动挂(L159-162),可关 `pauseOnDocumentHidden = false`。 + +### 8. alternate 的 XOR 一行 +```js +const isReversed = _reversed ^ (_alternate && isOdd) // render.js:L97 +``` +三状态组合用 XOR 解决。 + +### 9. Proxy 实现的 drawable +不引入新 CSS 属性,用 Proxy 拦截 `setAttribute('draw', ...)` 转译成 `stroke-dasharray` + `stroke-dashoffset`。用户写得像在用原生属性。 + +### 10. WAAPI 的 `easingToLinear(100 samples)` +自定义 easing 降级到 CSS `linear(v0,...,v100)`,让 GPU 线程跑 spring / elastic 成为可能。 + +### 11. **1000 targets 自动关 composition**(animation.js:L263) +```js +const tComposition = isUnd(composition) && targetsLength >= K + ? compositionTypes.none : ... +``` +巨量元素 stagger 时自动关闭昂贵的 sibling lookup —— 用户无感的性能兜底。 + +### 12. Scope.keepTime +`scope.keepTime(cb)` 在 media query refresh 时保留 timeline 的 currentTime(不从 0 开始)。这个 UX 细节很少有动画库做。 + +### 13. **playbackEase**:Timeline 级别的 warp +render.js:L100 `iterationTime = iterationDuration * _ease(iterationTime / iterationDuration)` —— 不是单个 tween 的 easing,而是把 timeline 的**整个时间流**扭曲。playback 特效用。 + +### 14. additive 的反向累加 +multi-input 同时影响 scale,不会互相覆盖,反而**叠加**。blend composition 把每个 tween 变成 delta,engine 每帧聚合一次(additive.js)。 + +### 15. stagger(customTotal) + stagger(use) +`stagger(100, { total: 10 })` 和 `stagger(100, { use: 'data-delay' })`:前者支持"N 个元素但假装是 M 个",后者支持"从元素属性读 index"(data-attribute 驱动)。 + +--- + +## 20. 可能的坑 / 可借鉴点 + +### 坑 +- **毫秒 vs 秒切换** (timeUnit):切到 's' 后内部全部乘 0.001,造成一堆 round(_, 12) 抗浮点 —— 不要中途切换单位,应用启动时定好。 +- **seek 取消的动画需要 reviveTimer**:timer.js:L86-99 说明 cancel 后再 seek 要先重建 siblings 链,否则渲染空白。库内部已处理,但如果你自己 hack 可能踩。 +- **Timer 构造器里直接读 engine._lastTickTime**(timer.js:L167):如果在动画循环外手动 `new Animation()` 然后立刻检查 currentTime 可能拿到"冷"数据 —— L166 特地 `engine.requestTick(now())` 热身。 +- **Draggable 在 transformed 祖先里**:依赖 `transforms.remove()` 临时清 transform 测量(draggable.js:L593),有 CSS 变量或复杂 transform 时可能不完美。 + +### 可借鉴 +- **linked-list children + addChild sortMethod** 这套 40 行原语,可以直接拷到任何需要排序链表的地方。 +- **预解析 + 运行期无正则**:任何需要字符串补间的场景(比如 CSS gradient 动画、SVG path 补间)都适用这个模式。 +- **按需 rAF**(engine.js tickEngine 模式):`_head` 空就自然终止循环,有需求时再启动。大多数自研动画库漏掉这个。 +- **visibility auto-pause**:一行事件监听,避免后台 tab 疯狂吃 CPU。 +- **Proxy 做虚拟属性**(drawable 的 'draw' 属性):在不能扩展原生 API 时给 API 设计者的后门。 +- **WAAPI linear() 降级**:任何需要 "main-thread 动画 + off-main-thread 采样" 协同的库都能学。 +- **scope 作用域 + revertibles**:给框架集成(React/Vue/Angular)用的标准 pattern,值得抄。 +- **阈值自动降级**(targets >= 1000 关 composition):性能护栏,用户不用自己做决定。 + +--- + +## 21. 模块依赖图(概要) + +``` +consts.js (enums + regex + Symbols) ← 所有模块 +helpers.js (math/type/linked-list) ← 所有模块 +globals.js (defaults/scope.current) ← timer/animation/scope + +core/values.js (decompose/getTweenType) ← animation/animatable +core/render.js (render + tick) ← timer/engine +core/clock.js ← engine/timer + +timer/timer.js ← animation/timeline +engine/engine.js ← timer/animatable/scroll/draggable +animation/animation.js ← timeline/animatable +animation/composition.js ← animation/timeline +animation/additive.js ← engine/composition + +timeline/timeline.js ← 用户 +timeline/position.js ← timeline/stagger + +easings/* (parser) ← animation/timeline/waapi/stagger/draggable +utils/stagger.js ← 用户 +utils/target.js (revert utilities) ← timeline/animatable + +scope/scope.js ← 用户(通常 React) +animatable/animatable.js ← 用户(mousemove 场景) +draggable/draggable.js ← 用户 +events/scroll.js ← 用户 +layout/layout.js ← 用户(FLIP 场景) +svg/* ← 用户(svg 场景) +text/split.js ← 用户 +waapi/waapi.js ← 用户(off-main-thread 场景) +``` + +--- + +## 附录:文件规模 + +| 模块 | 行数 | +|---|---:| +| layout/layout.js | 1607 | +| draggable/draggable.js | 1286 | +| events/scroll.js | 986 | +| animation/animation.js | 747 | +| types/index.js | 652 | +| waapi/waapi.js | 540 | +| timer/timer.js | 535 | +| text/split.js | 512 | +| core/render.js | 398 | +| animation/composition.js | 390 | +| timeline/timeline.js | 362 | +| scope/scope.js | 259 | +| core/values.js | 235 | +| engine/engine.js | 181 | +| utils/chainable.js | 171 | +| core/targets.js | 138 | +| utils/stagger.js | 142 | +| core/styles.js | 118 | +| svg/drawable.js | 118 | +| core/helpers.js | 263 | +| core/consts.js | 118 | +| core/clock.js | 107 | +| core/colors.js | 103 | +| svg/motionpath.js | 88 | +| utils/number.js | 84 | +| waapi/composition.js | 84 | +| animation/additive.js | 81 | +| core/globals.js | 74 | +| timeline/position.js | 72 | +| svg/morphto.js | 65 | +| utils/random.js | 63 | +| core/units.js | 63 | +| utils/time.js | 57 | +| core/transforms.js | 45 | +| svg/helpers.js | 24 | +| 其他 | 27 | +| **total** | **11,121** | diff --git a/.memory/worklog.json b/.memory/worklog.json index a93fcf2..f62e476 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -27,6 +27,13 @@ "message": "auto-save 2026-04-23 23:08 (~1)", "hash": "8e8f977", "files_changed": 1 + }, + { + "ts": "2026-04-23T23:14:02+08:00", + "type": "commit", + "message": "auto-save 2026-04-23 23:13 (~1)", + "hash": "764b395", + "files_changed": 1 } ] }