Appearance
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 函数,启动一个“进入”流程:
onBeforeEnter(JS 钩子) /v-enter-from(CSS 类):- 设置动画的“起始状态”(如
opacity: 0)。 addTransitionClass(el, enterFromClass)
- 设置动画的“起始状态”(如
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。
- 在下一帧(
等待动画结束 (
whenTransitionEnds):- Vue 会监听
transitionend或animationend事件。 - 如果 JS 钩子
onEnter接受了done参数,Vue 会等待done()被调用。 - (同时启动一个
setTimeout作为“兜底”,防止事件丢失)。
- Vue 会监听
onAfterEnter(JS 钩子) / 清理 CSS:- 动画结束,调用
finishEnter。 - 清理所有过渡类名(
v-enter-active,v-enter-to)。
- 动画结束,调用
performLeave:拦截“移除”操作
当 v-if="false" 触发“离开”时,<Transition> 不会立即 remove 元素。它会:
onBeforeLeave(JS 钩子) /v-leave-from(CSS 类):- 设置离开的“起始状态”(如
opacity: 1)。 addTransitionClass(el, leaveFromClass)
- 设置离开的“起始状态”(如
onLeave(JS 钩子) /v-leave-active(CSS 类):- 在下一帧执行。
addTransitionClass(el, leaveActiveClass)- 【触发动画】:移除
v-leave-from,添加v-leave-to(如opacity: 0)。 - 浏览器开始播放
transition。
等待动画结束 (
whenTransitionEnds):- 同上,等待
transitionend事件或done()调用。
- 同上,等待
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,并添加 CSStransition(v-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 事件的结合:
<Transition>:- 拦截
insert(插入)和remove(移除)操作。 - 通过“三阶段”(
from->active->to)来协调 CSS 类名或 JS 钩子的执行。 - 通过
whenTransitionEnds监听动画结束,并在结束后才真正执行remove(或完成enter)。
- 拦截
<TransitionGroup>:- 继承了
<Transition>的所有“进入/离开”能力。 - 额外在
onUpdated钩子中实现了 FLIP 算法。 - FLIP 通过
F(记录旧)->L(记录新)->I(瞬移回旧)->P(播放动画到新)四个步骤,实现了高性能的“移动”动画。
- 继承了
