Skip to content

Transition & TransitionGroup:动画的实现原理

<Transition><TransitionGroup> 是 Vue 提供的内置组件,用于给元素的“进入”、“离开”和“移动”添加动画。

  • <Transition>:用于单个元素或组件的进入/离开动画。
  • <TransitionGroup>:用于 v-for 列表的进入/离开/移动动画。

本节将分析这两个组件是如何“拦截” Vue 的 patch(Diff)过程,并将其与 CSS 或 JS 动画结合的。


<Transition>:DOM 操作的协调器

<Transition> 的核心原理是:它拦截了子元素的 insert(插入)和 remove(移除)操作,将这些“瞬时”的 DOM 操作,扩展为一个“有时间过程”的动画

它通过一个自定义的 render 函数来实现这一“拦截”。

自定义 render 函数

<Transition>setup 函数会返回一个自定义 render 函数。这个 render 函数的核心逻辑(简化后)如下:

typescript
// core/packages/runtime-dom/src/components/Transition.ts
setup(props, { slots }) {
  return () => {
    // 1. 获取插槽中的“当前”子节点
    const innerChild = slots.default && slots.default()[0]
    
    // 2. 【关键】获取“上一次”的子节点
    const prevChild = instance.subTree
    
    // 3. 比较新旧节点,判断是“进入”还是“离开”
    if (innerChild && innerChild.key !== prevChild.key) {
      // 是“进入” (Enter) 动画
      // ...
    } else if (prevChild && prevChild.key !== innerChild.key) {
      // 是“离开” (Leave) 动画
      // ...
    }
    
    // ... (省略 mode="out-in" / "in-out" 的处理逻辑) ...
  }
}

performEnter:拦截“插入”操作

v-if="true" 触发“进入”时,<Transition> 不会立即 insert 元素。它会调用一个内部的 performEnter 函数,启动一个“进入”流程:

  1. onBeforeEnter (JS 钩子) / v-enter-from (CSS 类)

    • 设置动画的“起始状态”(如 opacity: 0)。
    • addTransitionClass(el, enterFromClass)
  2. onEnter (JS 钩子) / v-enter-active (CSS 类)

    • 下一帧nextFrame)执行,确保“起始状态”已生效。
    • addTransitionClass(el, enterActiveClass)
    • 【触发动画】:移除 v-enter-from,添加 v-enter-to
      • removeTransitionClass(el, enterFromClass)
      • addTransitionClass(el, enterToClass)
    • 浏览器检测到 opacity 从 0 (from) 变为 1 (to),开始播放 transition
  3. 等待动画结束 (whenTransitionEnds)

    • Vue 会监听 transitionendanimationend 事件。
    • 如果 JS 钩子 onEnter 接受了 done 参数,Vue 会等待 done() 被调用。
    • (同时启动一个 setTimeout 作为“兜底”,防止事件丢失)。
  4. onAfterEnter (JS 钩子) / 清理 CSS

    • 动画结束,调用 finishEnter
    • 清理所有过渡类名(v-enter-active, v-enter-to)。

performLeave:拦截“移除”操作

v-if="false" 触发“离开”时,<Transition> 不会立即 remove 元素。它会:

  1. onBeforeLeave (JS 钩子) / v-leave-from (CSS 类)

    • 设置离开的“起始状态”(如 opacity: 1)。
    • addTransitionClass(el, leaveFromClass)
  2. onLeave (JS 钩子) / v-leave-active (CSS 类)

    • 下一帧执行。
    • addTransitionClass(el, leaveActiveClass)
    • 【触发动画】:移除 v-leave-from,添加 v-leave-to(如 opacity: 0)。
    • 浏览器开始播放 transition
  3. 等待动画结束 (whenTransitionEnds)

    • 同上,等待 transitionend 事件或 done() 调用。
  4. onAfterLeave (JS 钩子) / 移除 DOM

    • 动画结束,调用 finishLeave
    • 此时,才真正执行 DOM 移除操作

whenTransitionEnds:智能的“结束检测器”

whenTransitionEnds (Transition.ts) 是一个用于检测动画结束的函数:

typescript
function whenTransitionEnds(
  el: Element & { _endId?: number },
  expectedType: AnimationTypes | undefined,
  explicitTimeout: number | null,
  resolve: () => void,
) {
  // 1. 【优先】如果用户提供了 :duration="500",
  //    则使用 setTimeout,不监听事件
  if (explicitTimeout != null) {
    return setTimeout(resolve, explicitTimeout)
  }

  // 2. 【自动检测】
  //    调用 getTransitionInfo(),它会读取元素的 CSS 样式,
  //    找出是 'transition' 还是 'animation',
  //    以及最长的 'timeout' (duration) 和 'propCount' (属性数量)
  const { type, timeout, propCount } = getTransitionInfo(el, expectedType)

  if (!type) {
    // 如果没有 CSS 动画,立即结束
    return resolve()
  }

  // 3. 【监听事件】
  const endEvent = type + 'end' // 'transitionend' 或 'animationend'
  let ended = 0
  const onEnd = (e: Event) => {
    if (e.target === el && ++ended >= propCount) {
      // 确保所有属性都过渡完毕了,才算结束
      end()
    }
  }
  
  // 4. 【超时保护】
  //    设置一个比 CSS duration 稍长的定时器,
  //    防止 transitionend 事件因故丢失
  setTimeout(() => {
    if (ended < propCount) {
      end()
    }
  }, timeout + 1)
  
  el.addEventListener(endEvent, onEnd)
}

