Vue 基础体系 · 第 32/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue Transition 与 TransitionGroup:进入离开、列表动画和性能

在 Vue 3 中,<Transition><TransitionGroup> 都用于把“状态变化”转换为“DOM 变化过程中的视觉效果”。

两者解决的问题不同:

  • Transition:处理一个元素、一个组件,或一个动态组件的进入与离开。
  • TransitionGroup:处理一组带有唯一 key 的列表项,包括列表项的进入、离开和位置移动。

它们并不负责定义动画本身。Vue 负责在正确的生命周期阶段添加、移除 CSS 类,或调用 JavaScript 钩子;真正的动画通常由 CSS transition、CSS animation 或 JavaScript 动画库完成。


一、进入和离开动画的前提:状态变化必须产生 DOM 变化

动画的触发源不是“点击按钮”,而是响应式状态变化导致的条件渲染变化。

例如:

<script setup lang="ts">
import { ref } from 'vue'

const visible = ref(false)
</script>

<template>
  <button @click="visible = !visible">
    {{ visible ? '隐藏' : '显示' }}
  </button>

  <p v-if="visible">这段内容会进入或离开。</p>
</template>

visiblefalse 变为 true 时,p 元素被创建;从 true 变为 false 时,元素被移除。Vue 的 <Transition> 正是介入这两个过程:

<Transition name="fade">
  <p v-if="visible">这段内容会进入或离开。</p>
</Transition>

这里的 name="fade" 会决定 Vue 使用哪些 CSS 类名。它不会自动产生淡入淡出效果,仍然需要定义 CSS。


二、Transition 的进入与离开阶段

对于 name="fade"<Transition>,Vue 3 会使用以下类:

阶段 CSS 类 作用
进入初始状态 .fade-enter-from 元素刚插入时的状态
进入活动状态 .fade-enter-active 进入过程持续期间存在
进入结束状态 .fade-enter-to 元素进入完成时的状态
离开初始状态 .fade-leave-from 元素开始离开时的状态
离开活动状态 .fade-leave-active 离开过程持续期间存在
离开结束状态 .fade-leave-to 元素即将被移除时的状态

一个完整的淡入淡出示例:

<style scoped>
.fade-enter-active,
.fade-leave-active {
  transition:
    opacity 180ms ease,
    transform 180ms ease;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
  transform: translateY(8px);
}

.fade-enter-to,
.fade-leave-from {
  opacity: 1;
  transform: translateY(0);
}
</style>

进入过程可以理解为:

插入元素
  ↓
fade-enter-from + fade-enter-active
  ↓ 下一帧
移除 fade-enter-from,加入 fade-enter-to
  ↓ transition 完成
移除全部 enter 类

离开过程则是:

开始离开
  ↓
fade-leave-from + fade-leave-active
  ↓ 下一帧
移除 fade-leave-from,加入 fade-leave-to
  ↓ transition 完成
移除元素

transition 属性通常写在 *-enter-active*-leave-active 上,因为这两个类覆盖整个动画期间。

进入和离开的同时性

默认情况下,旧节点离开和新节点进入可能同时发生。例如:

<Transition name="fade">
  <p v-if="visible" key="shown">显示状态</p>
  <p v-else key="hidden">隐藏状态</p>
</Transition>

同一时刻只能有一个子节点,但两个节点切换时,默认模式允许离开和进入重叠。对于简单淡入淡出,这通常是合理的;对于页面或面板切换,重叠可能导致两个内容同时占据布局空间。


三、mode:控制旧节点和新节点的先后关系

<Transition> 支持三种常用模式。

1. 默认模式:同时进入和离开

<Transition name="fade">
  <component :is="currentComponent" />
</Transition>

旧组件开始离开的同时,新组件开始进入。

2. out-in:先离开,再进入

<Transition name="fade" mode="out-in">
  <component :is="currentComponent" />
</Transition>

流程是:

