Vue3 源码解读之 Transition 组件

Transition 组件的实现原理

Vue.js 3 内建的 Transition 组件可以为单个元素或单个组件添加过渡效果。它的核心实现原理如下:

  • 当 DOM 元素被挂载时,将动效附加到该 DOM 元素上;
  • 当 DOM 元素被卸载时,不要立即卸载 DOM 元素,而是等到附加到该 DOM 元素上的动效执行完成后再卸载它。

Transition 组件的基本结构

// packages/runtime-core/src/components/BaseTransition.ts

const BaseTransitionImpl: ComponentOptions = {
  name: `BaseTransition`,

  props: {
    mode: String,
    appear: Boolean,
    persisted: Boolean,
    
    // 省略部分代码
  },

  setup(props: BaseTransitionProps, { slots }: SetupContext) {
   // 省略部分代码
  }
}

export const BaseTransition = BaseTransitionImpl as any as {
  new (): {
    $props: BaseTransitionProps<any>
  }
}

从上面的代码可以看出,一个组件就是一个选项对象。Transition 组件上有 name、props、setup 等属性。其中 props 中的属性是用户使用 Transition 组件时需要传递给组件的 Props。setup 函数是组件选项,用于配置组合式API。

Transition 组件的 setup 函数

// packages/runtime-core/src/components/BaseTransition.ts

setup(props: BaseTransitionProps, { slots }: SetupContext) {
  const instance = getCurrentInstance()!
  const state = useTransitionState()

  let prevTransitionKey: any

  return () => {
    // 通过默认插槽获取需要过渡的元素
    const children =
      slots.default && getTransitionRawChildren(slots.default(), true)
    if (!children || !children.length) {
      return
    }

    // 省略部分代码

    // at this point children has a guaranteed length of 1.
    const child = children[0]

    // 省略部分代码

    return child
  }
}

setup 函数是 Vue.js 3 新增的组件选项,它通常会返回一个函数或者返回一个对象。在 Transition 组件中,setup 函数返回的是一个函数,该函数将会直接作为组件的render函数,即该render函数将会渲染添加了过渡效果的元素节点。也就是说,Transition 组件本身不会渲染任何额外的内容,它只是通过默认插槽读取过渡元素,并渲染需要过渡的元素。

给过渡元素添加 transition 钩子函数

在 setup 函数中,我们会看到 resolveTransitionHooks 函数和 setTransitionHooks 函数的调用,如下面的代码所示:

// packages/runtime-core/src/components/BaseTransition.ts

setup(props: BaseTransitionProps, { slots }: SetupContext) {
  const instance = getCurrentInstance()!
  const state = useTransitionState()

  let prevTransitionKey: any

  return () => {

    // 省略部分代码

    // in the case of <transition><keep-alive/></transition>, we need to
    // compare the type of the kept-alive children.
    // 获取被 <transition> 组件包裹的 <keep-alive/> 组件。然后需要对它们进行比较
    const innerChild = getKeepAliveChild(child)
    if (!innerChild) {
      return emptyPlaceholder(child)
    }

    // 初始化 transition 钩子函数
    const enterHooks =  resolveTransitionHooks(
      innerChild,
      rawProps,
      state,
      instance
    )
    // 给过渡元素添加 transition 钩子函数 
    setTransitionHooks(innerChild, enterHooks)


    // 省略部分代码

    // handle mode
    if (
      oldInnerChild &&
      oldInnerChild.type !== Comment &&
      (!isSameVNodeType(innerChild, oldInnerChild) || transitionKeyChanged)
    ) {
      // 离开时的 动画
      const leavingHooks = resolveTransitionHooks(
        oldInnerChild,
        rawProps,
        state,
        instance
      )
      // update old tree's hooks in case of dynamic transition
      setTransitionHooks(oldInnerChild, leavingHooks)

       // 省略部分代码

    }

    return child
  }
}

