『引言』
🙋🙋♂️提问🚩:想要给一个组件的显示和消失添加某种过渡动画,怎么办❓🤔🤔
回答📒: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>
『效果展示』
『代码解析』
上述代码中设置了一个按钮🔘控制显示和隐藏,还定义了一个show变量,初始化为false。
当show为true时,使用transition包裹起来的div中的文字显示,同时显示一个过渡动画。
当show为false时,使用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-from、v-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函数中会执行beforeEnter和enter钩子。
首先会先判断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 元素上的过渡动画执行完成后再卸载它。