旧组件 leave
  ↓ 完成
旧组件从 DOM 移除
  ↓
新组件 enter

适合内容区域切换,避免新旧内容重叠。代价是切换期间会出现一个短暂的空档。

3. in-out:先进入,再离开

<Transition name="fade" mode="in-out">
  <component :is="currentComponent" />
</Transition>

流程相反:

新组件 enter
  ↓ 完成
旧组件 leave

适合希望内容连续存在、不出现空白的场景,但新旧内容可能暂时同时占据空间。

mode 只适用于 <Transition>,不适用于 <TransitionGroup>。列表中有多个子节点,每个子节点的进入、离开和移动需要独立协调,因此不存在“整个列表先离开再进入”的单一模式。


四、完整的 Vue 3 + TypeScript 示例

下面的组件同时演示:

  • 单个元素的进入和离开;
  • mode="out-in" 的内容切换;
  • 列表项进入和离开;
  • 列表顺序变化时的位置动画;
  • v-show 场景;
  • 稳定的列表 key
<script setup lang="ts">
import { computed, ref } from 'vue'

type Item = {
  id: number
  label: string
}

const visible = ref(true)
const activePanel = ref<'a' | 'b'>('a')
const nextId = ref(4)

const items = ref<Item[]>([
  { id: 1, label: '编译' },
  { id: 2, label: '测试' },
  { id: 3, label: '部署' },
])

const reversed = ref(false)

const displayedItems = computed(() => {
  return reversed.value ? [...items.value].reverse() : items.value
})

function addItem() {
  const id = nextId.value++

  items.value.push({
    id,
    label: `新任务 ${id}`,
  })
}

function removeItem(id: number) {
  items.value = items.value.filter(item => item.id !== id)
}

function reverseItems() {
  reversed.value = !reversed.value
}
</script>

<template>
  <main class="demo">
    <section>
      <h2>单元素进入与离开</h2>

      <button @click="visible = !visible">
        {{ visible ? '隐藏提示' : '显示提示' }}
      </button>

      <Transition name="fade">
        <p v-if="visible" class="notice">
          操作已完成
        </p>
      </Transition>
    </section>

    <section>
      <h2>组件内容切换</h2>

      <button @click="activePanel = activePanel === 'a' ? 'b' : 'a'">
        切换面板
      </button>

      <Transition name="panel" mode="out-in">
        <article v-if="activePanel === 'a'" key="panel-a" class="panel">
          面板 A
        </article>

        <article v-else key="panel-b" class="panel">
          面板 B
        </article>
      </Transition>
    </section>

    <section>
      <h2>列表进入、离开和移动</h2>

      <div class="actions">
        <button @click="addItem">添加</button>
        <button @click="reverseItems">反转顺序</button>
      </div>

      <TransitionGroup
        name="rows"
        tag="ul"
        class="rows"
      >
        <li
          v-for="item in displayedItems"
          :key="item.id"
          class="row"
        >
          <span>{{ item.label }}</span>
          <button @click="removeItem(item.id)">删除</button>
        </li>
      </TransitionGroup>
    </section>

    <section>
      <h2>v-show</h2>

      <button @click="visible = !visible">
        切换 display
      </button>

      <Transition name="fade">
        <p v-show="visible" class="notice">
          v-show 不销毁节点,只切换显示状态
        </p>
      </Transition>
    </section>
  </main>
</template>

<style scoped>
.demo {
  max-width: 640px;
  margin: 40px auto;
  font-family: sans-serif;
}

section {
  margin-block: 32px;
}

button {
  cursor: pointer;
}

.notice,
.panel {
  margin-top: 16px;
  padding: 12px 16px;
  border: 1px solid #cbd5e1;
  border-radius: 8px;
}

.fade-enter-active,
.fade-leave-active {
  transition:
    opacity 180ms ease,
    transform 180ms ease;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
  transform: translateY(8px);
}

