跳到主要内容

Transition 组件原理详解

在 Vue 里做动画时,很多同学会遇到两类“看起来很诡异”的问题:

  1. 写了 transition: all .3s,但就是不动
  2. 动画动是动了,但元素一闪就没了/一闪就出现了

这些问题通常不是 CSS 写错了,而是没有理解 <Transition> 的核心职责:它不负责“生成动画”,它只负责在正确的时机,帮你把“动画触发条件”安排好


1. <Transition> 到底做了什么?

一句话概括:<Transition> 是一个抽象包装组件,它会把一组“进入/离开”的钩子挂到子节点上,然后渲染器在 mount / unmount 的关键时刻去调用这些钩子,从而实现过渡。

你需要先记住的 3 个关键点

  1. <Transition> 不会额外渲染一层 DOM(你在页面上看不到一个多出来的 div)
  2. <Transition> 只接受一个直接子节点(一个元素或一个组件)
  3. 过渡的本质是:在不同的帧里切换 class,让浏览器的 CSS Transition/Animation 机制生效
给学生的直觉

<Transition> 想象成一个“导演”:它不演戏(不画动画),它只负责安排演员(DOM)在什么时候换衣服(class)、什么时候下场(remove/hide)。


2. 基本用法:先把它用对

2.1 v-if 进入/离开(最经典)

<template>
<button @click="show = !show">切换</button>

<Transition name="fade">
<div v-if="show" class="box">Hello</div>
</Transition>
</template>

<script setup>
import { ref } from 'vue'
const show = ref(false)
</script>

<style scoped>
.box {
width: 120px;
height: 60px;
background: #90caf9;
display: grid;
place-items: center;
}

/* 进入起点/离开终点:透明 + 下移 */
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(8px);
}

/* 进入终点/离开起点:不写也行,默认就是元素自身样式 */
.fade-enter-to,
.fade-leave-from {
opacity: 1;
transform: translateY(0);
}

