「Vue3学习篇」-Transition

119 阅读9分钟

『引言』

🙋🙋‍♂️提问🚩:想要给一个组件的显示和消失添加某种过渡动画,怎么办❓🤔🤔

回答📒:Vue中提供了一些内置组件和对应的API来完成动画,实现过渡动画效果。

『Transition』

『定义』

【官方解释】单个元素或组件提供动画过渡效果。

【我的理解】 仅支持单个元素或组件作为其插槽内容,如果内容是一个组件,这个组件必须仅有一个根元素。

官网示例🌰

『用法』

<template>
  <div>
    <button @click="toggle">显示/隐藏</button>
    <transition>
      <div v-if="show">你好,我是wnxx</div>
    </transition>
  </div>
</template>
<script setup>
import { ref } from 'vue'
const show = ref(false)
const toggle = () => {
  show.value = !show.value
  console.log(show.value, 'isShow')
}
</script>
<style scoped>
.v-enter-active,
.v-leave-active {
  transition: opacity 1s ease;
}
.v-enter-from,
.v-leave-to {
  opacity: 0;
}
</style>

『效果展示』

transition.png

『代码解析』

上述代码中设置了一个按钮🔘控制显示和隐藏,还定义了一个show变量,初始化为false

showtrue时,使用transition包裹起来的div中的文字显示,同时显示一个过渡动画。

showfalse时,使用transition包裹起来的div中的文字隐藏。

通过使用<transition>组件,可以定义CSS过渡效果,使div中的文字出现和隐藏更加平滑。

关于transition的用法这里不做过多的介绍,主要分析一下transition的源码。

『transition源码』

『transition』

transition源码如下⬇️:

const BaseTransitionImpl: ComponentOptions = {
  name: `BaseTransition`,
  props: BaseTransitionPropsValidators,
  setup(props: BaseTransitionProps, { slots }: SetupContext) {
    //省略代码
  },
}

『transition中的setup函数』

transition中的setup函数源码如下⬇️:

setup(props: BaseTransitionProps, { slots }: SetupContext) {
    //省略部分代码
    return () => {
      //省略部分代码
      let child: VNode = children[0]
      if (children.length > 1) {
        let hasFound = false
        for (const c of children) {
          if (c.type !== Comment) {
            if (__DEV__ && hasFound) {
              warn(
                '<transition> can only be used on a single element or component. ' +
                  'Use <transition-group> for lists.',
              )
              break
            }
            child = c
            hasFound = true
            if (!__DEV__) break
          }
        }
      }
      const rawProps = toRaw(props)
      const { mode } = rawProps
      if (
        __DEV__ &&
        mode &&
        mode !== 'in-out' &&
        mode !== 'out-in' &&
        mode !== 'default'
      ) {
        warn(`invalid <transition> mode: ${mode}`)
      }
      if (state.isLeaving) {
        return emptyPlaceholder(child)
      }
     //省略部分代码
      //获取当前元素
      const oldChild = instance.subTree
      const oldInnerChild = oldChild && getKeepAliveChild(oldChild)
      if (
        oldInnerChild &&
        oldInnerChild.type !== Comment &&
        !isSameVNodeType(innerChild, oldInnerChild)
      ) {
        //省略部分代码
        }
      }
      return child
    }
  },

setup函数代码解析』

setup函数的代码还是比较长的,需要一点点的看,setup函数中先是获取当前组件的实例。

然后看到的是setup函数返回的是一个函数,定义了一个children变量,会通过默认插槽获取到需要过渡的元素。

setup部分函数源码如下⬇️:

setup(props: BaseTransitionProps, { slots }: SetupContext) {
    const instance = getCurrentInstance()!
    const state = useTransitionState()
      return () => {
         const children =
           slots.default && getTransitionRawChildren(slots.default(), true)
           if (!children || !children.length) {
             return
           }
     }
}

『过渡模式代码解析』

过渡模式:

  • out-in模式,会为当前旧元素新增afterLeave钩子afterLeave的执行会使当前实例触发updateEffect,进入更新阶段。

  • in-out模式,会为当前元素新增一个推迟当前元素离开动效执行的delayLeave钩子,其中,earlyRemove回调负责元素的移动或者移除, delayedLeave回调负责推迟leave钩子

    首先会调用getLeavingNodesForType函数获取缓存,然后更新缓存,为当前元素定义一个私有leave回调,在新元素上绑定delayedLeave钩子,用于推迟当前元素的离开动效。

过渡模式源码如下⬇️:

if (mode === 'out-in') {
       state.isLeaving = true
          leavingHooks.afterLeave = () => {
            state.isLeaving = false
            if (instance.update.active !== false) {
              instance.effect.dirty = true
              instance.update()
            }
          }
          return emptyPlaceholder(child)
        } else if (mode === 'in-out' && innerChild.type !== Comment) 
          leavingHooks.delayLeave = (
            el: TransitionElement,
            earlyRemove,
            delayedLeave,
          ) => {
            const leavingVNodesCache = getLeavingNodesForType(
              state,
              oldInnerChild,
            )
            leavingVNodesCache[String(oldInnerChild.key)] = oldInnerChild
            el[leaveCbKey] = () => {
              earlyRemove()
              el[leaveCbKey] = undefined
              delete enterHooks.delayedLeave
            }
            enterHooks.delayedLeave = delayedLeave
          }

『getLeavingNodesForType代码解析』

getLeavingNodesForType函数主要是根据当前元素类型获取Vnode缓存。其中leavingVNodes负责缓存当前(旧)元素vnode。

getLeavingNodesForType函数源码如下⬇️:

function getLeavingNodesForType(
  state: TransitionState,
  vnode: VNode,
): Record<string, VNode> {
  const { leavingVNodes } = state
  let leavingVNodesCache = leavingVNodes.get(vnode.type)!
  if (!leavingVNodesCache) {
    leavingVNodesCache = Object.create(null)
    leavingVNodes.set(vnode.type, leavingVNodesCache)
  }
  return leavingVNodesCache
}

『代码解析』

这里将获取<transition>组件包裹的keep-alive组件赋值给innerChild变量,然后判断innerChild变量存在,进行节点比较,因为keep-alive组件中,会对子节点进行缓存,所以需要对每个子节点进行比较。

源码如下⬇️:

const innerChild = getKeepAliveChild(child)
      if (!innerChild) {
        return emptyPlaceholder(child)
      }

『getKeepAliveChild函数』

getKeepAliveChild函数源码如下⬇️:

function getKeepAliveChild(vnode: VNode): VNode | undefined {
  return isKeepAlive(vnode)
    ? // #7121 ensure get the child component subtree in case
      // it's been replaced during HMR
      __DEV__ && vnode.component
      ? vnode.component.subTree
      : vnode.children
        ? ((vnode.children as VNodeArrayChildren)[0] as VNode)
        : undefined
    : vnode
}

『emptyPlaceholder函数』

emptyPlaceholder函数源码如下⬇️:

function emptyPlaceholder(vnode: VNode): VNode | undefined {
  if (isKeepAlive(vnode)) {
    vnode = cloneVNode(vnode)
    vnode.children = null
    return vnode
  }
}

『进入/离开动画代码解析』

这里会调用resolveTransitionHooks函数初始化一个transition钩子函数,进入时的动画enterHooks,然后会调用setTransitionHooks函数给innerChild变量过渡元素添加钩子函数。

在离开时的动画也会调用resolveTransitionHooks函数,然后调用setTransitionHooks函数,对旧树的钩子函数进行更新。

进入动画源码如下⬇️:

const enterHooks = resolveTransitionHooks(
        innerChild,
        rawProps,
        state,
        instance,
      )
      setTransitionHooks(innerChild, enterHooks)

离开动画源码如下⬇️:

const leavingHooks = resolveTransitionHooks(
          oldInnerChild,
          rawProps,
          state,
          instance,
        )
        setTransitionHooks(oldInnerChild, leavingHooks)

『resolveTransitionHooks代码解析』

props解构Transition组件的属性,定义了过渡元素虚拟节点的key、添加到过渡元素上的钩子函数hooks对象

钩子函数hooks对象包括:

  • 进入前的过渡动画beforeEnter
    • beforeEnter函数中对三种情况进行了处理,处理同一元素的v-show /v-if 过渡效果情况和处理旧的vnode在新的vnode中不存在的情况。
    • 当旧的vnode在新的vnode中不存在时,会调用 _leaveCb 函数,强制删除旧的vnode。
    • 最后元素会添加 v-enter-from 和 v-enter-active 类。
  • 进入的过渡函数enter
    • enter函数中会给元素移除 v-enter-from 类,添加 v-enter-to 类。
  • 离开的过渡函数leave
    • leave函数中会给元素添加 v-leave-fromv-leave-active 和 v-leave-to 类。 最后将hooks对象返回。

resolveTransitionHooks源码如下⬇️:

export function resolveTransitionHooks(
  vnode: VNode,
  props: BaseTransitionProps<any>,
  state: TransitionState,
  instance: ComponentInternalInstance,
): TransitionHooks {
  const {
    appear,
    mode,
    persisted = false,
    onBeforeEnter,
    onEnter,
    onAfterEnter,
    onEnterCancelled,
    onBeforeLeave,
    onLeave,
    onAfterLeave,
    onLeaveCancelled,
    onBeforeAppear,
    onAppear,
    onAfterAppear,
    onAppearCancelled,
  } = props
  const key = String(vnode.key)
  const leavingVNodesCache = getLeavingNodesForType(state, vnode)

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

  const callAsyncHook = (
    hook: Hook<(el: any, done: () => void) => void>,
    args: [TransitionElement, () => void],
  ) => {
    const done = args[1]
    callHook(hook, args)
    if (isArray(hook)) {
      if (hook.every(hook => hook.length <= 1)) done()
    } else if (hook.length <= 1) {
      done()
    }
  }

  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[leaveCbKey]) {
        el[leaveCbKey](true /* cancelled */)
      }
      // for toggled element with same key (v-if)
      const leavingVNode = leavingVNodesCache[key]
      if (
        leavingVNode &&
        isSameVNodeType(vnode, leavingVNode) &&
        (leavingVNode.el as TransitionElement)[leaveCbKey]
      ) {
        // force early removal (not cancelled)
        ;(leavingVNode.el as TransitionElement)[leaveCbKey]!()
      }
      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[enterCbKey] = (cancelled?) => {
        if (called) return
        called = true
        if (cancelled) {
          callHook(cancelHook, [el])
        } else {
          callHook(afterHook, [el])
        }
        if (hooks.delayedLeave) {
          hooks.delayedLeave()
        }
        el[enterCbKey] = undefined
      })
      if (hook) {
        callAsyncHook(hook, [el, done])
      } else {
        done()
      }
    },

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

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

  return hooks
}

『setTransitionHooks代码解析』

setTransitionHooks函数中,主要是给需要过渡的元素的虚拟节点添加transition钩子函数。 分为:

  • 会先判断vnode是否是组件,如果vnode是组件的话,会在过渡元素对象上添加对应的钩子函数。
  • 如果是Suspense组件,调用hooks对象的clone方法,添加对应的钩子函数。

setTransitionHooks源码如下⬇️:

export function setTransitionHooks(vnode: VNode, hooks: TransitionHooks) {
  if (vnode.shapeFlag & ShapeFlags.COMPONENT && vnode.component) {
    setTransitionHooks(vnode.component.subTree, hooks)
  } else if (__FEATURE_SUSPENSE__ && vnode.shapeFlag & ShapeFlags.SUSPENSE) {
    vnode.ssContent!.transition = hooks.clone(vnode.ssContent!)
    vnode.ssFallback!.transition = hooks.clone(vnode.ssFallback!)
  } else {
    vnode.transition = hooks
  }
}

『过渡生命周期函数』

『mountElement代码解析』

mountElement函数中会执行beforeEnterenter钩子。 首先会先判断VNode是否需要过渡。

  • 如果需要过渡,就会调用beforeEnter钩子函数,将DOM元素作为参数传递
  • 然后将DOM元素挂载
  • 最后会调用enter钩子函数,将DOM元素作为参数传递
  • hostInsert函数负责将el插入container

mountElement源码如下⬇️:

const mountElement = (
    vnode: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    namespace: ElementNamespace,
    slotScopeIds: string[] | null,
    optimized: boolean,
  ) => {
    let el: RendererElement
    let vnodeHook: VNodeHook | undefined | null
    const { props, shapeFlag, transition, dirs } = vnode

    el = vnode.el = hostCreateElement(
      vnode.type as string,
      namespace,
      props && props.is,
      props,
    )
    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
      hostSetElementText(el, vnode.children as string)
    } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      mountChildren(
        vnode.children as VNodeArrayChildren,
        el,
        null,
        parentComponent,
        parentSuspense,
        resolveChildrenNamespace(vnode, namespace),
        slotScopeIds,
        optimized,
      )
    }

    if (dirs) {
      invokeDirectiveHook(vnode, null, parentComponent, 'created')
    }
    setScopeId(el, vnode, vnode.scopeId, slotScopeIds, parentComponent)
    if (props) {
      for (const key in props) {
        if (key !== 'value' && !isReservedProp(key)) {
          hostPatchProp(
            el,
            key,
            null,
            props[key],
            namespace,
            vnode.children as VNode[],
            parentComponent,
            parentSuspense,
            unmountChildren,
          )
        }
      }
      if ('value' in props) {
        hostPatchProp(el, 'value', null, props.value, namespace)
      }
      if ((vnodeHook = props.onVnodeBeforeMount)) {
        invokeVNodeHook(vnodeHook, parentComponent, vnode)
      }
    }

    if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
      Object.defineProperty(el, '__vnode', {
        value: vnode,
        enumerable: false,
      })
      Object.defineProperty(el, '__vueParentComponent', {
        value: parentComponent,
        enumerable: false,
      })
    }
    if (dirs) {
      invokeDirectiveHook(vnode, null, parentComponent, 'beforeMount')
    }
    const needCallTransitionHooks = needTransition(parentSuspense, transition)
    if (needCallTransitionHooks) {
      transition!.beforeEnter(el)
    }
    hostInsert(el, container, anchor)
    if (
      (vnodeHook = props && props.onVnodeMounted) ||
      needCallTransitionHooks ||
      dirs
    ) {
      queuePostRenderEffect(() => {
        vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode)
        needCallTransitionHooks && transition!.enter(el)
        dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
      }, parentSuspense)
    }
  }
、

『unmount代码解析』

在 unmount 函数中,如果传入的参数 doRemove 为 true ,则调用 remove 方法。

unmount源码如下⬇️:

const unmount: UnmountFn = (
    vnode,
    parentComponent,
    parentSuspense,
    doRemove = false,
    optimized = false,
  ) => {
    const {
      type,
      props,
      ref,
      children,
      dynamicChildren,
      shapeFlag,
      patchFlag,
      dirs,
    } = vnode
    // unset ref
    if (ref != null) {
      setRef(ref, null, parentSuspense, vnode, true)
    }

    if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
      ;(parentComponent!.ctx as KeepAliveContext).deactivate(vnode)
      return
    }

    const shouldInvokeDirs = shapeFlag & ShapeFlags.ELEMENT && dirs
    const shouldInvokeVnodeHook = !isAsyncWrapper(vnode)

    let vnodeHook: VNodeHook | undefined | null
    if (
      shouldInvokeVnodeHook &&
      (vnodeHook = props && props.onVnodeBeforeUnmount)
    ) {
      invokeVNodeHook(vnodeHook, parentComponent, vnode)
    }

    if (shapeFlag & ShapeFlags.COMPONENT) {
      unmountComponent(vnode.component!, parentSuspense, doRemove)
    } else {
      if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
        vnode.suspense!.unmount(parentSuspense, doRemove)
        return
      }

      if (shouldInvokeDirs) {
        invokeDirectiveHook(vnode, null, parentComponent, 'beforeUnmount')
      }

      if (shapeFlag & ShapeFlags.TELEPORT) {
        ;(vnode.type as typeof TeleportImpl).remove(
          vnode,
          parentComponent,
          parentSuspense,
          optimized,
          internals,
          doRemove,
        )
      } else if (
        dynamicChildren &&
        // #1153: fast path should not be taken for non-stable (v-for) fragments
        (type !== Fragment ||
          (patchFlag > 0 && patchFlag & PatchFlags.STABLE_FRAGMENT))
      ) {
        unmountChildren(
          dynamicChildren,
          parentComponent,
          parentSuspense,
          false,
          true,
        )
      } else if (
        (type === Fragment &&
          patchFlag &
            (PatchFlags.KEYED_FRAGMENT | PatchFlags.UNKEYED_FRAGMENT)) ||
        (!optimized && shapeFlag & ShapeFlags.ARRAY_CHILDREN)
      ) {
        unmountChildren(children as VNode[], parentComponent, parentSuspense)
      }

      if (doRemove) {
        remove(vnode)
      }
    }

    if (
      (shouldInvokeVnodeHook &&
        (vnodeHook = props && props.onVnodeUnmounted)) ||
      shouldInvokeDirs
    ) {
      queuePostRenderEffect(() => {
        vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode)
        shouldInvokeDirs &&
          invokeDirectiveHook(vnode, null, parentComponent, 'unmounted')
      }, parentSuspense)
    }
  }

『remove代码解析』

remove函数是用来移除虚拟节点的。

remove函数源码如下⬇️:

const remove: RemoveFn = vnode => {
    const { type, el, anchor, transition } = vnode
    if (type === Fragment) {
      if (
        __DEV__ &&
        vnode.patchFlag > 0 &&
        vnode.patchFlag & PatchFlags.DEV_ROOT_FRAGMENT &&
        transition &&
        !transition.persisted
      ) {
        ;(vnode.children as VNode[]).forEach(child => {
          if (child.type === Comment) {
            hostRemove(child.el!)
          } else {
            remove(child)
          }
        })
      } else {
        removeFragment(el!, anchor!)
      }
      return
    }

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

『performRemove代码解析』

performRemove函数中封装了卸载的动作,performLeave函数负责leave钩子的调用,最终通过leave函数完成当前元素的插入和afterLeave钩子的调用。

  • hostRemove函数会将el从其父元素中移除
  • 会调用离开过渡函数afterLeave()afterLeave钩子负责当前元素先离开的效果
  • 会判断是否需要过渡处理,如果需要的话会调用leave钩子,不需要的话直接执行卸载
  • 需要过渡处理的时候,还会进一步判断是否需要延迟执行。需要就延迟执行,就调用delayLeave函数,delayLeave钩子负责当前元素推迟离开的效果,否则就直接执行performLeave函数

performRemove函数源码如下⬇️:

const performRemove = () => {
      hostRemove(el!)
      if (transition && !transition.persisted && transition.afterLeave) {
        transition.afterLeave()
      }
    }

    if (
      vnode.shapeFlag & ShapeFlags.ELEMENT &&
      transition &&
      !transition.persisted
    ) {
      const { leave, delayLeave } = transition
      const performLeave = () => leave(el!, performRemove)
      if (delayLeave) {
        delayLeave(vnode.el!, performRemove, performLeave)
      } else {
        performLeave()
      }
    } else {
      performRemove()
    }
  }

『总结』

它的核心实现原理如下:

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