.panel-enter-active,
.panel-leave-active {
  transition:
    opacity 220ms ease,
    transform 220ms ease;
}

.panel-enter-from {
  opacity: 0;
  transform: translateX(16px);
}

.panel-leave-to {
  opacity: 0;
  transform: translateX(-16px);
}

.rows {
  position: relative;
  padding: 0;
  list-style: none;
}

.row {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-block: 8px;
  padding: 10px 12px;
  border: 1px solid #cbd5e1;
  border-radius: 8px;
  background: white;
}

/* 进入和离开 */
.rows-enter-active,
.rows-leave-active {
  transition:
    opacity 200ms ease,
    transform 200ms ease;
}

.rows-enter-from,
.rows-leave-to {
  opacity: 0;
  transform: translateX(20px);
}

/*
 * 离开的元素脱离普通流,其他元素才能立即移动到目标位置。
 * 对垂直列表尤其常见。
 */
.rows-leave-active {
  position: absolute;
  width: 100%;
}

/* 列表位置移动 */
.rows-move {
  transition: transform 250ms ease;
}
</style>

运行前提是已有一个 Vue 3 + Vite + TypeScript 项目,例如使用官方脚手架创建的项目。将代码放入 src/App.vue 后启动开发服务器,预期结果是:

  1. “操作已完成”在创建和销毁时淡入淡出;
  2. 面板切换时旧面板完成离开后新面板才进入;
  3. 添加和删除列表项有进入、离开动画;
  4. 反转列表时,未被删除的项目会平滑移动到新位置;
  5. v-show 对应的节点仍然存在,只改变其显示状态。

五、v-ifv-show 的动画差异

v-if:创建和销毁节点

<Transition name="fade">
  <div v-if="visible">内容</div>
</Transition>

v-if 为真时创建节点,为假时执行离开过程,离开动画结束后从 DOM 中移除。

因此它适合:

  • 内容确实不需要长期存在;
  • 组件创建和销毁成本可以接受;
  • 希望离开后释放 DOM 和组件资源。

v-show:保留节点,只切换显示状态

<Transition name="fade">
  <div v-show="visible">内容</div>
</Transition>

v-show 通常通过修改 display 控制显示状态。Vue 仍然会调用进入和离开过渡,但节点不会被销毁。

因此它适合:

  • 频繁显示和隐藏;
  • 组件初始化成本较高;
  • 需要保留输入内容、内部状态或 DOM 状态。

需要注意,v-show 的“隐藏”最终仍然涉及 display: none。如果内容需要在隐藏期间继续参与布局,v-show 并不能满足要求,应使用其他样式状态控制。


六、为什么列表必须使用稳定且唯一的 key

TransitionGroup 的直接子节点必须能够被 Vue 稳定识别。最重要的条件是:每个列表项都应有在其生命周期内保持稳定的唯一 key

正确做法:

<TransitionGroup name="rows" tag="ul">
  <li
    v-for="item in items"
    :key="item.id"
  >
    {{ item.label }}
  </li>
</TransitionGroup>

常见错误是使用数组索引:

<li
  v-for="(item, index) in items"
  :key="index"
>
  {{ item.label }}
</li>

考虑初始列表:

索引 0 → A
索引 1 → B
索引 2 → C

删除 A 后:

索引 0 → B
索引 1 → C

如果使用索引作为 key,Vue 会认为:

旧 key 0 的节点仍然是新 key 0
旧 key 1 的节点仍然是新 key 1

于是它倾向于复用节点并更新文本,而不是识别出“A 离开、B 和 C 向前移动”。这会导致:

  • 离开动画作用到错误的内容;
  • 输入框内部值和列表项错位;
  • 组件本地状态被错误复用;
  • 移动动画不符合用户看到的数据变化。

key 的意义不是“给循环变量编号”,而是声明“这个 DOM 节点代表哪个业务实体”。因此,数据库主键、客户端生成的唯一 ID 通常比索引更合适。