/* 过渡过程 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
</style>
常见误区

你必须把“变化”写在 *-from*-to 里,把“耗时”写在 *-active 里。只写 transition 但没有前后差异(比如始终 opacity: 1),浏览器当然“无事可做”。

2.2 v-show 也能过渡,但逻辑不同

v-show 不会卸载元素,只是切 display: none。因此 <Transition> 会走一种“不卸载、只控制显示隐藏”的路径(源码里通常称为 persisted 场景)。

<template>
<button @click="show = !show">切换</button>

<Transition name="fade">
<div v-show="show" class="box">Hello</div>
</Transition>
</template>

这个例子 CSS 与上面完全相同。


3. 核心知识:6 个类名 + 分帧切换

以 Vue 3 推荐类名为例(name="fade"):

阶段进入(enter)离开(leave)作用
起点fade-enter-fromfade-leave-from过渡开始时的样式
过程fade-enter-activefade-leave-activetransition/animation 的定义(时长、曲线等)
终点fade-enter-tofade-leave-to过渡结束时的样式

关键不是“有哪些类”,而是“这些类在什么时候被加/删”:

离开(leave)同理,只是结束时会“真的移除/隐藏元素”:

为什么要“两次 rAF”?

一次 rAF 只保证“到下一帧”,但浏览器可能还没来得及把起点样式真正应用并完成布局/绘制;Vue 常用“双 rAF”确保起点生效后再切终点,过渡才会稳定触发。


4. 原理主线:Transition 如何接入 patch(渲染器)

在 Vue 3 里可以把它理解成两层:

  • BaseTransition(runtime-core):与平台无关,负责“什么时候 enter/leave、mode 怎么排队、如何延迟卸载”
  • Transition(runtime-dom):DOM 平台实现,负责“怎么加/删 class、怎么监听 CSS 结束事件、怎么读取 computedStyle”

4.1 渲染器会在关键时刻调用 vnode.transition

下面是帮助理解的伪代码(只看主线):

// 伪代码:挂载元素
const el = createElement()
if (vnode.transition) vnode.transition.beforeEnter(el)
insert(el, container)
if (vnode.transition) queuePostRenderEffect(() => vnode.transition.enter(el))

// 伪代码:卸载元素
if (vnode.transition) {
vnode.transition.leave(el, () => remove(el))
} else {
remove(el)
}

你会发现:Transition 能“延后移除元素” 的关键,是 leave(el, remove) 里拿到了一个 remove 回调;只有在动画结束后才调用它。

4.2 mode 的本质:在 enter/leave 之间做排队

默认情况下,新旧节点可以“同时”过渡;而 mode 会改变它们的先后顺序:


5. Vue 如何判断“到底有没有动画”?(自动嗅探)

你可能写了 enter/leave 类,但忘了写 transition/animation;这时 Vue 不应该傻等 transitionend,否则会卡住。

因此 Transition 在 DOM 层会做一件事:读取元素的 computedStyle,计算出真正的持续时间,并决定监听哪一种结束事件。

大体流程可以理解为:

  1. getComputedStyle(el) 读取:
    • transition-duration / transition-delay
    • animation-duration / animation-delay
  2. 把它们换算成毫秒,取 最大总时长(delay + duration)
  3. 如果总时长为 0:认为“没有动画”,直接 done()
  4. 否则监听:
    • transitionendanimationend(取决于你实际写的是哪种)
    • 同时加一个兜底定时器,避免事件丢失导致永久不结束
实战建议

当你用的是复杂动画库(JS 控制),或者你明确不想让 Vue 做 CSS 嗅探,可以写 :css="false",然后在 @enter/@leave 里手动调用 done


6. v-if vs v-show:为什么表现不一样?

v-if 会创建/销毁节点;v-show 只是改 display。这决定了 Transition 的“收尾动作”不同:

v-show 的坑

如果你的 CSS 写的是“离开时透明”,但你在 JS 里同步把 display: none 设掉,动画会直接被“掐断”。Vue 的做法是:先跑 leave,leave done 后再隐藏。


7. JavaScript 钩子:不用 CSS 也能完成过渡

<Transition> 支持一组事件钩子(这里列常用的):

  • @before-enter / @enter / @after-enter / @enter-cancelled
  • @before-leave / @leave / @after-leave / @leave-cancelled

7.1 纯 JS 过渡(推荐配合 :css="false"

<template>
<Transition :css="false" @enter="onEnter" @leave="onLeave">
<div v-if="show" class="box">Hello</div>
</Transition>
</template>

<script setup>
const onEnter = (el, done) => {
el.style.opacity = '0'
el.style.transform = 'translateY(8px)'

requestAnimationFrame(() => {
el.style.transition = 'opacity 250ms ease, transform 250ms ease'
el.style.opacity = '1'
el.style.transform = 'translateY(0)'

const cleanup = () => {
el.removeEventListener('transitionend', cleanup)
done()
}
el.addEventListener('transitionend', cleanup, { once: true })
})
}

const onLeave = (el, done) => {
el.style.transition = 'opacity 200ms ease, transform 200ms ease'
el.style.opacity = '0'
el.style.transform = 'translateY(8px)'
el.addEventListener('transitionend', done, { once: true })
}
</script>
记住一句话

当你使用 @enter/@leave 且带 done 参数时:一定要调用 done(),否则节点会永远处在“过渡中”,该卸载的不卸载,该进入的不进入。


8. 常见坑与最佳实践(非常高频)

8.1 <Transition> 只能包一个直接子节点

<!-- ❌ 错误:两个兄弟节点 -->
<Transition>
<div />
<div />
</Transition>

需要多个节点时:

  • 用一个容器包起来(动画作用于容器)
  • 或使用 <TransitionGroup>(列表/多节点过渡)

8.2 v-if / v-else 做“切换动画”一定要加 key

<Transition name="fade" mode="out-in">
<div v-if="tab === 'A'" key="A">A</div>
<div v-else key="B">B</div>
</Transition>

没有 key 时,Vue 可能会复用同一个元素,导致你以为在“切换”,实际上只是“改文本”,过渡钩子不会按预期触发。

8.3 height: auto 不能直接 transition

浏览器无法对 auto 做插值,常见方案:

  1. max-height 做近似(需要一个足够大的上限)
  2. 用 JS 钩子:进入时测量 scrollHeight,离开时从 scrollHeight 过渡到 0

8.4 动画优先用 transform + opacity

left/top/width/height 往往会触发布局(reflow),更卡;transform/opacity 通常只走合成层,性能更好。


9. 面试高频问答

Q1:<Transition> 会不会渲染出一层额外 DOM?

参考回答: 不会。<Transition> 是抽象组件,它最终渲染的是子节点本身,只是把过渡钩子挂到子 VNode 上,渲染器在 patch 时机调用这些钩子完成过渡。

Q2:v-ifv-show 的过渡有什么本质区别?

参考回答: v-if 是“挂载/卸载”,离开动画结束后才真正 remove()v-show 是“显示/隐藏”,离开动画结束后才把 display: none 设上去,节点始终存在。

Q3:为什么 Vue 做 enter/leave 要分帧(nextFrame / 双 rAF)?

参考回答: 因为浏览器会把同一帧里的样式修改合并计算。如果在同一帧里同时加起点类和终点类,浏览器可能直接应用终点样式,导致没有过渡。分到下一帧(甚至双 rAF)可以确保起点样式先落地,再切到终点,从而稳定触发 transition。

Q4:进入阶段的三个类(from/active/to)分别是什么作用?

参考回答: *-enter-from 定义起点样式,*-enter-active 定义过程(时长/曲线/延迟等),*-enter-to 定义终点样式。Vue 的做法是:先加 from+active,下一帧换成 to+active,结束后清理。

Q5:Vue 怎么知道动画什么时候结束?如果有多个属性过渡呢?

参考回答: Vue 会读取 computedStyle 里的 duration/delay(transition 和 animation),计算出最大总时长,并监听 transitionend/animationend。如果有多个属性同时过渡,会按“最长的那个”作为结束时间,并配合兜底定时器避免事件丢失。

Q6:mode="out-in"mode="in-out" 的区别是什么?

参考回答: out-in 是先让旧节点离开,离开结束后再让新节点进入;in-out 相反,新节点先进入,进入结束后旧节点再离开。本质上是 BaseTransition 对 enter/leave 做了排队和延迟处理。

Q7:什么时候需要 :css="false"

参考回答: 当你想用 JS(或第三方动画库)完全接管动画时,建议 :css="false" 关闭 CSS 嗅探,并在 @enter/@leave 里自己控制样式与时机,最后必须调用 done() 告诉 Vue“动画结束了”。

Q8:appear 是做什么的?

参考回答: 默认情况下,首次渲染不会触发 enter 过渡;加上 appear 后,组件第一次出现在页面上也会走一遍“进入动画”(使用 *-appear-* 类名或对应钩子)。

Q9:<TransitionGroup><Transition> 有什么区别?

参考回答: <Transition> 只处理“单个元素/组件”的进入离开;<TransitionGroup> 主要处理“列表/多节点”的插入、移除、移动,并常用 FLIP 思路来做位移过渡。


10. 总结

如果你能把“钩子挂到 VNode → patch 调用 → 分帧切 class → 等结束再收尾”这条主线说清楚,Transition 的 80% 面试题你就已经拿下了。