可以看到,在获取了被 组件包裹的 组件时调用了一次 resolveTransitionHooks 函数和 setTransitionHooks 函数。在处理离开时的动画模式时又调用了一次 resolveTransitionHooks 函数和 setTransitionHooks 函数。那么这两个函数有什么作用呢?接下来,我们来看看这两个函数的实现。

resolveTransitionHooks 初始化 transition 钩子函数

// 与 transition 相关的钩子函数会被添加到过渡元素的虚拟节点上
// 渲染器在渲染需要过渡的虚拟节点时,会在合适的时机调用这些钩子函数
export function resolveTransitionHooks(
  vnode: VNode,
  props: BaseTransitionProps<any>,
  state: TransitionState,
  instance: ComponentInternalInstance
): TransitionHooks {

  // Transition 组件的 props
  const {
    appear,
    mode,
    persisted = false,
    onBeforeEnter,
    onEnter,
    onAfterEnter,
    onEnterCancelled,
    onBeforeLeave,
    onLeave,
    onAfterLeave,
    onLeaveCancelled,
    onBeforeAppear,
    onAppear,
    onAfterAppear,
    onAppearCancelled
  } = props

  // 过渡元素虚拟节点的 key 
  const key = String(vnode.key)
  const leavingVNodesCache = getLeavingNodesForType(state, vnode)

  const callHook: TransitionHookCaller = (hook, args) => {
    hook &&
      callWithAsyncErrorHandling(
        hook,
        instance,
        ErrorCodes.TRANSITION_HOOK,
        args
      )
  }

  // 添加到过渡元素上的钩子函数
  const hooks: TransitionHooks<TransitionElement> = {
    mode,
    persisted,
    // 
    beforeEnter(el) {
      let hook = onBeforeEnter
      if (!state.isMounted) {
        if (appear) {
          hook = onBeforeAppear || onBeforeEnter
        } else {
          return
        }
      }
      // for same element (v-show)
      if (el._leaveCb) {
        el._leaveCb(true /* cancelled */)
      }
      // for toggled element with same key (v-if)
      const leavingVNode = leavingVNodesCache[key]
      if (
        leavingVNode &&
        isSameVNodeType(vnode, leavingVNode) &&
        leavingVNode.el!._leaveCb
      ) {
        // force early removal (not cancelled)
        leavingVNode.el!._leaveCb()
      }
      callHook(hook, [el])
    },

    enter(el) {
      let hook = onEnter
      let afterHook = onAfterEnter
      let cancelHook = onEnterCancelled
      if (!state.isMounted) {
        if (appear) {
          hook = onAppear || onEnter
          afterHook = onAfterAppear || onAfterEnter
          cancelHook = onAppearCancelled || onEnterCancelled
        } else {
          return
        }
      }
      let called = false
      const done = (el._enterCb = (cancelled?) => {
        if (called) return
        called = true
        if (cancelled) {
          callHook(cancelHook, [el])
        } else {
          callHook(afterHook, [el])
        }
        if (hooks.delayedLeave) {
          hooks.delayedLeave()
        }
        el._enterCb = undefined
      })
      if (hook) {
        hook(el, done)
        if (hook.length <= 1) {
          done()
        }
      } else {
        done()
      }
    },

    leave(el, remove) {
      const key = String(vnode.key)
      if (el._enterCb) {
        el._enterCb(true /* cancelled */)
      }
      if (state.isUnmounting) {
        return remove()
      }
      callHook(onBeforeLeave, [el])
      let called = false
      const done = (el._leaveCb = (cancelled?) => {
        if (called) return
        called = true
        remove()
        if (cancelled) {
          callHook(onLeaveCancelled, [el])
        } else {
          callHook(onAfterLeave, [el])
        }
        el._leaveCb = undefined
        if (leavingVNodesCache[key] === vnode) {
          delete leavingVNodesCache[key]
        }
      })
      leavingVNodesCache[key] = vnode
      if (onLeave) {
        onLeave(el, done)
        if (onLeave.length <= 1) {
          done()
        }
      } else {
        done()
      }
    },

    clone(vnode) {
      return resolveTransitionHooks(vnode, props, state, instance)
    }
  }

  return hooks
}

