Appearance
6.4 Transition & TransitionGroup:动画的实现原理
本节深入解析 Vue 3 动画系统的核心实现,包括 Transition 组件的 CSS 过渡机制、JavaScript 钩子系统,以及 TransitionGroup 的列表过渡和 FLIP 技术。通过源码分析,我们将理解 Vue 如何优雅地处理元素的进入、离开和移动动画。
——
一、动画系统架构概览
1.1 核心组件
Vue 3 的动画系统由两个核心组件构成:
- Transition: - 单元素过渡组件
- TransitionGroup: - 列表过渡组件
1.2 动画类型支持
typescript
type AnimationTypes = typeof TRANSITION | typeof ANIMATION
export interface TransitionProps extends BaseTransitionProps<Element> {
name?: string // 过渡名称,用于生成 CSS 类名
type?: AnimationTypes // 'transition' | 'animation'
css?: boolean // 是否启用 CSS 过渡
duration?: number | { enter: number; leave: number } // 显式持续时间
// 自定义过渡类名
enterFromClass?: string // 进入开始状态
enterActiveClass?: string // 进入过程状态
enterToClass?: string // 进入结束状态
appearFromClass?: string // 初始渲染开始状态
appearActiveClass?: string // 初始渲染过程状态
appearToClass?: string // 初始渲染结束状态
leaveFromClass?: string // 离开开始状态
leaveActiveClass?: string // 离开过程状态
leaveToClass?: string // 离开结束状态
}——
二、CSS 过渡机制深度解析
2.1 过渡类名系统
Vue 3 使用六个 CSS 类名来控制元素的进入和离开过渡:
css
/* 进入过渡 */
.v-enter-from { /* 进入开始状态 */ }
.v-enter-active { /* 进入过程中 */ }
.v-enter-to { /* 进入结束状态 */ }
/* 离开过渡 */
.v-leave-from { /* 离开开始状态 */ }
.v-leave-active { /* 离开过程中 */ }
.v-leave-to { /* 离开结束状态 */ }2.2 类名管理机制
和 函数负责过渡类名的添加和移除:typescript
export function addTransitionClass(el: Element, cls: string): void {
cls.split(/\s+/).forEach(c => c && el.classList.add(c))
;(
(el as ElementWithTransition)[vtcKey] ||
((el as ElementWithTransition)[vtcKey] = new Set())
).add(cls)
}
export function removeTransitionClass(el: Element, cls: string): void {
cls.split(/\s+/).forEach(c => c && el.classList.remove(c))
const _vtc = (el as ElementWithTransition)[vtcKey]
if (_vtc) {
_vtc.delete(cls)
if (!_vtc!.size) {
;(el as ElementWithTransition)[vtcKey] = undefined
}
}
}关键设计:
- Symbol 键缓存:使用 在元素上缓存过渡类名,避免与用户类名冲突
- 批量处理:支持空格分隔的多个类名同时添加/移除
- 内存管理:Set 为空时自动清理,防止内存泄漏
2.3 过渡时序控制
进入动画的完整时序:
typescript
const makeEnterHook = (isAppear: boolean) => {
return (el: Element, done: () => void) => {
const hook = isAppear ? onAppear : onEnter
const resolve = () => finishEnter(el, isAppear, done)
callHook(hook, [el, resolve])
nextFrame(() => {
// 移除 *-from 类名
removeTransitionClass(el, isAppear ? appearFromClass : enterFromClass)
// 添加 *-to 类名
addTransitionClass(el, isAppear ? appearToClass : enterToClass)
if (!hasExplicitCallback(hook)) {
// 自动检测过渡结束
whenTransitionEnds(el, type, enterDuration, resolve)
}
})
}
}时序关键点:
- 双重 requestAnimationFrame: 确保类名变更在下一帧生效
- 强制重排: 确保样式立即应用
- 自动检测结束: 监听过渡事件或使用超时机制
——
三、JavaScript 钩子系统
3.1 钩子生命周期
Transition 组件提供了完整的 JavaScript 钩子:
typescript
// 进入钩子
onBeforeEnter(el: Element): void
onEnter(el: Element, done: () => void): void
onAfterEnter(el: Element): void
onEnterCancelled(el: Element): void
// 离开钩子
onBeforeLeave(el: Element): void
onLeave(el: Element, done: () => void): void
onAfterLeave(el: Element): void
onLeaveCancelled(el: Element): void
// 初始渲染钩子(appear)
onBeforeAppear(el: Element): void
onAppear(el: Element, done: () => void): void
onAfterAppear(el: Element): void
onAppearCancelled(el: Element): void3.2 钩子调用机制
函数处理钩子的统一调用:typescript
const callHook = (
hook: Function | Function[] | undefined,
args: any[] = [],
) => {
if (isArray(hook)) {
hook.forEach(h => h(...args))
} else if (hook) {
hook(...args)
}
}3.3 显式控制检测
函数检测用户是否要显式控制过渡结束:typescript
const hasExplicitCallback = (
hook: Function | Function[] | undefined,
): boolean => {
return hook
? isArray(hook)
? hook.some(h => h.length > 1)
: hook.length > 1
: false
}设计理念:
- 如果钩子函数接受
done参数,则用户负责调用done()来结束过渡 - 否则 Vue 自动检测 CSS 过渡结束
3.4 过渡结束检测
实现了智能的过渡结束检测:typescript
function whenTransitionEnds(
el: Element & { _endId?: number },
expectedType: TransitionProps['type'] | undefined,
explicitTimeout: number | null,
resolve: () => void,
) {
const id = (el._endId = ++endId)
const resolveIfNotStale = () => {
if (id === el._endId) {
resolve()
}
}
if (explicitTimeout != null) {
return setTimeout(resolveIfNotStale, explicitTimeout)
}
const { type, timeout, propCount } = getTransitionInfo(el, expectedType)
if (!type) {
return resolve()
}
const endEvent = type + 'end'
let ended = 0
const end = () => {
el.removeEventListener(endEvent, onEnd)
resolveIfNotStale()
}
const onEnd = (e: Event) => {
if (e.target === el && ++ended >= propCount) {
end()
}
}
setTimeout(() => {
if (ended < propCount) {
end()
}
}, timeout + 1)
el.addEventListener(endEvent, onEnd)
}检测策略:
- 显式超时:优先使用用户指定的 duration
- 事件监听:监听
transitionend或animationend事件 - 超时保护:防止事件丢失导致的死锁
- 防重复:使用
_endId防止过期的回调执行
——
四、TransitionGroup 与列表过渡
4.1 列表过渡挑战
列表过渡比单元素过渡复杂得多,需要处理:
- 多元素协调:同时管理多个元素的进入/离开
- 位置变化:元素添加/删除导致其他元素位置改变
- 性能优化:避免大量 DOM 操作导致的性能问题
4.2 FLIP 技术实现
FLIP(First, Last, Invert, Play)是一种高性能动画技术:
typescript
// 在 onUpdated 钩子中执行 FLIP 动画
onUpdated(() => {
if (!prevChildren.length) {
return
}
const moveClass = props.moveClass || `${props.name || 'v'}-move`
// 检测是否支持 CSS transform
if (!hasCSSTransform(prevChildren[0].el as ElementWithTransition, instance.vnode.el as Node, moveClass)) {
prevChildren = []
return
}
// FLIP 的三个阶段
prevChildren.forEach(callPendingCbs) // 清理之前的回调
prevChildren.forEach(recordPosition) // Last: 记录新位置
const movedChildren = prevChildren.filter(applyTranslation) // Invert: 应用反向变换
// 强制重排,确保变换立即生效
forceReflow()
// Play: 播放动画
movedChildren.forEach(c => {
const el = c.el as ElementWithTransition
const style = el.style
addTransitionClass(el, moveClass)
style.transform = style.webkitTransform = style.transitionDuration = ''
const cb = ((el as any)[moveCbKey] = (e: TransitionEvent) => {
if (e && e.target !== el) return
if (!e || e.propertyName.endsWith('transform')) {
el.removeEventListener('transitionend', cb)
;(el as any)[moveCbKey] = null
removeTransitionClass(el, moveClass)
}
})
el.addEventListener('transitionend', cb)
})
prevChildren = []
})4.3 位置记录与计算
和 函数实现位置追踪:typescript
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: 应用反向变换,让元素看起来还在旧位置
s.transform = s.webkitTransform = `translate(${dx}px,${dy}px)`
s.transitionDuration = '0s'
return c
}
}4.4 CSS Transform 检测
函数检测元素是否支持 CSS 过渡:typescript
function hasCSSTransform(
el: ElementWithTransition,
root: Node,
moveClass: string,
): boolean {
// 创建元素克隆进行检测
const clone = el.cloneNode() as HTMLElement
const _vtc = el[vtcKey]
// 移除所有过渡类名
if (_vtc) {
_vtc.forEach(cls => {
cls.split(/\s+/).forEach(c => c && clone.classList.remove(c))
})
}
// 添加移动类名
moveClass.split(/\s+/).forEach(c => c && clone.classList.add(c))
clone.style.display = 'none'
const container = (root.nodeType === 1 ? root : root.parentNode) as HTMLElement
container.appendChild(clone)
const { hasTransform } = getTransitionInfo(clone)
container.removeChild(clone)
return hasTransform
}——
五、动画时机与模式控制
5.1 动画时机分类
- Enter 动画:元素首次插入 DOM 时
- Leave 动画:元素从 DOM 中移除时
- Appear 动画:组件初始渲染时的特殊 enter 动画
- Move 动画:列表中元素位置改变时(仅 TransitionGroup)
5.2 模式控制
Transition 组件支持两种模式:
typescript
// in-out: 新元素先进入,旧元素后离开
<Transition mode="in-out">
<component :is="currentComponent" :key="currentKey" />
</Transition>
// out-in: 旧元素先离开,新元素后进入
<Transition mode="out-in">
<component :is="currentComponent" :key="currentKey" />
</Transition>5.3 持续时间控制
函数处理持续时间的标准化:typescript
function normalizeDuration(
duration: TransitionProps['duration'],
): [number, number] | null {
if (duration == null) {
return null
} else if (isObject(duration)) {
return [NumberOf(duration.enter), NumberOf(duration.leave)]
} else {
const n = NumberOf(duration)
return [n, n]
}
}——
六、性能优化策略
6.1 批量 DOM 操作
TransitionGroup 将 DOM 读写操作分离,避免布局抖动:
typescript
// 分三个循环避免 DOM 读写混合
prevChildren.forEach(callPendingCbs) // 清理回调
prevChildren.forEach(recordPosition) // 批量读取位置
const movedChildren = prevChildren.filter(applyTranslation) // 批量应用变换
forceReflow() // 强制重排一次
// 批量启动动画
movedChildren.forEach(c => {
// 启动过渡动画
})6.2 内存管理
- WeakMap 缓存:使用 WeakMap 存储位置信息,自动垃圾回收
- Symbol 键:避免与用户属性冲突
- 及时清理:动画结束后立即清理事件监听器和缓存
6.3 事件优化
typescript
// 防抖处理,避免重复触发
const resolveIfNotStale = () => {
if (id === el._endId) {
resolve()
}
}
// 超时保护,防止事件丢失
setTimeout(() => {
if (ended < propCount) {
end()
}
}, timeout + 1)——
七、实践应用与最佳实践
7.1 基础过渡动画
vue
<template>
<Transition name="fade">
<div v-if="show" class="content">Hello Vue!</div>
</Transition>
</template>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>7.2 JavaScript 钩子动画
vue
<template>
<Transition
@before-enter="onBeforeEnter"
@enter="onEnter"
@leave="onLeave"
:css="false"
>
<div v-if="show" class="content">Animated Content</div>
</Transition>
</template>
<script setup>
import { gsap } from 'gsap'
function onBeforeEnter(el) {
el.style.opacity = 0
el.style.transform = 'scale(0)'
}
function onEnter(el, done) {
gsap.to(el, {
opacity: 1,
scale: 1,
duration: 0.5,
onComplete: done
})
}
function onLeave(el, done) {
gsap.to(el, {
opacity: 0,
scale: 0,
duration: 0.5,
onComplete: done
})
}
</script>7.3 列表过渡动画
vue
<template>
<TransitionGroup name="list" tag="ul">
<li v-for="item in items" :key="item.id" class="list-item">
{{ item.text }}
</li>
</TransitionGroup>
</template>
<style>
.list-enter-active,
.list-leave-active {
transition: all 0.5s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(30px);
}
.list-move {
transition: transform 0.5s ease;
}
</style>7.4 性能优化建议
合理使用 CSS vs JS:
- 简单动画优先使用 CSS
- 复杂动画或需要精确控制时使用 JavaScript
避免不必要的重排:
- 使用
transform和opacity而非width、height - 批量 DOM 操作,减少重排次数
- 使用
内存管理:
- 及时清理事件监听器
- 避免在动画钩子中创建闭包引用
列表优化:
- 为列表项提供稳定的
key - 避免在大列表上使用复杂的移动动画
- 为列表项提供稳定的
——
八、小结
Vue 3 的动画系统通过精心设计的架构实现了高性能、易用性和灵活性的完美平衡:
- CSS 过渡系统:通过智能的类名管理和时序控制,提供了声明式的动画解决方案
- JavaScript 钩子:为复杂动画提供了完全的程序化控制能力
- FLIP 技术:在 TransitionGroup 中实现了高性能的列表过渡动画
- 性能优化:通过批量操作、内存管理和事件优化确保了良好的性能表现
这套动画系统不仅满足了日常开发中的动画需求,还为高级动画场景提供了强大的扩展能力。正确理解和使用这些机制,能够帮助开发者创建出流畅、优雅的用户界面动画效果。
