- 前端
- UI组件
【免费下载链接】motion
A modern animation library for React and JavaScript
导读
Motion(framer-motion)的序列(sequence)动画已支持用标签(label)锚定时间点、用"+0.5"/"-0.5"相对当前播放头、用"<+0.5"相对上一段,唯独缺少“标签 + 偏移”的组合表达——at: "myLabel"可以工作,而at: "myLabel+0.2"会静默回退到currentTime。本篇文章以仓库内实现计划 plans/issues/issue-2204.md 为骨架,完整讲解该功能从语法决策、失败测试先行、纯函数解析实现到集成测试与质量门禁的落地全过程。读完你将掌握 Motion 序列时间解析器calcNextTime的完整逻辑、标签偏移语法"label+0.2"的精确解析规则(含精确匹配优先、负数截断到 0、贪婪正则等边界细节),并可直接复现整条 TDD 实施路径。
一、功能背景:时间轴标签的最后一个缺口
1.1 Motion 序列的既有时间表达
在 Motion 中,序列(AnimationSequence)是一组段(segment)数组,每段可以是[subject, keyframes, transition]元组、纯字符串标签、或带时间的标签对象{ name, at }。段的transition.at决定它在时间轴上的绝对锚点,目前支持四类表达(见 create.ts 中calcNextTime的调用与 types.ts 的SequenceTime联合类型):
| 语法 | 含义 | 解析结果 |
|---|---|---|
0.2(数字) | 绝对时间 | 直接返回该数字 |
"+0.5"/"-0.5" | 相对当前播放头 | Math.max(0, current + parseFloat(next)) |
"<" | 上一段结束时间 | 返回prev |
"<+0.5"/"<-0.5" | 相对上一段结束时间 | Math.max(0, prev + parseFloat(next.slice(1))) |
"myLabel" | 标签名 | labels.get(next) ?? current |
标签本身由两种方式创建:序列中的纯字符串段(如"my label")在当前位置打点;对象段{ name: "my label", at: 0 }则通过calcNextTime计算标注时间。两者共用同一个timeLabels: Map<string, number>(见 create.ts)。
1.2 缺失的组合能力
上述表格里唯独缺少“标签 + 数值偏移”的组合:at: "myLabel+0.2"期望“在标签时刻之后再晚 0.2 秒”,但当时代码把整串"myLabel+0.2"当作标签名去labels.get(),查不到便静默回退为current。正如计划文档 issue-2204.md 所述,构造精细交错时间轴(intricate staggered timelines)的开发者被迫手工换算绝对播放头时间——而这正是标签机制本要消除的数学。行业对标中,GSAP 早已支持"label+=0.2"语法,因此这是时间轴人机工学(timeline ergonomics)的“桌面上就该有的能力”(table-stakes),且修复只需在一个纯函数中增加几行解析逻辑,配合已有专门测试文件即可覆盖。
该功能与 plan 005(网格/距离 stagger) 正交:plan 005 关注
stagger()函数本身,而非at的标签算术,两者无重叠。
二、现状剖析:calcNextTime与类型系统
2.1 纯函数解析器的完整实现
修复前的calcNextTime位于 packages/framer-motion/src/animation/sequence/utils/calc-time.ts,全文仅 24 行,是解析一切序列时间表达的单一入口:
export function calcNextTime( current: number, next: SequenceTime, prev: number, labels: Map<string, number> ): number { if (typeof next === "number") { return next } else if (next.startsWith("-") || next.startsWith("+")) { return Math.max(0, current + parseFloat(next)) } else if (next === "<") { return prev } else if (next.startsWith("<")) { return Math.max(0, prev + parseFloat(next.slice(1))) } else { return labels.get(next) ?? current } }从源码可归纳出该函数的解析优先级:数字 > 相对当前 > 上一段结束 > 相对上一段 > 标签/回退。末路else分支用labels.get(next) ?? current同时承担“命中标签”与“找不到回退”两个职责——"label+0.2"无法命中标签名,于是落入current回退。
2.2 两个调用点与共享标签表
calcNextTime在 create.ts 中被两处调用,均传入共享的timeLabels:
- 标签对象标注(create.ts#L74-L77):处理
{ name: "my label", at: "-1" }这类带时间的标签段,把计算出的绝对时间写入timeLabels; - 段
at解析(create.ts#L87-L94):处理常规段的transition.at,将currentTime推进到解析结果。
2.3 类型层已放行:问题只在解析逻辑
SequenceTime联合类型(types.ts#L54-L59)由number | "<" | \+${number}` | `-${number}` | ${string}`组成。其中兜底的${string}意味着"label+0.2"在类型层面天然通过检查——它今天就能编译,只是**运行时解析错误**。这正解释了为何该修复无需触碰类型文件:计划明确将sequence/types.ts划为 out of scope,理由是即便增加label+n` 的模板字面量类型也无法实用地表达(计划 issue-2204.md Scope 章节)。
三、语法决策门:"label+0.2"而非 GSAP 的"label+=0.2"
实现前设有maintainer decision gate,需要先在 plans/issues/README.md 的行内记录语法决策并等待APPROVED:
- 推荐语法:
at: "label+0.2"/at: "label-0.2"——与 issue 的原生诉求一致,也与 Motion 已有的"<+0.2"风格统一,不引入 GSAP 的=("label+=0.2"); - 解析规则(精确匹配优先):先尝试把整个字符串当作标签名做精确查找——字面量名为
"step+1"的标签继续正常工作;仅当精确查找失败时,才从尾部切分+number/-number去查基础标签名;若基础标签也不存在,则保留现有回退行为(返回current); - 若被
REJECTED,则按not_planned关闭 issue 并建议用户改用“纯标签at+ 段级delay”组合。
四、实施路径:先失败测试,再最小实现
计划采用严格的红-绿测试驱动流程,共五步。
4.1 Step 1:先写会失败的测试
在既有单测 packages/framer-motion/src/animation/sequence/utils/tests/calc-time.test.ts 中,沿用已有labels.set("foo", 2)夹具,追加以下断言(注意labels为Map,无类型注解,set均合法):
// Label with offset expect(calcNextTime(4, "foo+1", 100, labels)).toBe(3) expect(calcNextTime(4, "foo-1", 100, labels)).toBe(1) expect(calcNextTime(4, "foo+0.25", 100, labels)).toBe(2.25) expect(calcNextTime(4, "foo-3", 100, labels)).toBe(0) // clamped to 0 expect(calcNextTime(4, "bar+1", 100, labels)).toBe(4) // unknown label → current (unchanged fallback) // Exact-match precedence labels.set("baz+1", 9) expect(calcNextTime(4, "baz+1", 100, labels)).toBe(9)跑 calc-time 过滤器,新断言在旧代码上必然失败(如"foo+1"返回4而非3),这正是实现前需要的“bug 形状的失败”(bug-shaped failure)。既有的describe("calcNextTime")已覆盖绝对时间、标签、相对时间、上一段、相对上一段五类形态,是本功能回归测试的天然底座。
4.2 Step 2:最小实现——只改else分支
按仓库“字节精简”(byte-light)风格,仅替换calcNextTime的末尾else分支,目标形态如下:
} else { const labelTime = labels.get(next) if (labelTime !== undefined) return labelTime const match = next.match(/^(.+)([+-]\d*\.?\d+)$/) if (match) { const base = labels.get(match[1]) if (base !== undefined) { return Math.max(0, base + parseFloat(match[2])) } } return current }两个关键设计点(计划文档 Maintenance notes 要求评审重点把关):
- 贪婪捕获
(.+):"a+b+0.2"会优先把"a+b"当作基础标签名,因为偏移必须是尾部的数值部分;这与"<+0.2"既有解析的严格风格保持一致; - 不支持空白:
"foo + 0.2"不解析——与上方"<+0.2"的严格解析保持奇偶一致(keep parity)。
修改后跑 calc-time 过滤器,全部通过(含 Step 1 新增用例)。
4.3 Step 3:集成测试钉死 1.5s 起点
在 packages/framer-motion/src/animation/sequence/tests/index.test.ts 中新增一条测试,仿照该文件既有标签测试的断言风格:构建序列
[ [el, { ... }, { duration: 1 }], "mid", [el2, { ... }, { duration: 1 }], [el3, { ... }, { at: "mid+0.5", duration: 1 }], ]经createAnimationsFromSequence解析后,断言第三个主体的times/duration使其起点落在 1.5 秒处(与相邻测试一样检查返回定义的transition[key].times与duration的对应关系)。这里的时间线推算:首段耗时 1s →"mid"标签落在 1s → el2 从 1s 起播 1s → el3 的"mid+0.5"= 1s + 0.5s = 1.5s 起播。跑 sequence 过滤器,全部通过。
4.4 Step 4:全量质量门禁
依次执行计划 issue-2204.md “Commands you will need” 一节给出的验证命令(均以仓库根目录为准):
| 用途 | 命令 | 预期 |
|---|---|---|
| 构建(仓库根一次) | yarn build | exit 0 |
| 单元测试 | npx jest --config packages/framer-motion/jest.config.json --testPathPattern="calc-time" | 通过 |
| 序列套件 | npx jest --config packages/framer-motion/jest.config.json --testPathPattern="sequence" | 通过 |
| Lint | yarn lint | exit 0 |
再跑一次 framer-motion 客户端全量套件:cd packages/framer-motion && yarn test-client,无新增失败(仓库记忆中已记录的既有 SSR / use-velocity 失败不计入)。
4.5 Step 5:答复 issue 并收尾
在 issue #2204 上回复已发布的语法与示例、指明随附的 release 版本,并按门禁以completed关闭(或按维护者偏好留至发布)。
五、作用域与刻意排除项
计划将改动严格限定在三个文件:
packages/framer-motion/src/animation/sequence/utils/calc-time.ts(唯一实现改动)packages/framer-motion/src/animation/sequence/utils/__tests__/calc-time.test.ts(单元测试)packages/framer-motion/src/animation/sequence/__tests__/index.test.ts(集成测试)
明确排除:sequence/types.ts(SequenceTime已由${string}放行,模板字面量类型无法实用表达label+n);GSAP 风格"+=0.2"别名、百分比偏移、"<label"组合;以及 motion-dom(序列逻辑本就归属 framer-motion,见 motion-dom 与 framer-motion 的包职责划分)。
六、工程规范:Git 流程、完成标准与止损条件
6.1 Git 工作流
- 分支名:
feature/sequence-label-offset - 提交信息遵循 Conventional Commits,如
feat: support time offsets from labels in sequence 'at' option - 除非操作者明确指示,否则不 push / 不开 PR
6.2 Done criteria(完成标准)
- 写任何代码前行状态为
APPROVED - 新 calc-time 用例在修复前已存在且失败(在 PR/报告中说明)
- calc-time + sequence 过滤器通过;
yarn build+yarn lintexit 0 - 只改动了 in-scope 文件
- 按门禁答复/关闭 issue,并更新
plans/issues/README.md行
6.3 STOP conditions(止损条件)
- 行未 APPROVED → 在 Step 1 前停止;
calcNextTime与计划中的摘录不一致(发生 drift)→ 停止;- Step 3 的集成测试两次尝试仍无法用相邻测试的断言风格钉住 1.5s 起点 → 停止并上报,不得自创新的断言机制。
七、维护备注:未来的seek("label+0.2")复用
计划文档还留下一条前瞻性备注:如果未来将标签→时间查找公开为 API(见 issue #2608 的限制说明),应复用同一套解析逻辑,使seek("label+0.2")与at: "label+0.2"行为完全一致。同时要求评审者重点审视两处:正则的精确匹配优先级,以及与其它相对形式保持一致的Math.max(0, ...)负数截断——后者意味着"foo-3"在foo=2时得到0而非-1,保证任何偏移都不会把时间轴播头拖到负值。
八、关键文件索引
- 实现计划原文:plans/issues/issue-2204.md
- 计划总索引与状态表:plans/issues/README.md
- 核心解析函数:calc-time.ts
- 时间类型定义:sequence/types.ts
- 调用点与时间轴构建:create.ts
- 单元测试:calc-time.test.ts
- 集成测试:index.test.ts
- 前端
- UI组件
【免费下载链接】motion
A modern animation library for React and JavaScript
相关推荐
Motion高级动画编排:5个时间线与序列动画实现技巧
Motion高级动画编排:5个时间线与序列动画实现技巧 Motion是一个开源的、生产就绪的React动画和手势库,为开发者提供了强大的动画编排能力。本文将深入
前端UI组件Mermaid Live Editor 免费在线画图教程:1 分钟用文字画出第一张流程图
Mermaid Live Editor 免费在线画图教程:1 分钟用文字画出第一张流程图 同事让你改图里的一个节点名:“只把顶部那个名字改成‘支付网关’,其他别
前端开发者工具数据可视化大麦自动抢票 3 步跑通:从环境到下单的完整流程
大麦自动抢票 3 步跑通:从环境到下单的完整流程 大麦自动抢票工具 ticket purchase 是一个 Python 脚本,Web 端走 Selenium,
GUI 自动化RPA
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考