airi 项目 Vue 3 列表动画实践:TransitionGroup 组件最佳实践与源码级实现解析
【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi
导读
<TransitionGroup>是 Vue 3 内置的列表过渡组件,专门用于处理一组子元素「进入、离开、移动」三类动画。本文以 component-transition-group.md 最佳实践指南为骨架,逐条讲解其使用边界、key规则、tag包装、mode禁用约束与基于 JavaScript 钩子的交错(stagger)动画,并结合 airi 仓库中 double-check-button.vue 与 beat-sync.vue 的真实实现,帮助你在自己的 Vue 3 应用中写出稳定、流畅、可维护的列表过渡。读完你既能避开最常见的踩坑点,也能看到生产级列表动画的落地写法。
适用范围:什么是 TransitionGroup,以及何时使用它
Vue 3 提供了两套内置过渡原语,选择依据非常明确:
<Transition>:针对单个元素/组件的进入与离开(enter/leave),可在单元素上应用mode="out-in"/mode="in-out"做进出时序编排;<TransitionGroup>:针对v-for渲染的一组列表项,除了进入、离开外,还额外支持移动(move)动画——当列表增删、排序导致兄弟节点相对位置变化时,Vue 会用 FLIP 思路平滑过渡。
实践指南给出一份直接可执行的任务清单,是使用<TransitionGroup>时的默认检查项:
- 只在列表与重复项场景使用
<TransitionGroup>; - 为每个直接子元素提供唯一且稳定的
key; - 需要语义化或布局类容器时通过
tag指定包装元素; - 不要使用
modeprop(它不被支持); - 交错(stagger)效果请用 JavaScript 钩子实现。
airi 仓库中每个 Vue 应用都遵循 Vue 3 + Composition API +<script setup>+ TypeScript 规范(见 vue-best-practices SKILL.md),而技能库在“动画相关特性”一节也将<TransitionGroup>定位为“animated list mutations(列表变更动画)”的唯一内置选项,与 component-transition.md(单元素过渡)、class-based / state-driven 动画技术互为补充。换句话说,遇到“列表项会增删或重排”的需求时,首选就是<TransitionGroup>。
只为列表使用 TransitionGroup
<TransitionGroup>是为重复渲染的列表项设计的,不应把毫无关系的固定兄弟组件塞进它。此时即使写了name,也不会得到预期动画,反而引入不必要的层级与约束。
错误示范(把两个固定组件当作过渡子项):
<template> <TransitionGroup name="fade"> <ComponentA /> <ComponentB /> </TransitionGroup> </template>正确示范(tag="ul"决定真实包装标签,v-for列表作为子项):
<template> <TransitionGroup name="list" tag="ul"> <li v-for="item in items" :key="item.id"> {{ item.name }} </li> </TransitionGroup> </template>关于tag属性需要补两点实现细节:
- 在 Vue 3 中,
tag已不是必填项——不传时<TransitionGroup>不会渲染多余的包裹元素,直接输出子节点列表;但当需要语义化标签(ul/ol/table)或需要一个确定布局的容器(如div+ flex/grid)时,显式传入tag是标准做法,上述真实实现也都传入了tag="div"。 - 传入的
tag元素上可以继续绑定 class/style 与普通 HTML 属性,Vue 会统一挂载到该包装元素上(attribute 继承规则仍然适用)。
仓库中的 double-check-button.vue 是一个“固定少数子项”的实战变体:它用<TransitionGroup name="double-check-slide" tag="div">包裹两个带显式key的按钮(key="cancel"与key="primary"),配合v-if="confirming"让取消按钮在确认模式下出现/消失,从而触发主按钮被“挤开”的移动动画。这说明<TransitionGroup>不必局限于超长列表,两个带 key 的兄弟元素构成的小型动态集合同样适用——关键在于子项必须是被key标识、可能进入或离开视图的成员,而不是互不相干的静态组件。
始终提供稳定且唯一的 key
key是<TransitionGroup>的硬性要求。没有稳定的 key,Vue 无法在多次渲染间追踪每个元素的身份:位置无法精确对应,移动动画自然失效,增删元素时还会发生复用错乱。
错误示范(用数组下标作 key):
<template> <TransitionGroup name="list" tag="ul"> <li v-for="(item, index) in items" :key="index"> {{ item.name }} </li> </TransitionGroup> </template>正确示范(用业务主键作 key):
<template> <TransitionGroup name="list" tag="ul"> <li v-for="item in items" :key="item.id"> {{ item.name }} </li> </TransitionGroup> </template>为什么下标 key 是灾难:以 index 为 key 时,元素身份跟着“位置”走而非跟着“数据”走。插入或删除头部元素会让后续所有元素的身份整体平移,Vue 会认为它们是被替换而非移动,导致 DOM 复用、过渡动画全部错位。选择 key 时应遵守:
- 唯一:同一次渲染内互不重复;
- 稳定:跨多次渲染保持不变(与业务实体绑定,如数据库 id、自增序号);
- 不被复用:删除后不再出现(避免使用“临时自增计数器”这种会增长的 key,会造成数组收缩时 key 错位)。
这一条与 Vue 通用列表渲染规范一致,也是仓库 vue-best-practices 中“模板安全(list rendering)”要求的一部分。
不要在 TransitionGroup 上使用 mode
mode="out-in"/mode="in-out"只属于<Transition>:它用于在同一时刻只能渲染一个元素、需要控制“旧元素离开后再进入新元素”的场景。而<TransitionGroup>的子元素是同时存在的,根本没有“替换单个元素”的语义,因此不提供mode——传了会被静默忽略,且并不会产生任何进/出排序效果。
错误示范(对组施加 mode,无效且产生误解):
<template> <TransitionGroup name="list" tag="div" mode="out-in"> <div v-for="item in items" :key="item.id">{{ item.name }}</div> </TransitionGroup> </template>正确示范(单元素切换的进出编排请用<Transition>,并通过:key强制视图重建):
<template> <Transition name="fade" mode="out-in"> <component :is="currentView" :key="currentView" /> </Transition> </template>实现依据:mode之所以能起作用,是因为<Transition>内部维护“旧元素销毁 / 新元素挂载”两个阶段;而<TransitionGroup>只关心同一集合内成员的增删移,渲染层并不存在“二选一”的时刻。如果你观察仓库中 double-check-button.vue 的“按钮互换”,会发现它并未依赖mode,而是用v-if+ 稳定 key 让两个按钮在集合中增删,再借助移动过渡与“离场元素脱离布局流”的 CSS 技巧(见后文“move 过渡与离场脱离布局流”)完成平滑换位——这正是 group 语义下实现进出编排的正确姿势。
用 JavaScript 钩子实现交错(stagger)动画
当需要“瀑布式”逐条入场时,<TransitionGroup>的 CSS class 方案不够灵活。最佳实践指南给出的标准做法是:关闭 CSS 控制(:css="false"),在@before-enter/@enter钩子中读取子项携带的索引,为每个元素计算不同的延迟。
原文档提供的完整示例:
<template> <TransitionGroup tag="ul" :css="false" @before-enter="onBeforeEnter" @enter="onEnter" > <li v-for="(item, index) in items" :key="item.id" :data-index="index"> {{ item.name }} </li> </TransitionGroup> </template> <script setup> function onBeforeEnter(el) { el.style.opacity = 0 el.style.transform = 'translateY(12px)' } function onEnter(el, done) { const delay = Number(el.dataset.index) * 80 setTimeout(() => { el.style.transition = 'all 0.25s ease' el.style.opacity = 1 el.style.transform = 'translateY(0)' setTimeout(done, 250) }, delay) } </script>逐行拆解这套写法背后的契约:
:css="false":告知 Vue 不再依赖 CSS 类判断过渡何时结束,enter钩子必须显式调用done()来声明动画完成,否则该元素会被认为永远处于过渡中;:data-index="index":把索引以 data 属性挂在元素上,钩子通过el.dataset.index读取——这是“模板只负责声明、逻辑负责计算延迟”的干净解耦;onBeforeEnter设置初始状态(透明、向下偏移),onEnter按index * 80ms递增延迟后播放到目标态,并在自身动画结束后setTimeout(done, 250)通知 Vue 收尾;- 为了视觉自然,延迟只应施加于入场路径;若同时需要离场交错,可继续在
@leave钩子中做反向处理。
airi 仓库中 beat-sync.vue 的“节拍可视化涟漪”模块正是这一模式的工程化版本。它使用:css="false"只监听@enter="onRippleEnter",为每个涟漪圆点渲染<div v-for="beat in beatsHistory" :key="beat.id" :data-beat-id="beat.id" />:
<TransitionGroup tag="div" ... :css="false" @enter="onRippleEnter" > <div v-for="beat in beatsHistory" :key="beat.id" :data-beat-id="beat.id" ... /> </TransitionGroup>对应的 onRippleEnter 从元素上取回业务标识el.dataset.beatId,用基于时间线的动画库让圆点从scale: 0, opacity: 1扩散到scale: 1, opacity: 0,在onComplete回调里找到对应项并从响应式数组beatsHistory中splice移除,最后调用done()完成整个过渡周期:
function onRippleEnter(el: Element, done: () => void) { const beatId = (el as HTMLElement).dataset.beatId createTimeline() .set(el, { opacity: 1, scale: 0 }) .add(el, { opacity: 0, scale: 1, duration: 2000, ease: 'out(5)', onComplete: () => { if (!beatId) return const idx = beatsHistory.value.findIndex(b => b.id === beatId) if (idx >= 0) beatsHistory.value.splice(idx, 1) done() }, }) }这个实现值得学习的两点设计:
- 数据驱动生命周期:动画结束后不是等待外部定时清空,而是在
done()之前同步从数组中移除该项——元素从 v-for 数据中被删除,DOM 才真正退场,动画与数据状态天然一致; - dataset 传参:与文档示例的
:data-index思路相同,把渲染期才可知的身份(id/index)通过 data 属性传入 JS 钩子,避免在闭包里捕获过期索引导致错删。
move 过渡与离场元素脱离布局流
进入/离开之外,<TransitionGroup>的独有能力是移动动画:当列表重排或某个元素离开导致其他成员位移时,Vue 会为每个“换了位置”的元素加上<name>-move类,你可以用它声明 transform 过渡,实现 FLIP 般的平滑滑动。
从 double-check-button.vue 的样式可以看到一个关键组合:
.double-check-slide-move { transition: transform 180ms ease; } .double-check-slide-enter-active, .double-check-slide-leave-active { transition: opacity 160ms ease, transform 160ms ease; } /* 让离场按钮脱离 flex 布局流,主按钮的位移才能被 TransitionGroup 捕获为 move 动画 */ .double-check-slide-leave-active { position: absolute; } .double-check-slide-enter-from, .double-check-slide-leave-to { opacity: 0; transform: translateX(-10px); }其中.double-check-slide-leave-active { position: absolute }是让 move 动画生效的经典技巧:默认情况下,正在“离开”的元素仍占据文档流空间,其余兄弟节点不会真正位移,FLIP 也就无从谈起;把它设为absolute后,离场元素被抽出布局流,主按钮的重新排布才成为一次可观测的“移动”,配合-move过渡产生顺滑的让位效果。仓库注释也明确说明了这一点:“Removing the cancel button from flex flow lets TransitionGroup animate the primary button's layout shift.”
因此一个完整的<TransitionGroup>过渡通常需要四段 CSS 声明:
| CSS 类 | 作用 | 触发时机 |
|---|---|---|
<name>-enter-from/<name>-enter-active/<name>-enter-to | 元素进入动画 | 新增子项时 |
<name>-leave-from/<name>-leave-active/<name>-leave-to | 元素离开动画 | 删除子项时 |
<name>-move | 位置迁移动画(一般只写 transition) | 兄弟节点因增删/重排而位移时 |
-leave-active { position: absolute } | 将离场元素移出布局流,使 move 动画可见 | 需要让位动画时 |
常见误区速查与相关参考
把本文所有约束收敛为一张自检表,可作为评审列表动画代码时的 check-list:
- 是否把固定、无关的兄弟组件放进了
<TransitionGroup>?——应只包裹会增删移的集合子项; - 每个直接子项是否有
key?key是否绑定业务唯一标识而非 index?——下标 key 会破坏移动追踪; - 是否给
<TransitionGroup>传了mode?——它只适用于<Transition>; - 需要列表之外的单元素进出编排?——改用
<Transition mode="out-in">并配合:key切换视图; - 逐条入场是否有延迟控制?——用
:css="false"+ JS 钩子 + dataset 索引/标识; - 删除后其余项是否“瞬移”而非滑动?——检查是否有
<name>-move过渡,以及离场元素是否需要position: absolute脱离布局流; - JS 钩子模式下是否每个分支都调用了
done()?——漏调会导致元素悬挂在过渡态。
想继续深入相邻主题,仓库内可直接阅读以下资源:
- 技能库动画决策:<.agents/skills/vue-best-practices/SKILL.md>(其中明确列出动画四选一的选型流程);
- 单元素过渡配套指南:component-transition.md;
- 非进出场动画:class-based 与 state-driven 两类技术的参考文档见 references 目录;
- 生产级实现样例:double-check-button.vue(多子项 + move 让位)与 beat-sync.vue(
:css="false"+ JS 钩子动画循环); - 注意区分命名陷阱:仓库 ui-transitions 包 中导出的
StageTransitionGroup是整套页面级路由转场编排器(配合 vue-router 的beforeEach与生命周期钩子系统运行),并非 Vue 内置<TransitionGroup>的封装——当你在依赖中看到以 Group 命名的组件时,务必先确认它到底是“列表过渡”还是别的过渡编排,避免误用。
【免费下载链接】airi💖🧸 Self hosted, you-owned Grok Companion, a container of souls of waifu, cyber livings to bring them into our worlds, wishing to achieve Neuro-sama's altitude. Capable of realtime voice chat, Minecraft, Factorio playing. Web / macOS / Windows supported.项目地址: https://gitcode.com/GitHub_Trending/ai/airi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考