在 resolveTransitionHooks 函数中,首先从 props 对象中解构出 Transition 组件上的属性,比如 apper、mode 属性以及各种过渡事件。

然后定义了一个 hooks 对象,我们重点关注 hooks 对象中的 beforeEnter、enter 和 leave 函数。其中 beforeEnter函数 和 enter函数对应着动画进场过渡效果的 beforeEnter 阶段和 enter 阶段,如下图所示:

Vue3 源码解读之 Transition 组件

在官方文档中,定义了6个过渡class,分别是 v-enter-from、v-enter-active、v-enter-to、v-leave-from、v-leave-active、v-leave-to 。详细介绍可阅读文档。

在创建DOM元素后并挂载 DOM 元素之前,可以将这个过程视为 beforeEnter 阶段,在这个阶段,会调用 transition.beforeEnter 钩子,从而在元素上添加 v-enter-from 和 v-enter-active 类。

在挂载 DOM 元素之后,则可以视作 enter 阶段,在这个阶段,会调用 transition.enter 钩子,从而在元素上移除 v-enter-from 类,添加 v-enter-to 类。

leave 函数对应着动画离场过渡效果的 leave 阶段,如下图所示:

Vue3 源码解读之 Transition 组件

在卸载DOM元素之前的过程,可以将其视作 leave 阶段。在这个阶段,会调用 transition.leave 钩子函数,从而在元素上添加 v-leave-from、v-leave-active 和 v-leave-to 类。

最后将该 hooks 对象返回,该 hooks 对象将会被谁使用呢?接下来,我们来看 setTransitionHooks 函数。

setTransitionHooks 设置 transition 钩子函数

// 给需要过渡的元素的虚拟节点添加 transition 钩子函数
export function setTransitionHooks(vnode: VNode, hooks: TransitionHooks) {
  if (vnode.shapeFlag & ShapeFlags.COMPONENT && vnode.component) {
    // vnode 是组件,则递归调用 setTransitionHooks
    // 在过渡元素 VNode 对象上添加transition 相应的钩子函数
    setTransitionHooks(vnode.component.subTree, hooks)
  } else if (__FEATURE_SUSPENSE__ && vnode.shapeFlag & ShapeFlags.SUSPENSE) {
    // vnode 是 Suspense 组件组件,调用 hooks 对象的 clone 方法
    // 设置 transition 相应的钩子函数
    vnode.ssContent!.transition = hooks.clone(vnode.ssContent!)
    vnode.ssFallback!.transition = hooks.clone(vnode.ssFallback!)
  } else {
    // 在过渡元素 VNode 对象上添加transition 相应的钩子函数
    vnode.transition = hooks
  }
}

过渡生命周期钩子的执行

渲染器在渲染需要过渡的虚拟节点时,会在合适的时机调用附加到该虚拟节点上的过渡相关的生命周期函数,具体体现在 mountElement 函数以及 unmount 函数中。

在 mountElement 中执行 beforeEnter 和 enter 钩子

// packages/runtime-core/src/renderer.ts

const mountElement = (
  vnode: VNode,
  container: RendererElement,
  anchor: RendererNode | null,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  isSVG: boolean,
  slotScopeIds: string[] | null,
  optimized: boolean
) => {
  
  // 省略部分代码

  // 判断一个 VNode 是否需要过渡
  const needCallTransitionHooks =
    (!parentSuspense || (parentSuspense && !parentSuspense.pendingBranch)) &&
    transition &&
    !transition.persisted
  if (needCallTransitionHooks) {
    // 调用 transition.beforeEnter 钩子,并将 DOM 元素作为参数传递
    transition!.beforeEnter(el)
  }
  // 挂载 DOM 元素
  hostInsert(el, container, anchor)
  if (
    (vnodeHook = props && props.onVnodeMounted) ||
    needCallTransitionHooks ||
    dirs
  ) {
    queuePostRenderEffect(() => {
      vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode)
      // 调用 transition.enter 钩子,并把 DOM 元素作为参数传递
      needCallTransitionHooks && transition!.enter(el)
      dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
    }, parentSuspense)
  }
}

