Transition 动画系统:CSS 类名状态机、JS 钩子与 FLIP 原理
第27章:Transition 动画系统——CSS 类名状态机、JS 钩子与 FLIP 原理
<Transition>组件的核心秘密:v-enter-from和v-enter-active必须在同一帧内、DOM 插入前添加到元素上,否则浏览器看不到起始状态,动画直接跳到终点——这是 95% 的 Vue 动画不生效 bug 的根本原因。
本章核心问题:Vue 的动画系统是如何在 DOM 操作与浏览器渲染帧之间精确协调类名切换的?
读完本章你将理解:
- 6 个 CSS 类名的精确添加/移除时机,以及与
requestAnimationFrame/nextTick的关系 - JavaScript 钩子为什么必须调用
done(),以及忘记调用会发生什么 <TransitionGroup>的 FLIP 算法如何用 CSS transform 实现位移动画,而不移动实际的 DOM 位置
Level 1 · 你需要知道的(1-3年经验)
1.1 六个类名:动画的完整生命周期
<Transition> 组件的工作方式,是在元素进入/离开时,按照精确的时序添加和移除 CSS 类名。一共有 6 个类名,分为进入和离开两组:
进入动画(Enter):
v-enter-from:进入的起始状态,元素插入前添加,动画开始后第一帧移除v-enter-active:进入动画期间持续存在,定义transition/animation属性v-enter-to:进入的结束状态,动画开始后第一帧添加,动画完成后移除
离开动画(Leave):
v-leave-from:离开的起始状态,离开动画触发后立即添加v-leave-active:离开动画期间持续存在,定义transition/animation属性v-leave-to:离开的结束状态,下一帧添加,动画完成后元素移除
最常见的淡入淡出动画写法:
.v-enter-active,
.v-leave-active {
transition: opacity 0.3s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
这里的关键逻辑是:
- 元素进入时,从
opacity: 0(v-enter-from)过渡到默认的opacity: 1 - 元素离开时,从
opacity: 1过渡到opacity: 0(v-leave-to) v-enter-to和v-leave-from通常不需要显式设置,因为它们就是元素的自然状态
1.2 基本使用:包裹单个元素
<Transition> 组件只能包裹单个子元素(或单个组件):
<template>
<button @click="show = !show">切换</button>
<Transition name="fade">
<div v-if="show" class="box">内容</div>
</Transition>
</template>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
注意 name="fade" 改变了类名前缀,从默认的 v- 变为 fade-。这样可以在同一页面使用多种不同的动画效果。
1.3 CSS animation 与 CSS transition 的区别
v-enter-active 可以使用 CSS transition,也可以使用 CSS animation:
使用 CSS transition:
.slide-enter-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-enter-from {
transform: translateX(-20px);
opacity: 0;
}
使用 CSS animation:
.bounce-enter-active {
animation: bounce-in 0.5s;
}
@keyframes bounce-in {
0% { transform: scale(0); }
50% { transform: scale(1.2); }
100% { transform: scale(1); }
}
.bounce-leave-active {
animation: bounce-in 0.5s reverse;
}
Vue 内部会检测 v-enter-active 上是否有 transition 或 animation,通过监听 transitionend 或 animationend 事件来判断动画何时结束。
当两者同时存在时,Vue 会以持续时间更长的那个为准。也可以通过 type 属性强制指定:
<Transition type="animation">
<!-- 同时有 transition 和 animation 时,以 animation 为准 -->
</Transition>
1.4 appear:初始渲染也播放动画
默认情况下,<Transition> 只在元素切换(显示/隐藏)时播放动画,初始渲染不播放。添加 appear 属性可以改变这一行为:
<Transition name="fade" appear>
<div>页面加载时也有淡入效果</div>
</Transition>
初始渲染使用专属的类名:v-appear-from、v-appear-active、v-appear-to。如果没有定义这些类名,Vue 会回退使用进入动画的类名(v-enter-from 等)。
1.5 多元素切换:mode 属性
当需要在两个元素之间切换时(例如按钮状态切换),默认情况下离开和进入动画会同时播放,这可能导致两个元素短暂同时出现:
<Transition name="fade" mode="out-in">
<button v-if="isEditing" key="save">保存</button>
<button v-else key="edit">编辑</button>
</Transition>
mode="out-in" 表示先播放离开动画,结束后再播放进入动画。mode="in-out" 则相反。
注意:多元素切换时必须给每个元素设置不同的 key,否则 Vue 会认为是同一个元素在更新内容,不会触发过渡。
1.6 TransitionGroup:列表动画
<TransitionGroup> 用于处理 v-for 列表的动画:
<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
</TransitionGroup>
<style>
.list-enter-active,
.list-leave-active {
transition: all 0.3s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}
/* 移动动画 */
.list-move {
transition: transform 0.3s ease;
}
/* 离开时脱离文档流,让其他元素能移动到位 */
.list-leave-active {
position: absolute;
}
</style>
v-move 类名(这里是 list-move)是 <TransitionGroup> 独有的,用于元素位移动画,基于 FLIP 算法实现(Level 2 会详细讲解)。
Level 2 · 它是怎么运行的(3-5年经验)
2.1 类名添加/移除的精确时序
理解 Vue 动画的关键,是精确理解每个类名在哪一帧被添加和移除。下面是进入动画的完整时序:
帧 N(触发 v-if="true")
│
├── Vue 创建 VNode,准备插入 DOM
├── 添加 v-enter-from(起始状态:如 opacity: 0)
├── 添加 v-enter-active(定义 transition 属性)
├── 将 DOM 节点插入文档
│ └── 此时浏览器尚未渲染(还在同一个 JS 任务中)
│
帧 N+1(下一个渲染帧,通过 requestAnimationFrame)
├── 添加 v-enter-to(终止状态:如 opacity: 1)
├── 移除 v-enter-from
│ └── 浏览器看到:从 opacity:0 过渡到 opacity:1 → 动画开始
│
动画结束(监听 transitionend 事件)
└── 移除 v-enter-active 和 v-enter-to
└── 动画清理完成
为什么必须在同一帧插入 DOM 并添加类名?
浏览器的渲染流程是:JS 执行 → 样式计算 → 布局 → 绘制。在同一个 JS 任务中,即使多次修改 DOM 和样式,浏览器也只会在任务结束后统一进行一次样式计算。
因此,Vue 在同一帧内:
- 添加
v-enter-from(opacity: 0) - 插入 DOM 节点
浏览器看到的是:带有 opacity: 0 样式的新节点。
然后在下一帧(通过 requestAnimationFrame):
- 移除
v-enter-from - 添加
v-enter-to
浏览器看到样式从 opacity: 0 变为 opacity: 1,触发 CSS transition。
错误场景:如果插入 DOM 后立即(同一帧)移除 v-enter-from,浏览器在做样式计算时已经是 opacity: 1 了,没有起点,动画不触发。
错误时序(动画不生效):
帧 N: 插入 DOM + 添加 v-enter-from + 立即移除 v-enter-from
→ 浏览器计算样式时 opacity 已经是 1,没有过渡
ASCII 流程图——进入动画的完整状态机:
触发条件(v-if=true / v-show=true)
│
▼
┌────────────────────────────────────┐
│ 同一帧(Frame N) │
│ 1. 添加 v-enter-from │ opacity: 0
│ 2. 添加 v-enter-active │ transition: opacity 0.3s
│ 3. 插入 DOM 节点 │
└────────────────┬───────────────────┘
│ requestAnimationFrame
▼
┌────────────────────────────────────┐
│ 下一帧(Frame N+1) │
│ 4. 移除 v-enter-from │ opacity: 0 → 消失
│ 5. 添加 v-enter-to │ opacity: 1 → 浏览器触发过渡
└────────────────┬───────────────────┘
│ transitionend 事件
▼
┌────────────────────────────────────┐
│ 动画结束 │
│ 6. 移除 v-enter-active │
│ 7. 移除 v-enter-to │
└────────────────────────────────────┘
2.2 离开动画的特殊处理
离开动画与进入动画有一个重要区别:元素必须在动画结束后才能从 DOM 中移除,而不是立即移除。
触发条件(v-if=false / v-show=false)
│
▼
┌────────────────────────────────────┐
│ Frame N │
│ 1. 添加 v-leave-from │ 当前状态(opacity: 1)
│ 2. 添加 v-leave-active │ transition: opacity 0.3s
│ (元素仍在 DOM 中) │
└────────────────┬───────────────────┘
│ requestAnimationFrame
▼
┌────────────────────────────────────┐
│ Frame N+1 │
│ 3. 移除 v-leave-from │
│ 4. 添加 v-leave-to │ opacity: 0 → 触发过渡
└────────────────┬───────────────────┘
│ transitionend 事件
▼
┌────────────────────────────────────┐
│ 动画结束 │
│ 5. 移除元素(或设置 display:none) │
│ 6. 移除 v-leave-active │
│ 7. 移除 v-leave-to │
└────────────────────────────────────┘
这就是为什么离开动画期间元素还在 DOM 里。如果设置了 position: absolute,可以防止它占用布局空间(这在 <TransitionGroup> 中很常用)。
2.3 duration 自动检测:Vue 如何知道动画何时结束
Vue 不依赖硬编码的时间,而是监听 CSS 事件:
// 伪代码,简化自 Vue 3 源码 packages/runtime-dom/src/components/Transition.ts
function whenTransitionEnds(el, expectedType, resolve) {
// 检测元素上的 transition 和 animation
const { type, timeout, propCount } = getTransitionInfo(el, expectedType);
if (!type) {
return resolve(); // 没有动画,直接完成
}
const endEvent = type === 'transition' ? 'transitionend' : 'animationend';
let ended = 0;
const onEnd = (e) => {
if (e.target === el) {
ended++;
if (ended >= propCount) {
// 所有属性的过渡都完成了
el.removeEventListener(endEvent, onEnd);
resolve();
}
}
};
el.addEventListener(endEvent, onEnd);
// 超时保险:防止 transitionend 未触发
setTimeout(() => {
if (ended < propCount) {
resolve();
}
}, timeout + 1);
}
getTransitionInfo 会读取元素的计算样式(getComputedStyle(el)),解析 transition-duration、animation-duration 等属性,计算出最长的持续时间。
多属性过渡:如果同时过渡 opacity 和 transform,Vue 等待所有属性的 transitionend 都触发后才认为动画完成。
2.4 JavaScript 钩子与 done() 机制
<Transition> 提供了完整的 JavaScript 钩子,允许使用 GSAP 等第三方动画库:
<Transition
@before-enter="onBeforeEnter"
@enter="onEnter"
@after-enter="onAfterEnter"
@enter-cancelled="onEnterCancelled"
@before-leave="onBeforeLeave"
@leave="onLeave"
@after-leave="onAfterLeave"
@leave-cancelled="onLeaveCancelled"
>
<div v-if="show">内容</div>
</Transition>
<script setup>
import gsap from 'gsap';
function onEnter(el, done) {
gsap.from(el, {
opacity: 0,
y: -20,
duration: 0.5,
onComplete: done // 必须调用 done!
});
}
function onLeave(el, done) {
gsap.to(el, {
opacity: 0,
y: 20,
duration: 0.5,
onComplete: done // 必须调用 done!
});
}
</script>
done() 的作用:告诉 Vue 动画已经完成。对于 @enter 和 @leave 钩子,done 是第二个参数。如果不调用 done():
@enter的done未调用:Vue 认为进入动画还在进行,@after-enter永远不会触发@leave的done未调用:Vue 认为离开动画还在进行,元素永远不会从 DOM 中移除
当使用 JS 钩子时,建议添加 :css="false" 告诉 Vue 跳过 CSS 过渡检测,避免干扰:
<Transition :css="false" @enter="onEnter" @leave="onLeave">
各钩子的完整签名:
| 钩子 | 参数 | 说明 |
|---|---|---|
before-enter |
(el) |
DOM 插入前,元素已有 v-enter-from |
enter |
(el, done) |
DOM 插入后,必须调用 done |
after-enter |
(el) |
进入动画完成 |
enter-cancelled |
(el) |
进入动画被中断(元素又被隐藏) |
before-leave |
(el) |
离开动画开始前 |
leave |
(el, done) |
离开动画进行中,必须调用 done |
after-leave |
(el) |
离开动画完成,元素已移除 |
leave-cancelled |
(el) |
离开动画被中断(元素又被显示) |
2.5 FLIP 算法:TransitionGroup 的位移动画原理
FLIP 是 First-Last-Invert-Play 的缩写,是实现元素位移动画的核心技术。普通的 CSS transition 只能动画化样式值的变化,但无法直接动画化 DOM 的重新排列(因为 DOM 操作是瞬间的)。FLIP 通过以下步骤绕过这个限制:
FLIP 算法的四个步骤:
First(记录初始位置)
│ getBoundingClientRect() 记录所有元素的位置
│
Last(执行 DOM 操作)
│ 实际重排列表(添加、删除、重新排序)
│ getBoundingClientRect() 记录所有元素的新位置
│
Invert(计算逆变换)
│ 对每个移动的元素,计算 deltaX = first.left - last.left
│ deltaY = first.top - last.top
│ 设置 transform: translate(deltaX, deltaY) → 让元素"看起来"还在原位
│ (这一步是同步的,浏览器还没渲染)
│
Play(播放动画)
│ 移除强制的 transform(或将其动画到 translate(0, 0))
│ 浏览器渲染:元素从"原位"移动到"新位" → 视觉上产生流畅动画
Vue 的 <TransitionGroup> 内置了 FLIP 实现:
// 伪代码,简化自 Vue 3 源码 packages/runtime-dom/src/components/TransitionGroup.ts
// 1. First:更新前记录位置
children.forEach(child => {
child._moveCb = null;
child._enterCb = null;
// 保存旧位置
const info = (child._transition = {});
info.oldPos = child.el.getBoundingClientRect();
});
// (Vue 虚拟 DOM diff 和真实 DOM 更新在这里发生)
// 2. Last + Invert:更新后,对需要移动的元素应用反向 transform
nextTick(() => {
children.forEach(child => {
const c = child._transition;
const newPos = child.el.getBoundingClientRect();
const dx = c.oldPos.left - newPos.left;
const dy = c.oldPos.top - newPos.top;
if (dx || dy) {
// Invert:让元素看起来在原来的位置
child.el.style.transform = `translate(${dx}px, ${dy}px)`;
child.el.style.transitionDuration = '0s'; // 关闭过渡(避免闪烁)
}
});
// 3. Play:强制浏览器重排,然后清除 transform
document.body.offsetHeight; // 强制重排(读取布局信息)
children.forEach(child => {
if (child.el.style.transform) {
// 添加 v-move 类(定义 transition),然后清除 transform
child.el.classList.add('v-move');
child.el.style.transform = '';
child.el.style.transitionDuration = '';
}
});
});
为什么需要 position: absolute?
当列表项离开时,它仍然占据 DOM 空间,会影响其他元素的"Last"位置计算。设置 position: absolute 让离开的元素脱离文档流,使其他元素能正确地移动到目标位置。
.list-leave-active {
position: absolute; /* 关键:离开元素脱离文档流 */
}
.list-move {
transition: transform 0.3s ease;
}
2.6 路由切换动画
与 Vue Router 配合实现页面切换动画:
<!-- App.vue -->
<template>
<RouterView v-slot="{ Component, route }">
<Transition :name="route.meta.transition || 'fade'" mode="out-in">
<component :is="Component" :key="route.path" />
</Transition>
</RouterView>
</template>
<style>
/* 向前跳转:新页面从右侧进入 */
.slide-left-enter-from { transform: translateX(100%); }
.slide-left-leave-to { transform: translateX(-100%); }
/* 向后跳转:新页面从左侧进入 */
.slide-right-enter-from { transform: translateX(-100%); }
.slide-right-leave-to { transform: translateX(100%); }
.slide-left-enter-active,
.slide-left-leave-active,
.slide-right-enter-active,
.slide-right-leave-active {
transition: transform 0.3s ease;
}
</style>
路由配置:
const routes = [
{ path: '/', component: Home, meta: { transition: 'slide-left' } },
{ path: '/detail', component: Detail, meta: { transition: 'slide-right' } },
];
更智能的方案是根据路由历史动态决定方向:
// router.js
let previousDepth = 0;
router.afterEach((to, from) => {
const toDepth = to.path.split('/').length;
const fromDepth = from.path.split('/').length;
to.meta.transition = toDepth > fromDepth ? 'slide-left' : 'slide-right';
});
Level 3 · 设计文档与源码(资深开发者)
3.1 Transition 组件的源码结构
Vue 3 的 Transition 实现分布在两个包中:
packages/runtime-core/src/components/BaseTransition.ts:平台无关的核心逻辑(状态机、钩子调度)packages/runtime-dom/src/components/Transition.ts:DOM 专用逻辑(CSS 类名操作)
BaseTransition 的核心(packages/runtime-core/src/components/BaseTransition.ts):
// 进入动画的完整调度逻辑(简化版)
function performEnter(el: Element, hooks: TransitionHooks) {
const {
beforeEnter, enter, afterEnter,
enterCancelled, delayLeave, delayedLeave
} = hooks;
const resolve = (cancelled?: boolean) => {
if (cancelled) {
enterCancelled(el);
} else {
afterEnter(el);
}
// 清理状态
if (el._enterCb) {
el._enterCb = null;
}
};
// 防止重复执行
if (el._enterCb) {
el._enterCb(true /* cancelled */);
}
el._enterCb = resolve;
// 调用 beforeEnter 钩子
if (beforeEnter) beforeEnter(el);
// 下一帧执行(这就是 requestAnimationFrame 调用的地方)
nextFrame(() => {
if (el._enterCb) {
// 如果已被取消,不执行
if (enter) {
enter(el, resolve);
} else {
resolve();
}
}
});
}
DOM 层的类名操作(packages/runtime-dom/src/components/Transition.ts):
const duringEnterFrom: TransitionHooks = {
beforeEnter(el) {
// 添加 v-enter-from 和 v-enter-active(Frame N)
addTransitionClass(el, enterFromClass); // v-enter-from
addTransitionClass(el, enterActiveClass); // v-enter-active
},
enter(el, done) {
// 这里在 Frame N+1(nextFrame 回调中)
// 移除 v-enter-from,添加 v-enter-to
removeTransitionClass(el, enterFromClass);
addTransitionClass(el, enterToClass);
if (!hasExplicitCallback(done)) {
// 没有显式的 JS done 回调,用 CSS 事件
whenTransitionEnds(el, type, () => {
removeTransitionClass(el, enterToClass);
removeTransitionClass(el, enterActiveClass);
done();
});
}
},
afterEnter(el) {
removeTransitionClass(el, enterToClass);
removeTransitionClass(el, enterActiveClass);
}
};
3.2 nextFrame 的实现
Vue 内部用 requestAnimationFrame 的双重嵌套来确保在下一个绘制帧执行:
// packages/runtime-dom/src/components/Transition.ts
const raf = window.requestAnimationFrame
? window.requestAnimationFrame.bind(window)
: setTimeout;
function nextFrame(fn: () => void) {
raf(() => {
raf(fn); // 双重 RAF,确保在样式计算后
});
}
为什么要双重 RAF?
单个 requestAnimationFrame 的回调仍然可能在浏览器处理样式之前执行(取决于浏览器实现)。双重 RAF 确保:
- 第一个 RAF:浏览器完成当前帧的样式计算和布局
- 第二个 RAF:在下一帧开始时执行,此时 DOM 和样式都已稳定
注意:部分浏览器(Chrome 64+)对 RAF 的实现已经足够精确,单个 RAF 也可以工作。但双重 RAF 是更保险的写法,Vue 沿用了这个 pattern。
3.3 TransitionGroup 的 FLIP 源码分析
packages/runtime-dom/src/components/TransitionGroup.ts 中的位移检测逻辑:
function callPendingCbs(c: VNode) {
const el = c.el as any;
if (el._moveCb) {
el._moveCb();
}
if (el._enterCb) {
el._enterCb();
}
}
function recordPosition(c: VNode) {
newPositionMap.set(c, (c.el as Element).getBoundingClientRect());
}
function applyTranslation(c: VNode): VNode | undefined {
const oldPos = positionMap.get(c)!; // First 阶段记录的位置
const newPos = newPositionMap.get(c)!; // Last 阶段记录的位置
const dx = oldPos.left - newPos.left;
const dy = oldPos.top - newPos.top;
if (dx || dy) {
const s = (c.el as HTMLElement).style;
// Invert:设置反向 transform
s.transform = s.webkitTransform = `translate(${dx}px,${dy}px)`;
s.transitionDuration = '0s'; // 立即跳到反向位置(无动画)
return c;
}
}
3.4 性能优化:GPU 合成层
动画性能的关键是避免触发 Layout(重排)和 Paint(重绘),只触发 Composite(合成):
只触发 Composite 的属性(可以 GPU 加速):
transform: translate/scale/rotateopacity
会触发 Layout 的属性(性能杀手,避免动画化):
width、height、margin、paddingtop、left(除非元素是绝对定位,用transform替代)font-size
最佳实践:用 will-change 提前告知浏览器:
.animated-element {
will-change: transform, opacity;
/* 浏览器会提前为该元素创建独立的合成层 */
}
注意滥用 will-change:每个合成层消耗 GPU 内存(约 4 * width * height 字节),不要对所有元素使用。只对即将动画的元素使用,动画结束后移除:
el.addEventListener('mouseenter', () => {
el.style.willChange = 'transform';
});
el.addEventListener('animationend', () => {
el.style.willChange = 'auto'; // 动画结束后释放
});
Level 4 · 边界与陷阱(全体适用)
陷阱 1:同一帧内修改 CSS 类名导致动画跳帧
错误代码:自定义动画逻辑,手动在同一帧内移除起始类名:
// 错误!动画不会生效
function startAnimation(el) {
el.classList.add('fade-enter-from');
el.classList.add('fade-enter-active');
el.style.display = 'block';
// 错误:同一帧内就移除了 fade-enter-from
el.classList.remove('fade-enter-from');
el.classList.add('fade-enter-to');
// 浏览器看到的是:元素直接以 fade-enter-to 状态出现
}
正确做法:
function startAnimation(el) {
el.classList.add('fade-enter-from');
el.classList.add('fade-enter-active');
el.style.display = 'block';
// 强制重排(让浏览器"看到"初始状态)
el.offsetHeight; // 读取任何布局属性都能强制重排
// 或使用 requestAnimationFrame
requestAnimationFrame(() => {
el.classList.remove('fade-enter-from');
el.classList.add('fade-enter-to');
});
}
陷阱 2:忘记给 @enter/@leave 钩子调用 done(),导致元素永远不消失
错误代码:
<Transition @leave="onLeave">
<div v-if="show">内容</div>
</Transition>
<script setup>
function onLeave(el, done) {
gsap.to(el, {
opacity: 0,
duration: 0.5,
onComplete: () => {
// 忘记调用 done()!
console.log('动画完成');
}
});
}
</script>
现象:离开动画播放完毕,元素的 opacity 变为 0,但元素仍然占用 DOM 空间(因为 Vue 在等待 done() 才移除元素)。
正确代码:
function onLeave(el, done) {
gsap.to(el, {
opacity: 0,
duration: 0.5,
onComplete: done // 直接传入 done 作为回调
});
}
陷阱 3:v-for 列表没有 key,或 key 设置不当
错误场景:
<!-- 错误:使用 index 作为 key -->
<TransitionGroup name="list">
<div v-for="(item, index) in items" :key="index">
{{ item.name }}
</div>
</TransitionGroup>
当在列表头部插入元素时,所有元素的 index 都改变了,Vue 认为是所有元素都在"更新",而不是在"新增",FLIP 动画会产生错误。
正确做法:使用稳定的唯一 id 作为 key:
<TransitionGroup name="list">
<div v-for="item in items" :key="item.id">
{{ item.name }}
</div>
</TransitionGroup>
陷阱 4:TransitionGroup 的 position: absolute 忘记设置导致 FLIP 抖动
错误场景:列表项在离开时没有脱离文档流:
/* 缺少 position: absolute */
.list-leave-active {
transition: opacity 0.3s;
}
.list-leave-to {
opacity: 0;
}
现象:删除一个列表项时,该项慢慢消失,但在消失过程中它仍然占用位置,导致其他元素的"Last"位置是错误的,FLIP 动画会先跳到错误位置再移动到正确位置(视觉上表现为抖动)。
正确代码:
.list-leave-active {
position: absolute; /* 脱离文档流 */
transition: opacity 0.3s;
width: 100%; /* 防止 absolute 导致宽度塌陷 */
}
.list-leave-to {
opacity: 0;
}
.list-move {
transition: transform 0.3s ease;
}
陷阱 5:mode="out-in" 与 keep-alive 组合导致动画卡死
错误场景:
<!-- 错误:keep-alive 包裹时使用 mode="out-in" 可能导致问题 -->
<Transition mode="out-in">
<KeepAlive>
<component :is="currentComponent" />
</KeepAlive>
</Transition>
<KeepAlive> 缓存的组件在"离开"时不会真正卸载,而是调用 deactivated 钩子。这与 mode="out-in" 的"等待离开完成"逻辑可能产生冲突。
正确做法:将 <Transition> 放在 <KeepAlive> 内部:
<KeepAlive>
<Transition mode="out-in">
<component :is="currentComponent" />
</Transition>
</KeepAlive>
本章小结
-
状态机时序是核心:
v-enter-from和v-enter-active必须在 DOM 插入前同帧添加,下一帧(通过requestAnimationFrame)才切换到v-enter-to,这是动画能够触发的根本原因。 -
done() 不可省略:使用 JavaScript 钩子时,
@enter和@leave的第二个参数done必须调用,否则 Vue 的状态机永远不会推进,元素要么永远不显示完成状态,要么永远不从 DOM 中移除。 -
FLIP 算法是 TransitionGroup 的基础:先记录位置(First)→ 执行 DOM 操作(Last)→ 计算反向偏移(Invert)→ 清除偏移触发过渡(Play),实现了在不移动实际 DOM 位置的情况下产生视觉位移动画。
-
性能优化只动画 transform 和 opacity:这两个属性只触发 GPU 合成,不触发 Layout 和 Paint,是动画性能的金标准;
will-change可以提前创建合成层,但要避免滥用。 -
TransitionGroup 离开时必须 position: absolute:让离开的元素脱离文档流,其他元素才能正确计算 FLIP 的 Last 位置,避免位移动画抖动。