<TransitionGroup>:列表的 FLIP 动画

<TransitionGroup>第一职责是渲染一个列表(如 <ul>)。 它的第二职责是:当列表项发生进入离开移动时,应用动画。

  • 对于“进入”和“离开”,它复用了 <Transition> 的所有逻辑(CSS 类名、JS 钩子)。
  • 对于“移动”(Move),它使用了一种名为 FLIP 的高性能动画技术。

FLIP 技术:高性能的位置动画

FLIP 是一种动画思想,它将“移动”动画分解为四步,以避免浏览器进行昂贵的“布局”计算:

  • F (First):记录所有子元素在“移动前”的原始位置positionMap)。
  • L (Last):在 DOM 更新后(onUpdated 钩子),立即记录所有子元素在“移动后”的目标位置newPositionMap)。
  • I (Invert):计算出 (Last - First)偏移量立即通过 transform: translate(dx, dy) 将元素“瞬移”回“原始位置”(First),此时元素在屏幕上的位置看起来没有变化。
  • P (Play):在下一帧,移除 transform,并添加 CSS transitionv-move 类)。元素就会平滑地从“原始位置”过渡(Play)回“目标位置”。

<TransitionGroup> 的 FLIP 源码实现

FLIP 的核心逻辑在 <TransitionGroup>setup 函数返回的 onUpdated 钩子中:

typescript
// core/packages/runtime-dom/src/components/TransitionGroup.ts
setup(props, { slots }) {
  const positionMap = new WeakMap()
  const newPositionMap = new WeakMap()

  // 【F (First)】
  // onBeforeUpdate:在 DOM 更新前,
  // 遍历所有子节点,记录“原始位置”
  onBeforeUpdate(() => {
    positionMap.clear()
    for (const vnode of children) {
      positionMap.set(vnode, (vnode.el as Element).getBoundingClientRect())
    }
  })

  // 【L, I, P】
  // onUpdated:在 DOM 更新后
  onUpdated(() => {
    newPositionMap.clear()
    
    // 【L (Last)】
    // 遍历子节点,记录“目标位置”
    for (const vnode of children) {
      newPositionMap.set(vnode, (vnode.el as Element).getBoundingClientRect())
    }

    // 【I (Invert)】
    // 遍历子节点,计算偏移,并“瞬移”回去
    const movedChildren: VNode[] = []
    for (const vnode of children) {
      const oldPos = positionMap.get(vnode)!
      const newPos = newPositionMap.get(vnode)!
      
      const dx = oldPos.left - newPos.left
      const dy = oldPos.top - newPos.top
      
      if (dx || dy) {
        // 找到了一个需要移动的节点
        movedChildren.push(vnode)
        const el = vnode.el as HTMLElement
        // “瞬移”回去
        el.style.transform = `translate(${dx}px,${dy}px)`
        el.style.transitionDuration = '0s' // 瞬移,不要动画
      }
    }

    // 强制浏览器重绘,确保“瞬移”生效
    forceReflow()

    // 【P (Play)】
    // 遍历所有“被瞬移”的节点,开始“播放”动画
    movedChildren.forEach(vnode => {
      const el = vnode.el as HTMLElement
      // 1. 添加 v-move 动画类 (它定义了 transition: transform 0.5s)
      addTransitionClass(el, moveClass)
      // 2. 清空瞬移样式,让元素回到它“本该在”的 L (Last) 位置
      //    浏览器检测到 transform 变化,开始播放 v-move 动画
      el.style.transform = ''
      el.style.transitionDuration = ''
      
      // 3. 监听动画结束,清理 v-move 类
      el.addEventListener('transitionend', (el._moveCb = (e) => {
        // ... 清理逻辑 ...
        removeTransitionClass(el, moveClass)
      }))
    })
  })
}

总结

Vue 的动画系统是 patch 算法与 DOM 事件的结合:

  1. <Transition>

    • 拦截 insert(插入)和 remove(移除)操作。
    • 通过“三阶段”(from -> active -> to)来协调 CSS 类名或 JS 钩子的执行。
    • 通过 whenTransitionEnds 监听动画结束,并在结束后才真正执行 remove(或完成 enter)。
  2. <TransitionGroup>

    • 继承<Transition> 的所有“进入/离开”能力。
    • 额外onUpdated 钩子中实现了 FLIP 算法。
    • FLIP 通过 F(记录旧) -> L(记录新) -> I(瞬移回旧) -> P(播放动画到新) 四个步骤,实现了高性能的“移动”动画。

微信公众号二维码

Last updated: