ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

motion 中 layoutId 交叉淡化与 scale 动画叠加导致的视觉过冲:投影机制溯源、复现与规避方案(issue-2284 复盘)

2026/10/1 9:43:22 拓冰建站 浏览量
motion 中 layoutId 交叉淡化与 scale 动画叠加导致的视觉过冲:投影机制溯源、复现与规避方案(issue-2284 复盘) 前端UI组件【免费下载链接】motionA modern animation library for React and JavaScript项目地址https://gitcode.com/GitHub_Trending/mo/motion点击查看免费下载layoutId共享元素动画是 motionframer-motion最受欢迎的能力之一当两个元素持有相同layoutId时Motion 会通过投影projection引擎把它们当作“同一个元素”做位置与尺寸的连续过渡。然而一旦投影动画与元素自身的scale动画同时运行会出现一个反直觉的视觉缺陷元素在动画中途被放大到远超目标值直到动画结束才“啪”地落回正确位置。本篇文章以仓库 plans/issues/issue-2284.md 三分类处置计划为主线从源码级投影机制出发讲清楚这个过冲overshoot现象为什么发生、如何在本仓库中复现与量化验证、为什么scale: [null, null, null, 1.5]这段关键帧能精准规避它以及一个真实的 triage 计划应该如何落地含可用的gh api命令。读完你将能独立判断“共享布局动画 独立 transform 动画”组合下的视觉异常成因并掌握在不改动投影引擎的前提下安全处理这类历史 issue 的完整工作流。一、问题背景与处置结论1.1 issue 是什么这是一个 2023 年 8 月报告的历史 issue仓库内计划编号 issue-2284对应 plans/issues/README.md 的 Needs-repro 队列场景卡片列表中有layoutId点击卡片后通过createPortal弹出 ModalModal 内的共享元素除了继承layoutId之外还设置了animate{{ scale: 1.5 }}现象卡片 → Modal 的交叉淡化crossfade过程中元素视觉上的缩放比例远超 1.5mid-animation 明显过冲而动画结束后又“精确回到”正确的最终布局根因定性这不是一行代码就能修好的 bug而是“独立 scale 动画”与“layout 投影”两个系统之间架构性的相互作用architectural interaction。1.2 三分类结论与处置基调计划给出的处置是NEEDS-REPRO triage-onlyClassificationNEEDS-REPRO长期存在的投影限制2023 年的 issue原始 StackBlitz/CodeSandbox 复现链接很可能已失效因此需要基于 issue 文本在本地重建复现PriorityP3EffortMRiskLOWDepends onnoneCategorybugknown limitation —— triage/document关键红线本计划只做三分类triage与文档化禁止尝试修改投影引擎Work is triage-only — do NOT attempt a projection-engine fix。之所以这样定调是因为 issue 已经多年没有新活动且 reporter 自己在评论区找到了满意的规避方案scale: [null, null, null, 1.5]。因此诚实的处置路径是在当前 main 上复现 → 在 issue 上文档化机制与规避方案 → 建议以已知限制关闭not_planned而不是贸然规划一个投机性的投影修复。1.3 执行前必须跑的 drift check计划要求执行者第一步先做“漂移检查”drift check防止计划中摘录的源码片段与最新 main 不一致时盲目照做# 1. 确认 issue 仍处于 open 状态若已 closed直接标记 DONE 并停止 gh api repos/motiondivision/motion/issues/2284 --jq .state # 期望输出 open # 2. 检查两份关键投影源码是否在计划基线提交 42bfbe3ed 之后发生变动 git diff --stat 42bfbe3ed..HEAD -- \ packages/motion-dom/src/projection/node/create-projection-node.ts \ packages/motion-dom/src/projection/geometry/delta-remove.ts # 若输出为空说明摘录仍然有效若发生变更需重新核对下方源码摘录不一致即 STOP这两份文件的当前版本都真实存在于仓库中create-projection-node.ts 与 delta-remove.ts本篇文章后续的机制分析正是基于它们。二、最小复现卡片 弹层共享 layoutId 且同时缩放计划把复现代码内联在文档中因为 2023 年的外部在线 Demo 链接大概率已失效结构非常简单两个元素共享同一个layoutId其中“出现”的那个元素被渲染进 Portal 且同时播放 scale 动画。// 卡片列表中的共享元素 // motion.div layoutId{dragon.name} Card .../ /motion.div // 点击后通过 createPortal 渲染的 Modal const Modal ({ dragon, toggleModal }) createPortal( ModalOverlay onClick{toggleModal} motion.div onClick{(e) e.stopPropagation()} layoutId{dragon.name} animate{{ scale: 1.5 }} CardModal dragon{dragon} / /motion.div /ModalOverlay, modalRoot )复现判据Bug 特征卡片 → Modal 交叉淡化期间元素视觉缩放明显越过 1.5例如峰值达到 1.8 以上而布局动画完成时又回落正确去掉layoutId或改用关键帧规避写法后行为正常Portal 不是必要条件a portal is optional — same projection path只是为了贴近真实 Modal 用法。仓库现有的 HTML 演示目录里有大量layoutId/共享元素过渡的参考实现例如 shared-element-crossfade.html、shared-element-a-b-a.html以及 React 侧的 Shared-layout-continuity-crossfade.tsx、Shared-layout-lightbox-crossfade.tsx可以对照理解交叉淡化与共享元素的正常行为基线。三、源码级机制剖析为什么会出现“乘法过冲”这一节是整个 triage 计划最有价值的部分 —— 它在基线提交42bfbe3ed上对照源码验证了过冲的完整链路。3.1 投影引擎测量布局时会先“剥掉”元素当前 transform当投影系统测量 Modal 的布局时它并不会直接使用浏览器报告的原生包围盒而是先利用元素的latestValuesmotion 值的最新解析结果把元素当前的 transform从测量盒中反向移除得到“无变换”的布局盒。核心入口是 create-projection-node.ts 中的removeTransformremoveTransform(box: Box): Box { const boxWithoutTransform createBox() copyBoxInto(boxWithoutTransform, box) for (let i 0; i this.path.length; i) { const node this.path[i] if (!hasTransform(node.latestValues)) continue let sourceBox: Box | undefined if (node.instance) { hasScale(node.latestValues) node.updateSnapshot() sourceBox createBox() copyBoxInto(sourceBox, node.measurePageBox()) } removeBoxTransforms( boxWithoutTransform, node.latestValues, node.snapshot?.layoutBox, sourceBox ) } if (hasTransform(this.latestValues)) { removeBoxTransforms(boxWithoutTransform, this.latestValues) } return boxWithoutTransform }注意最后的这段判断——只要hasTransform(this.latestValues)为真就用removeBoxTransforms把自身变换从测量盒中剥离计划中摘录的正是这段if (hasTransform(this.latestValues)) { removeBoxTransforms(boxWithoutTransform, this.latestValues) }3.2 removeBoxTransforms用 latestValues 的 scale 除以测量盒反向剥离变换的具体实现位于 delta-remove.ts 的removeBoxTransforms它会分别处理 x/y 两个轴每个轴由removeAxisTransforms转交removeAxisDeltaexport function removeBoxTransforms( box: Box, transforms: ResolvedValues, originBox?: Box, sourceBox?: Box ): void { removeAxisTransforms(box.x, transforms, xKeys, originBox ? originBox.x : undefined, sourceBox ? sourceBox.x : undefined) removeAxisTransforms(box.y, transforms, yKeys, originBox ? originBox.y : undefined, sourceBox ? sourceBox.y : undefined) }其中xKeys [x, scaleX, originX]、yKeys [y, scaleY, originY]也就是用latestValues中的平移、缩放和变换原点做反向运算。removeAxisDeltadelta-remove.ts在未显式指定原点时默认以origin 0.5元素中心为缩放轴心export function removeAxisDelta( axis: Axis, translate: number | string 0, scale: number 1, origin: number 0.5, ...关键点在于这里的scale直接取自latestValues.scale以及scaleX/scaleY它代表“那一刻的实时动画值”而不是动画的目标值。3.3 双系统相乘过冲的数学本质现在把整条链路串起来这正是计划中verified in source的机制结论投影系统在测量/每帧校正时会用当前运动中、变化着的latestValues.scale去剥除/校正目标盒removeTransform→removeBoxTransforms常规动画值管线在同一时间用真实的 DOM transform 把元素持续放大问题就出在两个系统共享同一个实时 scale但使用时机不同盒子被测量的瞬间与之后每一个投影帧之间latestValues.scale一直在变。于是投影目标盒使用的是一个陈旧/移动中的 scale 校正值而真实 DOM 上的 transform 在它下面继续动画两个系统对 scale 的处理相乘投影校正 × 真实 transform视觉上就表现为远远超过 1.5 的瞬时缩放当两个系统最终都稳定下来动画结束、布局 settle最终布局值收敛正确于是画面“啪”地回到目标状态——这就是用户观察到的snaps correct at the end。换句话说过冲不是单个动画的错误而是投影校正使用实时 scale与真实 DOM transform 同时动画两者叠加的乘积效应。作为旁证仓库的机构记忆institutional memory在 issue #3356 中记录过同类问题的“兄弟版本”对已缩放父级做共享布局动画时removeBoxTransforms只能看到被追踪的 motion 值及其originX/originY永远看不到原始 CSS 状态——同样的“测量侧信息不完整”根源。这也再次说明问题属于投影引擎的架构边界而非一处简单笔误。3.4 layoutId 交叉淡化在底层如何工作补齐投影栈视角为了让机制更完整值得补充投影栈shared layout stack的实现证据stack.ts 中的NodeStack负责维护同layoutId的所有成员promote决定谁是当前“领导者”leadpromote(node: IProjectionNode, preserveFollowOpacity?: boolean) { const prevLead this.lead if (node prevLead) return this.prevLead prevLead this.lead node node.show() if (prevLead) { prevLead.updateSnapshot() node.scheduleRender() // 相同 layoutId 且 layoutDependency 未变 → 复用上一成员的快照 if (prevDep undefined || prevDep ! nextDep) { node.resumeFrom prevLead if (preserveFollowOpacity) prevLead.preserveOpacity true if (prevLead.snapshot) { node.snapshot prevLead.snapshot node.snapshot.latestValues prevLead.animationValues || prevLead.latestValues } if (node.root?.isUpdating) node.isLayoutDirty true } if (node.options.crossfade false) prevLead.hide() } }从这段实现可以推断共享元素通过快照snapshot接力实现位置/尺寸的连续感新成员从旧成员的snapshot起步然后投影插值到自己的新布局默认crossfade: true时create-projection-node.ts 的setOptions默认值共享动画期间旧成员会以透明度淡化过渡交叉淡化的透明度判定见 create-projection-node.ts若crossfade false旧成员被直接隐藏prevLead.hide()。因此layoutId动画本质上同时在做三件事投影插值位置/尺寸 旧成员透明度淡化 本例额外的元素自身 scale 动画。第三件事与前两者叠加时就触发了 3.3 的乘法过冲。四、为什么scale: [null, null, null, 1.5]是有效的规避方案reporter 在 issue 评论中找到的关键帧写法animate{{ scale: [null, null, null, 1.5] }}计划的解释非常精确这段关键帧把 scale 在动画的 75% 时间内钉在当前值null表示沿用当前值让投影动画基本跑完然后 scale 才开始移动。时序上两者从“同时竞争”变成“先后错开”乘法叠加的窗口被消除过冲自然消失。换句话说null关键帧 “保持当前值不动”占前 3/4 的时长最后 1/4 才真正把 scale 从当前值推向 1.5此时投影动画已接近收敛校正使用的 scale 与真实 transform 不再产生显著的乘积漂移。计划同时给出了两条等价的可选方案给 scale 动画加 delay延迟到布局动画基本完成后才开始改为通过 layout 自身驱动缩放例如动画 width/height让缩放由投影引擎统一处理而不是独立的 transform 动画。而“通用修复”意味着投影引擎必须能在动画进行中补偿 in-flight 的 transform 动画——这是一个显著的架构级改动significant architectural change计划明确表态不会从这个 issue 出发去规划它。五、Step 1本地 fixture 复现与量化验证计划的执行核心是“先在当前 main 上复现并给出量化峰值”而不是直接下结论创建本地 fixture计划建议在 dev/react/src/tests/ 目录新建layout-shared-scale-overshoot.tsx导出App复刻上文 JSX小卡片持有layoutIdcard点击后渲染一个居中的 fixed 定位motion.div layoutIdcard animate{{ scale: 1.5 }} transition{{ duration: 2, ease: linear }}Portal 可选投影路径一致通过 Vite dev server 运行仓库 dev 目录的 package.json 提供依赖cd dev/react yarn vite --port 9990然后打开http://localhost:9990/?testlayout-shared-scale-overshoot进行目视观察 3.量化测量在动画中途采样getBoundingClientRect().width / offsetWidth确认其明显超过 1.5计划给出的参考阈值 1.8。验证判据给出 yes/no 结论并附上测量到的峰值比率。如果当前 main 上没有复现则要如实记录——此时处置建议翻转按“已修复 / 不可复现”处理关闭时的state_reasoncompleted只有在能定位到具体修复提交时才适用否则使用not_planned并注明cannot reproduce on motion12。这也体现了仓库 no repro → no fix 的一贯策略plans/issues/README.md 中多处以 repro first 为前置条件。六、Step 2可选把当前行为固化为 characterization spec如果复现干净、且你想要一个可追踪的产物可以按 CLAUDE.md 的约定补一条 Cypress 规格计划建议位置packages/framer-motion/cypress/integration/layout-shared-scale-overshoot.ts该目录下已有大量集成规格可参考必须同时覆盖 React 18 与 React 19仓库分别有cypress.json与cypress.react-19.json两套配置不要把它标成失败门禁failing gate——因为没有修复计划允许用一个带注释链接到本 issue的.skip规格来记录行为如果这条规格不能带来额外信号整步可以跳过。这条规格的定位是characterization刻画现状而不是regression防止回归它的读者是未来试图动投影引擎的人而非 CI 门禁。七、Step 3在 issue 上报告结论并走 gated close这是整个计划的最终产出并且是门禁gated步骤只有当 plans/issues/README.md 中本计划的 status 行被标记为APPROVED之后才允许发布结论并关闭 issue。仓库对关闭动作的约定是 Close actions are ALWAYS gated。报告评论的完整模板计划原文可直接复用gh api repos/motiondivision/motion/issues/2284/comments -f bodyTriage update: this is a known architectural interaction between layout (layoutId) projection and a simultaneously-animating scale value. When the projection system measures the modal it removes the elements current transform using the live scale value; while scale is itself mid-animation, that correction and the real transform drift apart and multiply, which is the overshoot you saw — it snaps correct once both animations settle. The keyframe workaround you found (scale: [null, null, null, 1.5]) is the recommended pattern: it delays the scale change until the layout animation has mostly completed. Equivalent options: animate scale with a delay, or scale via layout itself (animate width/height). A general fix requires the projection engine to compensate for in-flight transform animations, which is a significant architectural change were not planning from this issue. Closing as a documented limitation; result of Step 1: still reproduces on motion12 as described / no longer reproduces on motion12. gh api -X PATCH repos/motiondivision/motion/issues/2284 -f stateclosed -f state_reasonnot_planned执行要点发布前必须把result of Step 1占位符替换为 Step 1 的真实结论仍按描述在 motion12 复现 或 在 motion12 上不再复现关闭原因使用not_planned除非能定位到具体修复提交才适合completed验证gh api repos/motiondivision/motion/issues/2284 --jq .state应返回closed仓库注意事项gh pr edit在此仓库不可用Projects Classic GraphQL 弃用所致PR/issue 的编辑一律用gh api -X PATCH。八、完成标准与 STOP 条件8.1 Done criteria完成标准Step 1 已执行记录 reproduce / no-reproduce 结论与峰值缩放测量值packages/motion-dom/src/projection/**无任何改动git status在该目录下保持干净评论与关闭动作仅在 APPROVED 门禁下执行且占位符已填写plans/issues/README.md 中的 status 行已更新。8.2 STOP conditions必须立即停止的情形出现想“顺手改投影源码去补偿动画中 transform”的冲动——这是明确的越界行为只允许报告发现不允许动手fixture 表现出不同的失败形态例如根本没有交叉淡化——那属于另一个独立 issue报告即可不要扩大范围摘录的投影代码发生漂移对照 drift check 结果不一致即 STOP提醒PR 编辑一律走gh api -X PATCH不要用gh pr edit。九、维护备注与后续方向计划在 Maintenance notes 中留下了一个明确的“重访条件”值得作为读者关注点如果未来effects/VisualElement 统一重构仓库记忆中的 effects slot-model 重构方向落地届时应重新评估“transform 动画感知的投影”是否变得可行重访时本 issue#2284与 issue #3356 就是现成的两条测试用例。从当前仓库源码结构看投影引擎集中在 packages/motion-dom/src/projection/ 下此前 framer-motion 中的投影节点副本已在提交67365fb75移入 motion-dom因此任何未来修复的落点都应在 motion-dom 的投影模块——这也解释了为何本 triage 计划把“不动packages/motion-dom/src/projection/**”作为完成标准与 STOP 条件的双重红线。结语这个 case 教会我们什么issue-2284 的价值不在“修复”而在一次教科书式的源码级三分类先确认问题真实存在且属于架构边界投影校正用实时 scale × 真实 transform 动画的乘积效应再确认存在令人满意的规避方案时序错开的 keyframe 写法最后以documented limitation not_planned的方式干净地收尾并把未来修复的可行条件写进维护备注。对于任何正在使用layoutId 独立 transform 动画组合的开发者本文给出的三条操作建议可直接落地共享布局动画期间避免让同一元素的 scale/x/y 以独立动画同时播放必须组合时使用scale: [null, null, null, 1.5]这类“先钉住、后移动”的关键帧或给 transform 动画加 delay最稳妥的做法是把缩放交给 layout 本身动画 width/height让投影引擎统一负责变换。赞分享前端UI组件【免费下载链接】motionA modern animation library for React and JavaScript项目地址https://gitcode.com/GitHub_Trending/mo/motion点击查看免费下载相关推荐Motion 中 display: none 动画失效问题的根因分析与 v11.2.0 修复方案issue-2563 复盘Motion 中 display: none 动画失效问题的根因分析与 v11.2.0 修复方案issue 2563 复盘 本文以仓库内的 plans/前端UI组件Motion 仓库 issue-2514 计划剖析垂直 layoutId 错位问题的复现与并入 issue-1935 的合并方案Motion 仓库 issue 2514 计划剖析垂直 layoutId 错位问题的复现与并入 issue 1935 的合并方案 本文围绕 plans/iss前端UI组件Motion 项目 issue-3243 复盘AnimatePresence 中途卸载导致 exit 卡死与 PR 3707 修复路径Motion 项目 issue 3243 复盘AnimatePresence 中途卸载导致 exit 卡死与 PR 3707 修复路径 导读 本文基于 Mot前端UI组件上一篇终极指南如何用VizTracer可视化调试Python异步代码下一篇Path of Building PoE2珠宝系统完全解析从底层机制到高级配置的终极指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考