` 非 root 目标解析:从零到自动 `view-transition-name` 的完整实现指南)
前端UI组件【免费下载链接】motionA modern animation library for React and JavaScript项目地址https://gitcode.com/GitHub_Trending/mo/motion点击查看免费下载本文围绕plans/002-view-transition-target-resolution.md这一实施计划展开讲解 motion 仓库中animateView()View Transitions API 集成如何补齐非 root 目标解析这一关键能力此前只有root目标可用用户给.get(.card, …)这类 selector/Element 目标传动画时必须手工在 CSS 中为每个元素写view-transition-name本计划落地后库会自动解析目标、自动分配并清理view-transition-name并配套单元测试与真实 Chromium E2E 测试。读完本文你将掌握animateView()的完整目标解析链路、生成命名的分配与清理规则、pair 形态shared-element的命名转移机制以及如何在当前仓库中构建、运行单测与 Playwright 验证。背景animateView()已公开但目标解析缺失animateView()是 motion 的 View Transitions API 集成入口它通过导出链packages/motion-dom/src/view/index.ts→packages/motion-dom/src/index.ts→packages/framer-motion/src/dom.tsexport * from motion-dom一路暴露到motion包属于已经发布的公共 API。然而在计划提出时2026-06-10commit42bfbe3ed其实现里埋着三个描述同一缺口的 TODOpackages/motion-dom/src/view/start.ts中// TODO: Go over existing targets and ensure they all have ids// TODO: Go over new targets and ensure they all have ids位于document.startViewTransition回调内// TODO: If target is not root, resolve elements and iterate over each三处 TODO 指向同一个事实除root外的目标从未被解析成真实元素元素也从未被自动赋予view-transition-name。后果是声明类型ViewTransitionTargetDefinition string | Element见 types.ts本应支持 selector 与 Element实际却被静默当作已命名的 layer处理——除非用户在 CSS 里手动为每个元素写好view-transition-name否则animateView(update).add(.card, {...})什么都不会发生。此外tests/目录下有animate/、animate-layout/、effects/、gestures/、scroll/五个区域唯独没有view/E2E 覆盖为零。本计划Priority P2、Effort M、Risk MED的目的正是让已经上船的 API 兑现它声明的类型表面。目标类型的现状与设计决策类型定义ViewTransitionTargetDefinition的完整类型types.tsexport type ViewTransitionTargetDefinition string | Element而每个目标对应的动画定义ViewTransitionTarget支持五个槽位types.tslayout控制 morphgroup 层的时序enter/exitpresence-gated按在场状态门控——enter只对纯新入元素新快照有、旧快照无生效exit只对纯离开元素生效两快照都在的 survivor 两者都不触发只做 morphnew/oldview-targeted不门控——只要对应视图存在就动画包括 survivor 的 new/old用于 crossfade 与 slide-through。三类目标的命名策略计划 Step 1 给出并最终被验证的设计也是当前 start.ts 的实际行为aselector 命中 1 个元素解析该元素生成一个view-transition-namebselector 命中 N 个元素每个元素各得一个生成的独立名字各自成为独立 layercElement目标直接解析该元素并生成名字。兼容性规则计划 Step 2 的硬性要求也是 E2E 回归用例看起来像已预命名 layer的字符串目标普通标识符如card保持原有行为——即当作既有层名直接使用不做解析与改名。CSS selector / Element 目标才走resolveElements 自动命名。核心实现start.ts的解析引擎双快照都要有同一个名字View Transition 有两张快照旧/新motion 需要保证同一个元素在两个快照中持有同一个名字才能作为单个group层做 morph。因此startViewAnimation在document.startViewTransition之前调用resolveLayers(old)命名并进入旧快照在更新回调内await update()之后调用resolveLayers(new)把update()新建的元素补命名进新快照。实现位于 start.tsconst nameRegistry new MapElement, string() const assigned: Element[] [] // ... const resolveLayers (phase: old | new) { targets.forEach((target, definition) { // root 与预命名 layer直接用 definition 本身作为名字 if (definition root || !resolveDefs.has(definition)) { names [definition as string] } else if (pairs.has(definition)) { // pair旧快照命名旧端新快照强制同名给新端 } else { names assignViewTransitionNames(definition as ElementOrSelector, ...) } // 每个名字登记 layerTargets / stagger 位置 / crop 覆盖 }) }nameRegistryElement → 名字的映射是关键状态同一个元素在两次resolveLayers中都会命中 registry从而复用同一名字assigned数组则记录所有被我们写入名字的元素供结束后清理。名字生成与跳过已命名逻辑名字生成与清理封装在独立的packages/motion-dom/src/view/utils/assign-names.ts计划特意要求把 element→name 分配抽成独立 helper方便未来 React 集成复用let nameCount 0 const generatedName () motion-view-${nameCount} const isGeneratedName (name: string) name.startsWith(motion-view-)生成名统一使用motion-view-N命名空间好处有二能区分我们拥有的名字必须清理与作者自定义名字不能碰被中断的过渡若遗留了旧motion-view-*下次会**重新占有re-own**而不是误当作作者名字泄漏出去。assignViewTransitionNames的跳过规则对应计划skip elements that already have a view-transition-name inline or computedconst current currentNames[i] if ( current current ! none current ! auto current ! match-element !isGeneratedName(current) ) { name current // 作者已命名原样复用交给作者清理 } else { name generatedName() // 未命名 / auto / match-element / 陈旧生成名重新生成 element.style.setProperty(view-transition-name, name) assigned.push(element) }注意auto/match-element被强制覆盖浏览器为它们生成的名字不暴露给脚本motion 无法用其定位 layer所以必须换成自己可控的名字。另一个细节是先批量读取、后批量写入实现中先把所有元素的当前计算样式读出来再逐个setProperty避免读写交错触发逐元素 style recalcassign-names.ts 注释说明了这一点。解析目标用resolveElementsselector / Element 的统一解析复用了packages/motion-dom/src/utils/resolve-elements.ts中现成的resolveElements(ElementOrSelector)——这正是LayoutAnimationBuilder在用的那个 helperexport function resolveElements(elementOrSelector, scope?, selectorCache?): Element[] { if (elementOrSelector null) return [] if (elementOrSelector instanceof EventTarget) return [elementOrSelector] if (typeof elementOrSelector string) return Array.from(root.querySelectorAll(elementOrSelector)) return Array.from(elementOrSelector).filter((el) el ! null) }它能接受Element | Element[] | NodeListOfElement | string | null | undefined所以 Element 目标直接进EventTarget分支返回单元素数组selector 走querySelectorAll。清理releaseViewTransitionNames过渡结束或失败、或被中断时transition.finished.finally(cleanup)触发清理start.tsexport function releaseViewTransitionNames(assigned, classed [], grouped []) { for (const element of assigned) element.style.removeProperty(view-transition-name) for (const element of classed) element.style.removeProperty(view-transition-class) for (const element of grouped) element.style.removeProperty(view-transition-group) }assigned只包含 motion 写入过名字的元素作者自己的名字绝不会被删classed单独跟踪.class()加过view-transition-class的元素与assigned分离保证清理类时不会误删作者内联的view-transition-namegrouped跟踪设过view-transition-group的元素。计划维护说明特别提醒 reviewer 审视这条清理路径生成名不得在中断过渡后泄漏interrupt: immediate路径见packages/motion-dom/src/view/queue.ts与ViewTransitionOptions。另外releaseViewTransitionNames被设计为可安全多次调用finished 与 interrupted 都可能触发。ViewTransitionBuilder链式 API目标如何被登记animateView(update, options)返回ViewTransitionBuilderindex.ts其内部用targets new MapViewTransitionTargetDefinition, ViewTransitionTarget()存储目标用resolveDefs new Set...()记录需要解析成元素的目标root不在其中。关键链式方法全部返回this以支持链式调用方法作用.add(subject, newSubject?)登记目标resolveDefs.add(subject)标记需解析第二个参数形成 pair共享元素 morph即使没有任何 bucket 也会注册空目标从而自动启用 layout/group morph.crop(true/false)强制开/关该目标的 cropclip object-fit: cover 动画圆角默认只在真正 morph 时自动裁剪.group(false)让该目标的 layer 保持扁平顶层view-transition-group: none不嵌套进 DOM 祖先 layer用于从卡片中飞出这类逃逸祖先裁剪的效果.class(name)给解析出的元素打view-transition-class让作者能用::view-transition-group(.name)这种不透明生成名之外的 CSS 选择器定位 layer.layout(options)自定义 morph 时序对隐式root目标等价于让页面整体参与过渡.enter/.exit/.new/.old(keyframes, options)设置四个动画槽位门控规则见上ViewTransitionOptions支持interrupt: wait | immediate默认wait。队列实现见packages/motion-dom/src/view/queue.tsbuilder 构造时addToQueue(this)入队immediate的动画会把前面所有wait动画的 update 函数批量合并进来并插队执行。动画如何匹配到生成的名字getViewAnimations与 layer 信息解析计划 Step 1 要求先读懂两条匹配链路start.ts的targets.forEach主体 getViewAnimationsgetViewAnimations()get-view-animations.ts从document.getAnimations()中筛出所有effect.target document.documentElement且pseudoElement以::view-transition开头的 WAAPI 动画——即浏览器为 view transition 生成的伪元素动画getViewAnimationLayerInfo(pseudoElement)get-layer-info.ts用正则从伪元素中拆出{ layer, type }const match pseudoElement.match( // group-children嵌套过渡排在 group 之前保证它先匹配 /::view-transition-(old|new|group-children|group|image-pair)\((.*?)\)/ ) return { layer: match[2], type: match[1] }layer就是view-transition-name现在可以是 motion 生成的motion-view-N。start.ts随后据此若该layer:type已被 motion 显式动画覆盖则取消浏览器生成的对应淡入淡出仅当两侧都是 opacity crossfade 时保留 UA 的plus-lighter混合避免叠加闪烁若只有一侧被覆盖孤儿半段取消另一侧的默认淡出否则.new({clipPath})这类非 opacity 的揭示动画会把旧视图溶解掉否则用effect.updateTiming(timing)把浏览器生成动画重排到 Motion 的时序上——这正是任何被解析/命名的目标自动获得 layoutgroupmorph的机制也是未显式覆盖的 old/new 层套用默认时序的路径。group 与 group-children 都跟随 layout 时序保证嵌套容器与 morph 同步morph 的 crossfade 两半必须共享完全相同的 delay 且线性缓动弹簧的反弹属于几何不属于透明度否则plus-lighter加色混合会在重叠处闪亮。Pair共享元素 morph的命名转移animateView(update).add(a, .modal)让两个不同元素共享一个名字、互相 morphshared-element transition反向传参即可 morph 回去。实现位于start.ts的 pair 分支旧快照解析旧端元素并命名把名字存入pairNames新快照先把名字从旧端元素上转移走removeProperty(view-transition-name)并从nameRegistry删除再强制把同名赋给新端元素。若不转移当旧端元素以visibility: hidden而非移除的方式存活到新快照时两个元素会撞名触发 duplicate view-transition-name。数量不匹配时assign-names.ts新端比旧端多多余元素没有 counterpart分一个全新名字作为 newcomer 动画而不是被静默留在无名状态新端比旧端少返回的名字数组按新端实际数量裁剪不会产生幻影 layer。回退分支没有startViewTransition的环境start.ts开头就做了特性检测计划 Step 3 要求单测覆盖这条路径if (!document.startViewTransition) { return (async () { await update() return new GroupAnimation([]) })() }JSDOM / Electron 没有startViewTransition这里用异步 IIFE而非new Promise(async ...)保证抛错的 update 会 reject 这个 promise 而不是让它悬空update 正常执行后解析为空GroupAnimation。配套单测runs the update and resolves with an element target验证了带 Element 目标时回退依然工作assign-names.test.ts。测试矩阵单元 真实 Chromium E2E计划要求 JSDOM 单测覆盖纯逻辑Playwright 跑真实 Chromium只有真浏览器有startViewTransition。单元测试packages/motion-dom/src/view/__tests__/assign-names.test.ts覆盖selector 命中 3 个元素 → 3 个唯一motion-view-N名字、assigned与 registry 各 3 条生成名以内联样式写入用 spy 验证setProperty(view-transition-name, name)同一元素多次调用复用同一名字且只入assigned一次pair 的新端被强制赋予旧端名字作者已命名getComputedStyle返回card→ 原样复用不进assignedauto/match-element浏览器内部名不暴露给脚本→ 被覆盖为生成名releaseViewTransitionNames移除所有生成名pair 新端多于旧端 → 多余元素获新名少于旧端 → 返回数组裁剪.class()的元素只清类、不清作者名字陈旧motion-view-999→ 重新占有生成新名字而非当作作者名泄漏。E2E 测试tests/view/view-targets.spec.ts结构参照tests/animate-layout/animate-layout.spec.ts包含三大用例及大量扩展用例全部以test.skip(!result.supported, No startViewTransition support)做特性守卫仅 Chromium 运行Element 目标fixturedev/html/public/playwright/view/view-target-element.htmlanimateView(...).add(box).enter({ opacity: [0, 1] }, ...)后断言document.getAnimations()中存在::view-transition-(new|group)(motion-view-N)伪元素动画多元素 selectorview-target-selector.htmlselector 命中 3 个元素 → 至少 3 个不同命名的 layer回归view-target-prenamed.html作者 CSS 已命名box→ 复用作者名断言无任何motion-view-N生成名出现行为与旧版完全一致。其余用例还验证了bare.add()自动启用 group morph、逐元素 stagger delay、aspect 变化自动 cropoverflow: clipobject-fit: cover 圆角动画、.crop(false)退出、enter/exit 不作用于 survivor、.class()可被::view-transition-group(.tag) { z-index: 99 }命中、x/y简写被跳过并警告、pair 双向 morph、crossfade 保留plus-lighter、孤儿淡出被取消、survivor 的 scale 从实时值而非 0.85 起点、嵌套 groupChromium 140等。fixture 页面通过window.__view全局对象向 spec 回传结果ready、pseudos、error等spec 用page.waitForFunction等待就绪后page.evaluate读取。验证命令从仓库根目录执行目的命令成功标准构建yarn buildexit 0motion-dom 单测npx jest --config packages/motion-dom/jest.config.json --testPathPatternview全部通过Playwright E2E真实 Chromiumnpx playwright test tests/view/全部通过含预命名回归用例Lintyarn lintexit 0注意Playwright 配置在playwright.config.tstestDir: ./tests、baseURL: http://localhost:8000/playwright/、自动启动webServer测试页面位于dev/html/public/playwright/spec 位于tests/area/name.spec.ts修改 motion-dom 源码后必须先 rebuild 再跑 E2E因为 fixture 消费的是构建产物。完成标准与边界计划的 Done criteria 为构建 exit 0grep -n TODO packages/motion-dom/src/view/start.ts无匹配三个 TODO 已删除view 相关 jest 全部通过npx playwright test tests/view/全部通过git status确认无 in-scope 之外的文件被改动plans/README.md状态行更新。明确的 out of scope计划禁止顺手做React 层集成AnimatePresence 驱动animateView——这是独立的更大设计问题但维护注释指出未来会直接构建在这套解析层之上因此 element→name 分配被刻意抽成独立 helperassign-names.ts供 React 复用getViewAnimations与types.global.ts的浏览器全局类型改动除非类型错误强制最小化补充那条animation-timing-function: linear !importantCSS 规则——它是 easing 策略先线性烘焙进关键帧、再靠updateTiming重排时序的组成部分不允许简化。当前仓库中的实际落地状态截至本仓库当前代码该计划描述的三处 TODO 已在packages/motion-dom/src/view/start.ts中由resolveLayersassignViewTransitionNames实现并删除utils/assign-names.ts、utils/choose-layer-type.ts、utils/css.ts、utils/has-target.ts等 helper 已就位view/__tests__/下已有assign-names.test.ts与queue.test.ts两套单测tests/view/view-targets.spec.ts已包含 20 个 E2E 用例dev/html/public/playwright/view/下存在view-target-element.html、view-target-selector.html、view-target-prenamed.html等 fixture。阅读路径建议先通读 计划原文再对照 start.ts、assign-names.ts 与 E2E spec 三份文件即可完整还原从设计到验证的闭环。需要说明的前提View Transitions APIdocument.startViewTransition目前仅在 Chromium 系浏览器可用本文所有相关行为均以该 API 存在为前提无 API 环境下 motion 走的是仅执行 update、返回空动画的回退分支。赞分享前端UI组件【免费下载链接】motionA modern animation library for React and JavaScript项目地址https://gitcode.com/GitHub_Trending/mo/motion点击查看免费下载相关推荐从零实现 MikroORM 自定义 DriverSQL 与非 SQL 驱动的完整开发指南从零实现 MikroORM 自定义 DriverSQL 与非 SQL 驱动的完整开发指南 MikroORM 是 Node.js 生态中基于 Data Mapp后端vanilla-extract createViewTransition API 详解为 CSS View Transitions 生成局部作用域的 view-transition-namevanilla extract createViewTransition API 详解为 CSS View Transitions 生成局部作用域的 view前端开发工具Vant Weapp动画完全指南从Transition组件到自定义动效实现Vant Weapp动画完全指南从Transition组件到自定义动效实现 你是否还在为小程序动画实现烦恼页面切换生硬、交互反馈不足、自定义动效复杂难调试前端小程序UI组件移动开发上一篇Win11Debloat让你的Windows系统重获新生的终极优化工具下一篇GetQzonehistory把 QQ 空间十年老说说一次搬回家的完整指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考