七、TransitionGroup 的三类动画

一个列表变化可能同时包含三种不同的变化。

1. 进入动画

新列表项出现时使用:

.rows-enter-from
.rows-enter-active
.rows-enter-to

2. 离开动画

列表项从数据中移除时使用:

.rows-leave-from
.rows-leave-active
.rows-leave-to

3. 移动动画

已有列表项的位置发生变化时使用:

.rows-move

移动动画并不是通过给每个元素设置 topleft 完成的。Vue 会在列表更新前后记录元素位置,然后通过变换把元素从旧位置“反向抵消”,再将 transform 过渡到目标位置。这类技术通常称为 FLIP:

  • First:记录更新前的位置;
  • Last:记录更新后的目标位置;
  • Invert:用反向 transform 把元素视觉上放回旧位置;
  • Play:过渡到 transform: none

设某元素更新前的垂直位置是 yoldy_\text{old},更新后的目标位置是 ynewy_\text{new},则需要补偿的位移可以表示为:

Δy=yoldynew\Delta y = y_\text{old} - y_\text{new}

如果元素实际向下移动了 40 像素,Vue 可以先让它保持 translateY(-40px) 的视觉位置,再把这个变换过渡到 translateY(0)。用户看到的就是元素从旧位置平滑移动到新位置。

这解释了两个重要边界:

  1. key 必须正确,否则 Vue 无法判断哪个节点是“同一个元素”;
  2. 移动动画依赖真实布局位置,复杂嵌套布局、动态高度和绝对定位可能需要额外 CSS 或 JavaScript 协调。

八、为什么离开项经常需要 position: absolute

假设垂直列表中删除中间项:

A
B  ← 删除
C

如果 B 执行离开动画期间仍处于普通文档流中,C 在动画结束前通常不会立即移动到 B 的位置。这样用户看到的是:

  1. B 淡出;
  2. B 完全消失;
  3. C 突然跳上来。

将离开项设置为绝对定位后:

.rows-leave-active {
  position: absolute;
}

B 在离开阶段脱离普通流,C 可以立即占据目标位置;随后 C 通过 .rows-move 平滑移动。

这不是所有布局都必须使用的固定规则:

  • 如果离开动画本身不要求其他元素同时移动,可以不设置;
  • 如果列表项高度不固定,绝对定位后通常还需要处理宽度和高度;
  • 网格、表格和复杂 flex 布局不能直接照搬垂直列表的 CSS。

九、TransitionGroup 的结构约束

TransitionGroup 与普通容器有几个不同点。

1. 默认不会强制生成包装元素

Vue 3 中,TransitionGroup 默认可以渲染为 fragment。如果需要明确的 HTML 容器,可以使用 tag

<TransitionGroup name="rows" tag="ul">
  <li v-for="item in items" :key="item.id">
    {{ item.label }}
  </li>
</TransitionGroup>

这里生成的容器是 ul,直接子节点是 li

2. 直接子节点需要稳定 key

key 应写在 v-for 产生的直接子节点上,而不是只写在内部元素上:

<!-- 推荐 -->
<TransitionGroup name="rows">
  <div
    v-for="item in items"
    :key="item.id"
  >
    {{ item.label }}
  </div>
</TransitionGroup>

3. 列表项必须代表可区分的实体

如果两个项目的 key 重复,Vue 无法建立稳定的节点身份,可能出现运行时警告、状态错位或动画异常。唯一性不仅应在当前数组中成立,还应在相邻更新之间保持稳定。


十、JavaScript 钩子:动画由代码控制时必须完成回调

CSS 适合普通的透明度、位移和缩放效果。如果动画依赖测量高度、第三方动画库或复杂的时间线,可以使用 JavaScript 钩子。

<template>
  <Transition
    :css="false"
    @enter="enter"
    @leave="leave"
  >
    <div v-if="visible" class="box">
      内容
    </div>
  </Transition>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const visible = ref(true)

