Vue 提供了一整套过渡的解决方案,<transition>
组件,我们可以利用它配合一些 CSS 样式很方便的实现过渡动画,也可以利用它配合 JavaScript 的钩子函数实现动画(v-if、v-show、动态组件、组件根节点);
<transition>
组件作为单个元素/组件的过渡效果,只会把过渡效果应用到其包裹的内容上,而不会额外渲染 DOM 元素,也不会出现在可被检查的组件层级中。
1、源码实现
<transition>
组件和 <keep-alive>
组件⼀样,都是 Vue 的内置组件,⽽ <transition>
的定义在 src/platforms/web/runtime/component/transtion.js
中:
export default {
name: 'transition',
props: transitionProps,
abstract: true,
render (h: Function) {
//利用slot的default 获取子节点
let children: any = this.$slots.default
//子节点不存在直接返回
if (!children) {
return
}
// 过滤文本节点(可能的空白)
children = children.filter(isNotTextNode)
//过滤之后不存在子节点直接返回
if (!children.length) {
return
}
// 警告多个元素
if (process.env.NODE_ENV !== 'production' && children.length > 1) {
warn(
'<transition> can only be used on a single element. Use ' +
'<transition-group> for lists.',
this.$parent
)
}
//警告无效的模式
const mode: string = this.mode
if (process.env.NODE_ENV !== 'production' &&
mode && mode !== 'in-out' && mode !== 'out-in'
) {
warn(
'invalid <transition> mode: ' + mode,
this.$parent
)
}
const rawChild: VNode = children[0]
// 如果这是一个组件的根节点,并且该组件的父容器节点也有转换,请跳过。
if (hasParentTransition(this.$vnode)) {
return rawChild
}
// 使用getRealChild()忽略抽象组件
const child: ?VNode = getRealChild(rawChild)
if (!child) {
return rawChild
}
//out-in模式下如果一个keep-alive 返回一个占位符
if (this._leaving) {
return placeholder(h, rawChild)
}
// 处理id和data
const id: string = `__transition-${this._uid}-`
child.key = child.key == null
? child.isComment
? id + 'comment'
: id + child.tag
: isPrimitive(child.key)
? (String(child.key).indexOf(id) === 0 ? child.key : id + child.key)
: child.key
const data: Object = (child.data || (child.data = {})).transition = extractTransitionData(this)
const oldRawChild: VNode = this._vnode
const oldChild: VNode = getRealChild(oldRawChild)
// 标记 v-show
// 这样过渡模块就可以把控制权交给指令
if (child.data.directives && child.data.directives.some(isVShowDirective)) {
child.data.show = true
}
if (
oldChild &&
oldChild.data &&
!isSameChild(child, oldChild) &&
!isAsyncPlaceholder(oldChild) &&
!(oldChild.componentInstance && oldChild.componentInstance._vnode.isComment)
) {
// 用一个对动态转换很重要的新数据替换旧的子转换数据!
const oldData: Object = oldChild.data.transition = extend({}, data)
// 处理过渡模式
if (mode === 'out-in') {
// 离开结束时返回占位符节点和队列更新
this._leaving = true
mergeVNodeHook(oldData, 'afterLeave', () => {
this._leaving = false
this.$forceUpdate()
})
return placeholder(h, rawChild)
} else if (mode === 'in-out') {
if (isAsyncPlaceholder(child)) {
return oldRawChild
}
let delayedLeave
const performLeave = () => { delayedLeave() }
mergeVNodeHook(data, 'afterEnter', performLeave)
mergeVNodeHook(data, 'enterCancelled', performLeave)
mergeVNodeHook(oldData, 'delayLeave', leave => { delayedLeave = leave })
}
}
return rawChild
}
}
1、从默认插槽中获取<transition>
包裹的⼦节点,并且判断了⼦节点的⻓度,如果⻓度为 0,则直 接返回,否则判断⻓度如果⼤于 1,也会在开发环境报警告,因为 <transition>
组件是只能包裹⼀ 个⼦节点的;
2、过渡组件的对 mode
的⽀持只有 2 种, in-out
或者是 out-in
;
3、rawChild
就是第⼀个⼦节点 vnode
,接着判断当前 <transition>
如果是组件根节点并且外⾯ 包裹该组件的容器也是 的时候要跳过,这里传⼊的是 this.$vnode
,也就是 <transition>
组件的 占位 vnode
,只有当它同时作为根 vnode
,也就是 vm._vnode
的时候,它的 parent
才不会为空,并且判断 parent
也是 <transition>
组件,才返回 true
;
4、利用 getRealChild
获取组件的⾮抽象⼦节点,因为 <transition>
很可能会包裹⼀个 keep- alive
;
5、先根据 key
等⼀系列条件获取 id
,接着从当前通过 extractTransitionData
从父组件实例上提取出过渡所需要的数据(data = props、data.camelize = listeners
);
6、对于子组件,如果使用了 v-show
就将 child.data.show
设置为 true
;
7、最后则是对新旧 child
进行比较,并对部分钩子函数进行 hook merge
等操作;
8、mergeVNodeHook
的逻辑:它会将 hook
函数合并到 def.data.hook[hookKey]
中,生成一个新的 invoker
;
2、props
<transition>
组件和 <keep-alive>
组件有⼏点实现类似,同样是抽象组件,同样直接实现 render
函数,同样利⽤了默认插槽。 <transition>
组件⾮常灵活,⽀持的 props
⾮常多:
export const transitionProps = {
name: String,
appear: Boolean,
css: Boolean,
mode: String,
type: String,
enterClass: String,
leaveClass: String,
enterToClass: String,
leaveToClass: String,
enterActiveClass: String,
leaveActiveClass: String,
appearClass: String,
appearActiveClass: String,
appearToClass: String,
duration: [Number, String, Object]
}
name:用于自动生成 CSS 过渡类名;比如 name 为 test ,自动生成:test-enter(过渡开始状态,一帧)、test-enter-to(过渡插入之后和结束之前)、test-enter-active(过渡结束状态,一帧)、test-leave、test-leave-to、test-leave-active等;
appear:是否在初始渲染时使用过渡,默认为 false;
css:是否使用 CSS 过渡类,默认为 true;设置为 false,将只通过组件事件触发注册的 JavaScript 钩子;
mode:控制离开/进入过渡的时间序列,有效的模式有 “out-in
” 和 “in-out
”;默认同时进行;
type:指定过渡事件类型,侦听过渡何时结束;有效值为 “transition
” 和 “animation
”;
duration:指定过渡的持续时间,{ enter: number, leave: number }
;
自定义类名:将name默认生成的类名替换成对应阶段自定义的类名;enterClass、leaveClass、enterToClass、leaveToClass、enterActiveClass、leaveActiveClass、appearClass、appearActiveClass、appearToClass;
3、事件
before-enter:设置过渡进入之前的组件状态;
enter:设置过渡进入完成时的组件状态;
after-enter:设置过渡进入完成之后的组件状态;
enter-cancelled:设置过渡进入取消之后的组件状态;
before-leave:设置过渡离开之前的组件状态;
leave:设置过渡离开完成时的组件状态;
after-leave:设置过渡离开完成之后的组件状态;
leave-cancelled (v-show only):设置过渡离开取消之后的组件状态;
before-appear:设置过渡显示之前的组件状态;
appear:设置过渡显示完成时的组件状态;
after-appear:设置过渡显示完成之后的组件状态;
appear-cancelled:设置过渡显示取消之后的组件状态;
4、enter & leave
<transition>
组件的实现是在 render
阶段只获取了⼀些数据,这些数据包括不同 mode 下绑定的钩子函数以及每一个钩子需要的 data 数据,并且返回了渲染的 vnode
,并没有任何和动画相关的东西,它的动画相关的逻辑全部在 src/platforms/web/modules/transition.js
中:
首先看一下下面代码:
function _enter (_: any, vnode: VNodeWithData) {
if (vnode.data.show !== true) {
enter(vnode)
}
}
export default inBrowser ? {
create: _enter,
activate: _enter,
remove (vnode: VNode, rm: Function) {
/* istanbul ignore else */
if (vnode.data.show !== true) {
leave(vnode, rm)
} else {
rm()
}
}
} : {}
从上面的代码我们能看出,在动画处理这块,它设定了两个时机,分别是:
1、在 create 和 activate 的时候执行 enter()
2、remove 的时候执行 leave()
enter
export function enter (vnode: VNodeWithData, toggleDisplay: ?() => void) {
const el: any = vnode.elm
// 如果有离开的回调函数,直接执行
if (isDef(el._leaveCb)) {
el._leaveCb.cancelled = true
el._leaveCb()
}
//解析过渡数据
const data = resolveTransition(vnode.data.transition)
if (isUndef(data)) {
return
}
// 如果 el 中存在 _enterCb 或者 el 不是元素节点,则直接返回
if (isDef(el._enterCb) || el.nodeType !== 1) {
return
}
const {
css,
type,
enterClass,
enterToClass,
enterActiveClass,
appearClass,
appearToClass,
appearActiveClass,
beforeEnter,
enter,
afterEnter,
enterCancelled,
beforeAppear,
appear,
afterAppear,
appearCancelled,
duration
} = data
// 处理边界
let context = activeInstance
let transitionNode = activeInstance.$vnode
while (transitionNode && transitionNode.parent) {
context = transitionNode.context
transitionNode = transitionNode.parent
}
const isAppear = !context._isMounted || !vnode.isRootInsert
if (isAppear && !appear && appear !== '') {
return
}
//定义过渡类名
const startClass = isAppear && appearClass
? appearClass
: enterClass
const activeClass = isAppear && appearActiveClass
? appearActiveClass
: enterActiveClass
const toClass = isAppear && appearToClass
? appearToClass
: enterToClass
//定义过渡钩⼦函数
const beforeEnterHook = isAppear
? (beforeAppear || beforeEnter)
: beforeEnter
const enterHook = isAppear
? (typeof appear === 'function' ? appear : enter)
: enter
const afterEnterHook = isAppear
? (afterAppear || afterEnter)
: afterEnter
const enterCancelledHook = isAppear
? (appearCancelled || enterCancelled)
: enterCancelled
//其它配置
const explicitEnterDuration: any = toNumber(
isObject(duration)
? duration.enter
: duration
)
// 获取 enter 动画执行时间
if (process.env.NODE_ENV !== 'production' && explicitEnterDuration != null) {
checkDuration(explicitEnterDuration, 'enter', vnode)
}
//过渡是否受到css影响
const expectsCSS = css !== false && !isIE9
//用户是否想控制CSS
const userWantsControl = getHookArgumentsLength(enterHook)
//定义过渡完成后执行的回调函数
const cb = el._enterCb = once(() => {
if (expectsCSS) {
removeTransitionClass(el, toClass)
removeTransitionClass(el, activeClass)
}
if (cb.cancelled) {
if (expectsCSS) {
removeTransitionClass(el, startClass)
}
enterCancelledHook && enterCancelledHook(el)
} else {
afterEnterHook && afterEnterHook(el)
}
el._enterCb = null
})
if (!vnode.data.show) {
// 在进入时注入一个插入钩子来移除挂起的leave元素
mergeVNodeHook(vnode, 'insert', () => {
const parent = el.parentNode
const pendingNode = parent && parent._pending && parent._pending[vnode.key]
if (pendingNode &&
pendingNode.tag === vnode.tag &&
pendingNode.elm._leaveCb
) {
pendingNode.elm._leaveCb()
}
enterHook && enterHook(el, cb)
})
}
// 开始执⾏过渡动画
beforeEnterHook && beforeEnterHook(el)
if (expectsCSS) {
addTransitionClass(el, startClass)
addTransitionClass(el, activeClass)
nextFrame(() => {
removeTransitionClass(el, startClass)
if (!cb.cancelled) {
addTransitionClass(el, toClass)
if (!userWantsControl) {
if (isValidDuration(explicitEnterDuration)) {
setTimeout(cb, explicitEnterDuration)
} else {
whenTransitionEnds(el, type, cb)
}
}
}
})
}
//有show
if (vnode.data.show) {
toggleDisplay && toggleDisplay()
enterHook && enterHook(el, cb)
}
//用户不控制,css也不
if (!expectsCSS && !userWantsControl) {
cb()
}
}
1、首先对过渡数据进行处理,resolveTransition
会通过autoCssTransition
来处理 name
属性,生成一个用来描述各个阶段的 class
名称的对象,扩展到 def
种并返回给 data
,这样 data
种就可以获取到过渡相关的所有数据了;
2、接下来处理当 <transition>
作为⼦组件的根节点,那么我们需要检查它的⽗组件作为 appear
的检查; isAppear
表⽰当前上下⽂实例还没有 mounted
,第⼀次出现的时机;如果是第⼀次并且 <transition>
组件没有配置 appear
的话,直接返回;
3、对于过渡类名:
startClass
定义进入过渡的开始状态,在元素插入时生效,在下一个帧移除;
activeClass
定义过渡的状态,在元素整个过渡过程中作⽤,在元素被插⼊时⽣效,在 transition/animation 完成之后移除;
toClass
定义进⼊过渡的结束状态,在元素被插⼊⼀帧后 ⽣效 (与此同时 startClass 被删除),在<transition>/animation
完成之后移除;
4、对于过渡钩⼦函数:
beforeEnterHook
是过渡开始前执⾏的钩⼦函数;
enterHook
是在元素 插⼊后或者是 v-show 显⽰切换后执⾏的钩⼦函数;
afterEnterHook
是在过渡动画执⾏完后的钩 ⼦函数;
enterCancelledHook
5、其他:
explicitEnterDuration
动画执行时间;
expectsCSS
是否受到css影响;
userWantsControl
用户是否想控制css;
cb
定义过渡完成后执行的回调函数;
6、没有 show
属性时,调用 mergeVNodeHook
把 hook
函数合并到 def.data.hook[hookey]
中,⽣成新 的 invoker , createFnInvoker
⽅法;
7、开始执行动画,beforeEnterHook
钩子并将 el
传入;然后判断 expectsCSS
,如果为 true 则表明希望⽤ CSS 来控制动画,那么会执⾏ addTransitionClass(el, startClass) 和 addTransitionClass(el, activeClass)
;
8、addTransitionClass
主要是给当前 dom 元素 el 添加样式,这里添加的 startClass
和 activeClass
,就相当于给 name 属性添加了 xxx-enter 和 xxx-enter-active
样式;
9、然后执行 nextFrame()
进入下一帧,下一帧主要是移除掉上一帧增加好的 class;然后判断此时过渡没有被取消,没有则执⾏ addTransitionClass(el, toClass)
添加 toClass
;之后如果用户没有通过 enterHook
钩子函数来控制动画,此时若用户指定了 duration
时间,则执行 setTimeout
进行 duration
时长的延时,否则执行 whenTransitionEnds
决定 cb
的执行时机;
10、whenTransitionEnds
:通过 getTransitionInfo
获取到 transition
的一些信息,如 type、timeout、propCount
,并为 el
元素绑定上 onEnd
。随后不停执行下一帧,更新 ended
值,直到动画结束。则将 el
元素的 onEnd
移除,并执行 cb
函数;
11、执行 cb 函数, 把toClass
和 activeClass
移除,然后判断如果有没有取消,如果取消则移除 startClass
并执⾏ enterCancelledHook
,否则执⾏ afterEnterHook(el)
;
12、如果有 show
属性,则直接执行enterHook
;
13、如果不受css影响也不受用户控制则直接执行 cb
函数;
leave
export function leave (vnode: VNodeWithData, rm: Function) {
const el: any = vnode.elm
// 如果有进入的回调函数,直接执行
if (isDef(el._enterCb)) {
el._enterCb.cancelled = true
el._enterCb()
}
//解析过渡数据
const data = resolveTransition(vnode.data.transition)
if (isUndef(data) || el.nodeType !== 1) {
return rm()
}
//el中存在_leaveCb之直接返回
if (isDef(el._leaveCb)) {
return
}
const {
css,
type,
leaveClass,
leaveToClass,
leaveActiveClass,
beforeLeave,
leave,
afterLeave,
leaveCancelled,
delayLeave,
duration
} = data
//过渡是否受到css影响
const expectsCSS = css !== false && !isIE9
//用户是否想控制CSS
const userWantsControl = getHookArgumentsLength(leave)
//动画执行时间
const explicitLeaveDuration: any = toNumber(
isObject(duration)
? duration.leave
: duration
)
// 获取 leave 动画执行时间
if (process.env.NODE_ENV !== 'production' && isDef(explicitLeaveDuration)) {
checkDuration(explicitLeaveDuration, 'leave', vnode)
}
//定义过渡完成后执行的回调函数
const cb = el._leaveCb = once(() => {
if (el.parentNode && el.parentNode._pending) {
el.parentNode._pending[vnode.key] = null
}
if (expectsCSS) {
removeTransitionClass(el, leaveToClass)
removeTransitionClass(el, leaveActiveClass)
}
if (cb.cancelled) {
if (expectsCSS) {
removeTransitionClass(el, leaveClass)
}
leaveCancelled && leaveCancelled(el)
} else {
rm()
afterLeave && afterLeave(el)
}
el._leaveCb = null
})
if (delayLeave) {
delayLeave(performLeave)
} else {
performLeave()
}
function performLeave () {
// the delayed leave may have already been cancelled
if (cb.cancelled) {
return
}
// record leaving element
if (!vnode.data.show && el.parentNode) {
(el.parentNode._pending || (el.parentNode._pending = {}))[(vnode.key: any)] = vnode
}
beforeLeave && beforeLeave(el)
if (expectsCSS) {
addTransitionClass(el, leaveClass)
addTransitionClass(el, leaveActiveClass)
nextFrame(() => {
removeTransitionClass(el, leaveClass)
if (!cb.cancelled) {
addTransitionClass(el, leaveToClass)
if (!userWantsControl) {
if (isValidDuration(explicitLeaveDuration)) {
setTimeout(cb, explicitLeaveDuration)
} else {
whenTransitionEnds(el, type, cb)
}
}
}
})
}
leave && leave(el, cb)
if (!expectsCSS && !userWantsControl) {
cb()
}
}
}
leave
的实现和 enter
的实现基本上的一样的,不同的是从 data
中解析出来的是 leave
相关的样式类名和钩子函数,还有一点不同是可以配置 delayLeave
,它是一个函数,可以延时执行 leave
的相关过渡动画,在 leave
动画执行完成之后,它会执行 rm
函数 把节点从 DOM 中移除;
总结
Vue 的过渡实现步骤:
1、自动检测目标元素是否使用了 CSS 过渡动画,如果有则在恰当的时机添加和删除 CSS类名;
2、如果过渡组件提供了 javascript 钩子函数,这些钩子函数将在恰当的时机调用;
3、如果没有 javascript 钩子,也没有 CSS 过渡动画,DOM 操作(插入和删除)在下一帧中立即执行;
所有执行的动画其实是 CSS 或者 javascript 钩子函数提供的,而 transition 只是帮我们管理这些动画,控制 CSS 的添加和删除,把控 javascript 钩子函数执行时机。