从上面的代码可以看到,在挂载 DOM 元素之前,会调用 transition.beforeEnter 钩子;在挂载元素之后,会调用 transition.enter 钩子,并且这两个钩子函数都接收需要过渡的 DOM 元素对象作为第一个参数。

在 unmount 中执行 leave 钩子

// packages/runtime-core/src/renderer.ts

const unmount: UnmountFn = (
  vnode,
  parentComponent,
  parentSuspense,
  doRemove = false,
  optimized = false
) => {
  const {
    type,
    props,
    ref,
    children,
    dynamicChildren,
    shapeFlag,
    patchFlag,
    dirs
  } = vnode
  
  // 省略部分代码

  if (shapeFlag & ShapeFlags.COMPONENT) {
    // 省略部分代码
  } else {
    
    // 省略部分代码
    
    if (doRemove) {
      remove(vnode)
    }
  }
  
  // 省略部分代码
  
}

可以看到,在 unmount 函中,如果传入的参数 doRemove 为 true ,则调用 remove 方法移除虚拟节点。过渡生命周期leave钩子的执行,就在 remove 函数中。代码如下:

// packages/runtime-core/src/renderer.ts

const remove: RemoveFn = vnode => {
  const { type, el, anchor, transition } = vnode
  if (type === Fragment) {
    removeFragment(el!, anchor!)
    return
  }

  if (type === Static) {
    removeStaticNode(vnode)
    return
  }

  // 将卸载动作封装到 performRemove 函数中
  const performRemove = () => {
    hostRemove(el!)
    if (transition && !transition.persisted && transition.afterLeave) {
      transition.afterLeave()
    }
  }

  if (
    vnode.shapeFlag & ShapeFlags.ELEMENT &&
    transition &&
    !transition.persisted
  ) {
    // 如果需要过渡处理,则调用 transition.leave 钩子,
    // 同时将 DOM 元素和 performRemove 函数作为参数传递
    const { leave, delayLeave } = transition
    const performLeave = () => leave(el!, performRemove)
    if (delayLeave) {
      // 需要延迟执行 leave
      delayLeave(vnode.el!, performRemove, performLeave)
    } else {
      // 直接执行 leave
      performLeave()
    }
  } else {
    // 如果不需要过渡处理,则直接执行卸载操作
    performRemove()
  }
}

在上面这段代码中,我们将卸载动作封装到 performRemove 函数内。如果 DOM 元素需要过渡处理,那么就需要等待过渡结束后再执行 performRemove 函数完成卸载,否则直接调用该函数完成卸载即可。

总结

本文介绍了 Transition 组件的原理与实现。它的核心原理可以总结为:在DOM元素挂载时,将动效附加到DOM元素上,而在卸载DOM元素之前,等到附加到DOM元素上的动效执行完成后再卸载DOM元素。

在实现动效的过程中,可以将其分为 beforeEnter、enter、leave 等阶段,在不同的阶段执行不同的操作。在 beforeEnter 阶段,会调用 transition.beforeEnter 钩子,在 enter 阶段会调用 transition.enter 钩子,在 leave 阶段则会调用 transition.leave 钩子。

渲染器在执行DOM元素的挂载和卸载操作时,会优先检查 vnode 节点是否需要进行过渡,如果需要,则会在合适的时机执行 vnode.transtion 对象中定义的过渡相关的钩子函数。