function enter(
  element: Element,
  done: () => void
) {
  const el = element as HTMLElement

  el.animate(
    [
      { opacity: 0, transform: 'scale(0.95)' },
      { opacity: 1, transform: 'scale(1)' },
    ],
    {
      duration: 220,
      easing: 'ease-out',
      fill: 'forwards',
    },
  ).finished
    .then(() => done())
    .catch(() => done())
}

function leave(
  element: Element,
  done: () => void
) {
  const el = element as HTMLElement

  el.animate(
    [
      { opacity: 1, transform: 'scale(1)' },
      { opacity: 0, transform: 'scale(0.95)' },
    ],
    {
      duration: 220,
      easing: 'ease-in',
      fill: 'forwards',
    },
  ).finished
    .then(() => done())
    .catch(() => done())
}
</script>

这里的关键点是:

  • :css="false" 告诉 Vue 不要等待 CSS 过渡;
  • 钩子接收 done 时,必须在动画完成或失败后调用它;
  • 如果不调用 done,Vue 可能一直认为节点处于过渡中,离开节点无法被正确移除;
  • 使用 finished 的 Promise 时,应处理失败路径,例如动画被取消或浏览器拒绝执行。

如果 JavaScript 钩子不接收 done,Vue 会把它视为同步钩子:

function beforeEnter(element: Element) {
  // 只执行同步准备工作
}

需要区分“准备阶段”和“完成阶段”。同步钩子适合设置初始样式,异步动画则应明确调用 done

CSS 和 JavaScript 混用

也可以保留 CSS 动画,同时使用钩子进行测量:

<Transition
  name="fade"
  @before-enter="prepare"
  @after-enter="finish"
>
  <div v-if="visible">内容</div>
</Transition>

如果钩子没有 done 参数,Vue 仍根据 CSS 过渡或动画的结束时间判断完成。CSS 声明的持续时间应与实际视觉效果一致,否则可能出现:

  • DOM 已被移除但视觉动画尚未结束;
  • 动画结束后节点仍短暂保留;
  • 多个 CSS 属性中某个属性结束更晚,导致判断不符合预期。

对于嵌套动画或 CSS 时间难以自动推断的情况,可以显式设置持续时间:

<Transition
  name="fade"
  :duration="{ enter: 300, leave: 500 }"
>
  <div v-if="visible">内容</div>
</Transition>

这里的数字单位是毫秒。它只控制 Vue 等待的时间,不会自动改变 CSS 动画的实际持续时间,因此两者应保持一致。


十一、生命周期钩子和取消路径

Transition 提供进入和离开的阶段钩子,例如:

<Transition
  name="fade"
  @before-enter="beforeEnter"
  @enter="enter"
  @after-enter="afterEnter"
  @enter-cancelled="enterCancelled"
  @before-leave="beforeLeave"
  @leave="leave"
  @after-leave="afterLeave"
  @leave-cancelled="leaveCancelled"
>
  <div v-if="visible">内容</div>
</Transition>

对应的状态变化可以表示为:

stateDiagram-v2
    [*] --> Hidden
    Hidden --> Entering: visible = true
    Entering --> Visible: enter 完成
    Entering --> Hidden: 进入被取消
    Visible --> Leaving: visible = false
    Leaving --> Hidden: leave 完成
    Leaving --> Visible: 离开被取消

“取消”不是普通的“动画完成”。例如:

  1. 元素开始进入;
  2. 用户很快再次触发状态切换;
  3. 元素还没进入完成,就开始离开。

此时应清理进入阶段设置的临时样式或动画实例。否则可能出现:

  • 元素残留 opacity: 0
  • Web Animations API 的旧动画继续覆盖新状态;
  • 多次触发后出现状态竞争。

如果使用第三方动画库,应在 enter-cancelledleave-cancelled 中停止旧动画,并确保新的钩子可以重新接管元素。


