- 前端
- UI组件
【免费下载链接】motion
A modern animation library for React and JavaScript
本文以仓库内的 plans/issues/issue-2563.md 计划文档为主体,深入复盘 Motion 动画库中animate(el, { opacity: 0, display: "none" })在useAnimate/独立animate()路径下动画结束后display停留在旧值的问题:包括其被定位为与 issue-2656 同根因的回归、为何只有"none"关键字受影响、mixVisibility的修复实现,以及如何通过回归测试与 GitHub CLI 完成验证和关闭的完整流程。读者读完可以掌握 Motion 对离散值(display/visibility)动画的底层处理机制、修复后的预期行为,以及仓库中这套"问题计划(plan)"的落地执行方法。
一、问题现象:useAnimate中display: "none"无法被设置
该问题于 2024-03-16 报告,针对当时的 11.0.14 版本。复现代码非常简洁:
animate(el, { opacity: 0, display: "none" }, { duration: 0.7 })实际表现是:opacity正常按 0.7s 淡出,但display始终停留在动画开始前的旧值,元素在视觉淡出后依然占据布局。而同一动画中如果使用其他display关键字(如inline、contents、flex),则一切正常。issue 下共有 8 条评论确认了同样的症状,并验证了transitionEnd: { display: "none" }这个 workaround 可以绕开问题。其中一位评论者(modulareverything,2024-05-03)通过二分定位,将问题锁定到v10 → v11.1.x 的升级区间——这与 issue-2656 所指向的 11.0.11 回归版本完全吻合,为后续合并处理提供了直接证据。
二、根因分析:为什么偏偏只有"none"失效
2.1 同一回归的两个入口
- issue-2563:
useAnimate/ 独立animate()命令式 API 路径; - issue-2656(详见 plans/issues/issue-2656.md):React 组件的
variants/animate属性路径,即{ opacity: 1, display: "block" }与{ opacity: 0, display: "none" }之间切换时,隐藏动画结束后display没有回到none。
两个问题在 11.0.10 都正常,在 11.0.11 同时被破坏。issue-2656 计划文档明确指出根因是11.0.11 的异步关键帧解析重写(async-keyframe-resolution,CHANGELOG 中对应 "Keyframes now resolved asynchronously"),该改动改变了离散字符串关键帧的混合(mix)方式,导致none终点状态丢失。mattgperry 于 2024-05-13 确认了回归(原话大意:"这个确实被无意间破坏了,不过此前的行为也不理想——瞬间切到 'none'"),并在当天合入修复。
2.2isNone():"none"被当作特殊关键帧
为什么其他关键字不受影响?关键在关键帧解析器对"none"的特殊对待。packages/motion-dom/src/animation/keyframes/utils/is-none.ts 中的isNone()把"none"(以及数字0、字符串"0"、各种零值字符串)判定为特殊值:
export function isNone(value: AnyResolvedKeyframe | null) { if (typeof value === "number") { return value === 0 } else if (value !== null) { return value === "none" || value === "0" || isZeroValueString(value) } else { return true } }"inline"、"contents"等普通关键字不会命中该判断,走的是常规的mixImmediate即时混合路径,因此不受回归影响;而"none"由于在解析器和 mixers 中都被当作特殊关键帧处理,走进了被破坏的离散混合(discrete-mix)路径,最终丢掉了终点值。这就是"只有none坏了"的完整解释。
三、修复实现:mixVisibility与二值可见性插值
3.1 核心函数
修复提交9dc6e6aa1(2024-05-13,随v11.2.0于 2024-05-14 发布)新增了mixVisibility,位于 packages/motion-dom/src/utils/mix/visibility.ts:
export const invisibleValues = new Set(["none", "hidden"]) export function mixVisibility(origin: string, target: string) { if (invisibleValues.has(origin)) { return (p: number) => (p <= 0 ? origin : target) } else { return (p: number) => (p >= 1 ? target : origin) } }其语义可以拆成两条规则:
- 从不可见 → 可见(如
none→block):p <= 0时保持origin,其余进度立即输出target,即显示方向瞬间应用可见值; - 从可见 → 不可见(如
block→none):p >= 1时才输出target,其余进度保持origin,即隐藏方向在整个动画期间保持可见,动画结束瞬间才切到none/hidden。
这正是 CHANGELOG 中该版本记录的措辞:"Binary visibility interpolation i.edisplay: ["block", "none"]now maintains the visible state throughout the animation"(见 CHANGELOG.md 对应 v11.2.0 条目)。
3.2 路由与调度
mixVisibility并非孤立生效,它被嵌入到混合管线中:
- 路由:packages/motion-dom/src/utils/mix/complex.ts 的
mixComplex检测到invisibleValues("none"、"hidden")且对端没有可插值数值时,直接返回mixVisibility(origin, target),不再走数值混合。 - 保留 JS 路径:packages/motion-dom/src/animation/utils/can-animate.ts 中
if (name === "display" || name === "visibility") return true,确保这两个属性始终进入 JS 动画路径(代码注释明确说明"这些传统上不可动画,但我们支持它们")。 - 不启用 WAAPI 加速:packages/motion-dom/src/animation/waapi/supports/waapi.ts 依据
acceleratedValues判断,display不在加速列表中,因此display动画永远不会被 Web Animations API 加速执行,全部由 JS 帧循环驱动。 - 终点落盘:packages/motion-dom/src/animation/JSAnimation.ts 的
tick在动画完成时通过getFinalKeyframe(...)写入真实的最终关键帧(即"none"),保证动画结束后样式值精确落在目标值上。
3.3 回归测试与演示用例
仓库已包含完整的回归测试,位于 packages/framer-motion/src/motion/tests/animate-prop.test.tsx:
"animate display none => block immediately switches to block":验证显示方向在动画开始后立即输出"block";"animate display block => none switches to none on animation end":验证隐藏方向在动画结束时才输出"none";- 还有
"animate visibility hidden => visible immediately switches to visible"等visibility用例,以及none/block切换触发onAnimationComplete的用例。
修复提交同时新增了开发演示 fixture:dev/react/src/examples/Animation-display-visibility.tsx,可直接在 dev 环境观察display/visibility动画的完整时序。
四、修复后的预期行为:none在动画完成时生效,而非开始时
需要特别强调的是,修复后display: "none"在动画完成时才被应用,而不是动画一开始就生效——这是设计行为,不是 bug。对报告者的 0.7s 场景,即:元素先淡出 0.7s(期间保持可见),0.7s 结束后display才切换为none。这样元素在淡出过程中始终可见可交互,视觉上自然;若要在动画开始时立即隐藏,需要改用其他手段(例如opacity配合pointer-events或延迟应用display)。这也是原文档在 Verdict 末尾特意标注"not at the start — that is the designed behaviour, not a bug"的原因。
因此,transitionEnd: { display: "none" }这个 workaround 在 v11.2.0 之后不再需要,官方在关闭评论中也明确建议:若在 motion@12 上仍可复现,请附上最小复现(reproduction)新建 issue。
五、useAnimate路径为何同样被覆盖
issue-2563 走的是useAnimate命令式路径,issue-2656 走的是 React 声明式路径,两条路径最终汇入同一条渲染管线。从 packages/framer-motion/src/animation/animate/subject.ts 可以看到,元素动画会为 DOM 元素创建视觉元素(createDOMVisualElement,见 subject.ts),随后与组件动画一样经过AsyncMotionValueAnimation → JSAnimation → mix这条管线。由于display永远不会被 WAAPI 加速(见 3.2 节),两条入口在 JS 混合层完全汇合,因此同一个mixVisibility修复天然覆盖useAnimate与独立animate()。
六、计划文档的执行流程:从验证到关闭
issue-2563 计划文档本身还包含一套可复现的落地步骤,这也展示了仓库中 plan 类文档的标准作业方式:
Step 1:用 issue-2656 的回归测试做验证(gate)
npx jest --config packages/framer-motion/jest.config.json --testPathPattern="animate-prop" -t "display"验证标准:所有display相关测试全部通过(≥3 个,0 失败);若有任何失败,则说明回归仍然存在,必须停止并改用 FIX 计划。
Step 2:审批门禁(Approval gate)
打开 plans/issues/README.md,找到 issue-2563 对应状态行;若该计划未被标记为 APPROVED,则将该行置为 BLOCKED 并停止。
Step 3:评论并关闭 issue
gh api repos/motiondivision/motion/issues/2563/comments -f body="This was the same regression as #2656 and was fixed in v11.2.0 (2024-05-14). Since then, animating to display: 'none' holds the visible value for the duration of the animation and applies 'none' when the animation completes — so with your 0.7s duration, display switches to none after 0.7s (by design, so the element stays visible while it fades out). The transitionEnd workaround is no longer required. If you can still reproduce on motion@12, please open a new issue with a reproduction." gh api -X PATCH repos/motiondivision/motion/issues/2563 -f state=closed -f state_reason=completed验证:gh api repos/motiondivision/motion/issues/2563 --jq .state返回"closed"。
完成标准与停止条件
- Done criteria:Step 1 测试通过;issue 在 APPROVED 后以
completed原因注释并关闭;不修改任何源文件;更新 plans/issues/README.md 状态行。 - STOP conditions:Step 1 失败 → 报告并不得关闭;README 行未 APPROVED → 置为 BLOCKED。
- Drift check:每次执行前先跑
gh api repos/motiondivision/motion/issues/2563 --jq .state,若 issue 已关闭则直接标记 DONE 停止,避免重复劳动。
七、小结:从"奇怪 bug"到可复用的修复范式
issue-2563 的价值在于它揭示了一个典型回归的三层结构:症状(display: "none"不生效)、根因(异步关键帧解析重写破坏离散值混合,且只有被isNone()特殊化的"none"走受损路径)、修复(mixVisibility把display/visibility定义为"二值可见性插值":显示立即生效、隐藏动画结束生效)。它同时演示了与 issue-2656 合并关闭的跨 issue 协同方式:同根因问题共享修复与回归测试,通过-t "display"定向测试 + GitHub CLI 完成闭环。
对使用 Motion 的开发者,本文可以提炼出两条实践结论:
- 对
display/visibility这类离散属性,动画期间的值变化是"阶梯式"的:显示方向立即切换,隐藏方向在动画完成时切换——这正是淡出后自动隐藏元素的标准模式,无需再借助transitionEnd。 - 遇到"某个特殊值失效、其他值正常"的动画 bug 时,可以顺着"特殊值在解析器/mixer 中的特殊分支"这条线索排查(如
isNone()对"none"的特殊处理),往往能快速定位到回归点。
进一步阅读:根因与修复细节见 plans/issues/issue-2656.md;核心实现见 visibility.ts、complex.ts、is-none.ts、can-animate.ts;回归测试见 animate-prop.test.tsx。
- 前端
- UI组件
【免费下载链接】motion
A modern animation library for React and JavaScript
相关推荐
Readest 自动导入"按子文件夹分组"失效问题修复解析:Issue 5423 的根因、修复与工程实践
Readest 自动导入"按子文件夹分组"失效问题修复解析:Issue 5423 的根因、修复与工程实践 导读 本文基于 Readest 仓库中的修复记忆文档,
桌面应用跨平台前端Flutter camera 插件版本演进全解析:从 CHANGELOG 看 API 迭代、架构迁移与工程实践
Flutter camera 插件版本演进全解析:从 CHANGELOG 看 API 迭代、架构迁移与工程实践 camera 是 Flutter 官方维护的相机
前端UI组件Motion 拖拽惯性修复实录:hold-then-flick 速度稀释问题(issue-1747)的根因、修复与回归验证
Motion 拖拽惯性修复实录:hold then flick 速度稀释问题(issue 1747)的根因、修复与回归验证 本篇技术指南以开源动画库 Motio
前端UI组件
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考