☰
Motion 序列动画标签偏移:在 `at` 选项中实现 `“label+0.2“` 相对时间定位
2026/9/30 10:57:30 网站建设 项目流程
  • 前端
  • UI组件

【免费下载链接】motion

A modern animation library for React and JavaScript

项目地址:https://gitcode.com/GitHub_Trending/mo/motion
点击查看免费下载

导读

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 要求评审重点把关):

  1. 贪婪捕获(.+):"a+b+0.2"会优先把"a+b"当作基础标签名,因为偏移必须是尾部的数值部分;这与"<+0.2"既有解析的严格风格保持一致;
  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 buildexit 0
单元测试npx jest --config packages/framer-motion/jest.config.json --testPathPattern="calc-time"通过
序列套件npx jest --config packages/framer-motion/jest.config.json --testPathPattern="sequence"通过
Lintyarn lintexit 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

项目地址:https://gitcode.com/GitHub_Trending/mo/motion
点击查看免费下载

相关推荐

上一篇:ACE-Step UI:3步打造你的专属本地音乐创作工作站
下一篇:HuggingFaceModelDownloader终极指南:快速下载AI模型的完整解决方案

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询