十二、动态组件与 key:决定是更新还是重新过渡

动态组件切换通常这样写:

<Transition name="fade" mode="out-in">
  <component
    :is="currentComponent"
    :key="currentComponent"
  />
</Transition>

key 的作用是明确告诉 Vue:组件类型变化时,这不是同一个节点的普通更新,而是一个节点离开、另一个节点进入。

如果没有合适的身份差异,Vue 可能复用同一个节点,只更新其内容。复用本身并不错误,但它不会必然触发你期待的进入和离开过渡。

同理,想让同一组件在参数变化时重新执行进入动画,也可以使用代表版本或实体身份的 key

<Transition name="fade" mode="out-in">
  <UserCard
    :key="userId"
    :user-id="userId"
  />
</Transition>

但不应无条件在每次渲染中生成随机 key。随机 key 会让 Vue 每次都销毁并重建节点,导致:

  • 本地状态丢失;
  • 输入焦点丢失;
  • 组件生命周期被频繁执行;
  • 动画和性能都变差。

十三、性能:动画属性、布局和列表规模

1. 优先动画化 transformopacity

下面这些属性通常更适合动画:

transition:
  opacity 180ms ease,
  transform 180ms ease;

原因是它们通常不需要重新计算其他元素的布局。相比之下,动画化以下属性可能触发布局或绘制成本:

height
width
top
left
margin
padding

这不是说这些属性绝对不能动画,而是它们更容易影响周围元素的位置,需要浏览器重新计算布局。

例如,列表高度随展开变化时:

.panel {
  transition: height 200ms ease;
}

如果高度从 auto 变为具体值,CSS 无法直接对 auto 做普通数值插值。此时通常需要:

  • 使用固定高度或 max-height
  • 使用 JavaScript 测量真实高度;
  • 使用 Web Animations API 或动画库控制数值。

2. TransitionGroup 不会减少 DOM 数量

TransitionGroup 只是协调列表节点的过渡,不是虚拟列表组件。对于大量数据:

  • 所有列表项仍然可能存在于 DOM;
  • 离开项在动画完成前仍然保留;
  • 位置移动需要读取和比较布局;
  • 复杂列表可能造成布局计算和绘制压力。

如果列表规模很大,应首先解决渲染规模问题,例如分页、窗口化或虚拟滚动,然后再对可见窗口中的项目使用有限的动画。不能期待给几千个 DOM 节点包上 TransitionGroup 就获得高性能。

3. 不要在移动动画中强制布局

FLIP 需要读取元素位置,但如果在读取布局后立刻大量修改会影响布局的样式,可能产生强制同步布局。自定义 JS 列表动画时,应避免反复执行:

element.getBoundingClientRect()
element.style.height = ...
element.offsetHeight
element.getBoundingClientRect()

更合理的做法是批量读取位置,再批量写入样式,并让浏览器在合适的帧中执行更新。Vue 内部的列表移动协调属于框架实现,不应依赖其未公开的内部细节;自定义动画代码应把测量和写入阶段明确分开。


十四、减少动态效果:尊重用户的系统设置

部分用户会在操作系统中启用“减少动态效果”。可以通过媒体查询降低或关闭动画:

@media (prefers-reduced-motion: reduce) {
  .fade-enter-active,
  .fade-leave-active,
  .panel-enter-active,
  .panel-leave-active,
  .rows-enter-active,
  .rows-leave-active,
  .rows-move {
    transition-duration: 1ms;
  }
}

这不会改变 Vue 的进入和离开生命周期,只是让视觉动画几乎立即完成。这样组件状态、DOM 生命周期和无障碍偏好之间仍然保持一致。

如果动画承担了重要信息,例如“保存中”到“保存成功”的状态提示,则不能只依赖颜色或位移动画传达语义,还应提供文本、ARIA 状态或其他可感知信息。


十五、常见失败表现与诊断方法

失败一:只有闪烁,没有动画

