Skip to content

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)
      }
    })
  }
}

时序关键点

  1. 双重 requestAnimationFrame 确保类名变更在下一帧生效
  2. 强制重排 确保样式立即应用
  3. 自动检测结束 监听过渡事件或使用超时机制

——

三、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): void

3.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)
}

检测策略

  1. 显式超时:优先使用用户指定的 duration
  2. 事件监听:监听 transitionendanimationend 事件
  3. 超时保护:防止事件丢失导致的死锁
  4. 防重复:使用 _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 动画时机分类

  1. Enter 动画:元素首次插入 DOM 时
  2. Leave 动画:元素从 DOM 中移除时
  3. Appear 动画:组件初始渲染时的特殊 enter 动画
  4. 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 性能优化建议

  1. 合理使用 CSS vs JS

    • 简单动画优先使用 CSS
    • 复杂动画或需要精确控制时使用 JavaScript
  2. 避免不必要的重排

    • 使用 transformopacity 而非 widthheight
    • 批量 DOM 操作,减少重排次数
  3. 内存管理

    • 及时清理事件监听器
    • 避免在动画钩子中创建闭包引用
  4. 列表优化

    • 为列表项提供稳定的 key
    • 避免在大列表上使用复杂的移动动画

——

八、小结

Vue 3 的动画系统通过精心设计的架构实现了高性能、易用性和灵活性的完美平衡:

  • CSS 过渡系统:通过智能的类名管理和时序控制,提供了声明式的动画解决方案
  • JavaScript 钩子:为复杂动画提供了完全的程序化控制能力
  • FLIP 技术:在 TransitionGroup 中实现了高性能的列表过渡动画
  • 性能优化:通过批量操作、内存管理和事件优化确保了良好的性能表现

这套动画系统不仅满足了日常开发中的动画需求,还为高级动画场景提供了强大的扩展能力。正确理解和使用这些机制,能够帮助开发者创建出流畅、优雅的用户界面动画效果。


微信公众号二维码