检查以下条件:

  1. 是否确实使用了 <Transition><TransitionGroup>
  2. name 与 CSS 类前缀是否一致;
  3. v-if 或列表数据是否真的发生变化;
  4. transition 是否写在 *-enter-active*-leave-active*-move 上;
  5. CSS 是否被其他规则覆盖;
  6. 持续时间是否为 0

例如:

<Transition name="fade">

必须对应:

.fade-enter-active { ... }
.fade-leave-active { ... }

写成 .transition-enter-active 不会匹配。

失败二:列表删除了错误的项目

优先检查 key。如果使用了数组索引,删除头部或中间项时最容易暴露问题。改为稳定的业务 ID:

:key="item.id"

同时确认 item.id 在列表中唯一,且不会因为排序或过滤而重新生成。

失败三:位置移动没有动画

检查:

.rows-move {
  transition: transform 250ms ease;
}

还应确认:

  • 列表顺序确实改变,而不是所有节点都被重新创建;
  • 子节点有稳定 key;
  • 元素的最终位置确实发生了变化;
  • 没有其他 CSS 覆盖 transform
  • 自定义样式没有让所有元素脱离正常布局。

失败四:离开钩子后节点一直不消失

通常是 JavaScript 钩子接收了 done,却没有调用:

function leave(element: Element, done: () => void) {
  // 必须在动画完成或失败后调用 done()
}

如果使用 Promise,应同时处理 resolve 和 reject,避免动画被取消后永远不结束。

失败五:mode="out-in" 切换时出现空白时间过长

检查 CSS 实际持续时间和 duration 是否一致。若 CSS 设置了 500ms,但 duration 设置为 1000,Vue 会额外等待;反过来则可能提前结束生命周期。

失败六:动画期间页面高度跳动

这通常不是 Vue 错误,而是布局策略的结果。常见原因是:

  • 离开节点仍在普通文档流中;
  • 进入和离开节点同时存在;
  • 列表项高度不一致;
  • 离开项设置了绝对定位但没有保留宽度;
  • 动画化了会影响布局的属性。

应根据目标效果选择 modeposition: absolute、固定宽度或其他布局方案,而不是只调整动画时长。


十六、规范保证、实现细节和工程取舍

可以明确区分三类结论。

Vue API 的规范能力

Vue 3 官方 API 保证了:

  • <Transition> 用于单个元素或组件的进入、离开过渡;
  • <TransitionGroup> 用于列表项的进入、离开和移动过渡;
  • 过渡类名、钩子、modeappearduration 等属于公开能力;
  • 列表项需要稳定的 key 才能正确识别节点身份。

常见实现机制

Vue 通过以下方式完成协调:

  • 在进入和离开的不同阶段切换 CSS 类;
  • 根据 CSS 过渡或动画结束时间判断完成;
  • 对列表项更新前后的位置进行测量,以支持移动过渡;
  • 在离开过渡结束前暂时保留离开节点。

具体的内部调度细节、DOM 测量时机和补丁算法属于实现细节,不应作为业务代码的依赖。

工程取舍

最终选择取决于交互目标:

  • 简单显示隐藏:Transition + opacity/transform
  • 内容面板互斥切换:mode="out-in"
  • 高频切换但需要保留状态:v-show
  • 可排序、可插入、可删除的列表:TransitionGroup + 稳定 key
  • 大规模数据:先控制 DOM 规模,再考虑动画;
  • 复杂时间线:JavaScript 钩子或专用动画库;
  • 用户启用减少动态效果:通过 prefers-reduced-motion 降低动画。

TransitionTransitionGroup 的核心不是“给页面加特效”,而是把响应式状态变化、节点身份、DOM 生命周期和视觉过渡连接起来。只要能准确回答“哪个节点被创建”“哪个节点被移除”“哪个节点只是移动”“Vue 何时认为过渡完成”,大多数进入、离开和列表动画问题都可以从机制层面定位。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。