动画一直是前端比较纠结的点,容易被忽视却又是那么重要,能写出让人感到愉悦自然的交互体验确实能为项目增色不少,毕竟这是上手就能感受到的,所以很有必要对vue
的transition
组件实现原理一探究竟。transition
组件的动画实现分为两种,使用Css
类名和JavaScript
钩子,接下来依次介绍。
transition组件介绍
这是一个抽象组件,也就是说在组件渲染完成后,不会以任何Dom
的形式表现出现,只是以插槽的形式对内部的子节点进行控制。它的作用是在合适的时机进行Css
类名的添加/删除或执行JavaScript
钩子来达到动画执行的目的。
transition转VNode
既然是组件,那么在生成为真实Dom
的时候,首先需要转为VNode
,然后才是拿着这个VNode
去转为真实的Dom
。所以我们首先来看下transition
组件会变成一个什么样的VNode
。
export const transitionProps = { // transition组件接受的props属性
appear: Boolean, // 是否首次渲染
css: Boolean, // 是否取消css动画
mode: String, // in-out或out-in二选一
type: String, // 显示声明监听animation或transition
name: String, // 默认v
enterClass: String, // 默认`${name}-enter`
leaveClass: String, // 默认`${name}-leave`
enterToClass: String, // 默认`${name}-enter-to`
leaveToClass: String, // 默认`${name}-leave-to`
enterActiveClass: String, // 默认`${name}-enter-active`
leaveActiveClass: String, // 默认`${name}-leave-active`
appearClass: String, // 首次渲染时进入
appearActiveClass: String, // 首次渲染时持续
appearToClass: String, // 首次渲染时离开
duration: [Number, String, Object] // 动画时长
}
export default {
name: 'transition',
props: transitionProps,
abstract: true, // 标记为抽象组件,在vue内部不会参与父子组件的构建关系
render(h) { // 采用render函数编写,终于知道为啥叫h了
let children = this.$slots.default // 获取默认插槽内节点
if (!children) {
return
}
if (!children.length) {
return
}
if (children.length > 1) {
...插槽内只能有一个子节点
}
const mode = this.mode
if (mode && mode !== 'in-out' && mode !== 'out-in') {
...mode只能是in-out或out-in
}
const child = children[0] // 子节点对应VNode
const id = `__transition-${this._uid}-`
child.key = child.key == null // 为子节点的VNode添加key属性
? child.isComment // 注释节点
? id + 'comment'
: id + child.tag
: isPrimitive(child.key) // 原始值
? (String(child.key).indexOf(id) === 0 ? child.key : id + child.key)
: child.key
(child.data || (child.data = {})).transition = extractTransitionData(this)
// 核心!将props和钩子函数赋给子节点的transition属性,表示是一个经过transition组件渲染的VNode
return child
}
}
export function extractTransitionData(comp) { // 赋值函数
const data = {}
const options = comp.$options
for (const key in options.propsData) { // transition组件接收到的props
data[key] = comp[key]
}
const listeners = options._parentListeners // 注册在transition组件上的钩子方法
for (const key in listeners) {
data[key] = listeners[key]
}
return data
}
通过以上代码我们知道了,transition
组件主要是做两件事情,首先为渲染子节点的VNode
添加key
属性,然后是在它的data
属性下添加一个transition
属性,表示这是一个经过transition
组件渲染的VNode
,在之后path
创建真实Dom
的过程中再另外处理。
Css类名实现原理
我们首先重点来看Css
类名实现方式的原理,现在已经拿到对应的VNode
,现在就需要创建成真实的Dom
,在path
的过程中,Dom
上的style
、css
、attr
等属性都是分成的模块进行创建,这些模块都有各自的钩子函数,例如有created
、update
、insert
函数,部分模块各有不同,表示在某个时间段做某件事。transition
也不例外,首先会执行created
钩子。我们知道,transition
组件是分为enter
和leave
状态的,先看下enter
状态:
export function enter (vnode) { // 参数为组件插槽内的VNode
const el = vnode.elm // 对应真实节点
const data = resolveTransition(vnode.data.transition) // 扩展属性
// data包含了传入的props以及扩展的6个class属性
if (isUndef(data)) { // 如果不是transition渲染的vnode,再见
return
}
...
}
export function resolveTransition (def) { // 扩展属性
const res = {}
extend(res, autoCssTransition(def.name || 'v')) // class对象扩展到空对象res上
extend(res, def) // 将def上的属性扩展到res对象上
return res
}
const autoCssTransition (name) { // 生成包含6个需要使用到的class对象
return {
enterClass: `${name}-enter`,
enterToClass: `${name}-enter-to`,
enterActiveClass: `${name}-enter-active`,
leaveClass: `${name}-leave`,
leaveToClass: `${name}-leave-to`,
leaveActiveClass: `${name}-leave-active`
}
})
执行enter
,首先继续往transition
属性上扩展6
个之后会使用的class
名,我们接着往下看:
export function enter (vnode) { // 参数为组件插槽的内的VNode
...
const { // 解构出需要的参数
enterClass,
enterToClass,
enterActiveClass,
appearClass,
appearActiveClass,
appearToClass,
css,
type
// ...省略其他参数
} = data
const isAppear = !context._isMounted || !vnode.isRootInsert
// _isMounted表示组件是否mounted
// isRootInsert表示是否根节点插入
if (isAppear && !appear && appear !== '') {
// 如果没有配置appear属性,也是第一次渲染的情况直接退出,没有动画效果
return
}
const startClass = isAppear && appearClass // 如果有定义appear且有对应的appearClass
? appearClass // 执行定义的appearClass
: enterClass // 否则还是执行enterClass
const activeClass = isAppear && appearActiveClass
? appearActiveClass
: enterActiveClass
const toClass = isAppear && appearToClass
? appearToClass
: enterToClass
...
}
接下来是取出props
里以及扩展的class
值,用于之后使用。然后是appear
的实现原理,如果还没有mounted
以及不是根节点插入,且有定义appear
属性,则使用appearClass
完整执行一次enter
状态的函数,否则没有动画直接渲染。接下来是最核心的实现过程。
export function enter (vnode) {
...
const expectsCSS = css !== false && !isIE9 // 没有显性的指明不执行css动画
const cb = once(() => { // 定义只会执行一次的cb函数,只是定义并不执行
if (expectsCSS) {
removeTransitionClass(el, toClass) // 移除toClass
removeTransitionClass(el, activeClass) // 移除activeClass
}
})
if (expectsCSS) {
addTransitionClass(el, startClass) // 添加startClass
addTransitionClass(el, activeClass) // 添加activeClass
nextFrame(() => { // requestAnimationFrame的封装,下一帧浏览器渲染回调时执行
removeTransitionClass(el, startClass) // 移除startClass
addTransitionClass(el, toClass) // 添加toClass
whenTransitionEnds(el, type, cb)
// 浏览器过渡结束事件transitionend或animationend之后执行cb,移除toClass和activeClass
})
}
}
首先定义一个cb
函数,这个函数被once
函数包裹,它的作用是只让里面的函数执行一次,当然这个cb
只是定义了,并不会执行。接下来同步的为当前真实节点添加startClass
和activeClass
,也就是我们熟悉的v-enter
和v-enter-active
;之后在requestAnimationFrame
也就是浏览器渲染的下一帧移除startClass
并添加toClass
,也就是v-enter-to
;最后执行whenTransitionEnds
方法,这个方法是监听浏览器的动画结束事件,也就是transitionend
或animationend
事件,表示v-enter-active
内定义的动画或过渡结束了,结束后执行上面定义cb
,在这个函数里面移除toClass
和activeClass
。
不难发现其实enter
状态的这个函数它主要做的事情就是管理v-enter
/v-enter-active
/v-enter-to
这三个class
的添加和删除,具体的动画是用户定义的。
很自然的我们能想到,leave
状态的函数就是管理的另外三个class
的添加和删除,接下来只展示leave
的核心代码:
export function leave (vnode) {
const cb = once(() => {
removeTransitionClass(el, leaveToClass) // 移除v-leave-to
removeTransitionClass(el, leaveActiveClass) // 移除v-leave-active
})
addTransitionClass(el, leaveClass) // 添加v-leave
addTransitionClass(el, leaveActiveClass) // 添加v-leave-active
nextFrame(() => { // 浏览器下一帧执行
removeTransitionClass(el, leaveClass) // 移除v-leave
addTransitionClass(el, leaveToClass) // 添加v-leave-to
whenTransitionEnds(el, type, cb) // 在动画结束的事件之后执行cb函数
})
}
源码里还有很多边界的情况的处理,如transition
包裹又是抽象组件、执行enter
时leave
还没执行、上一个enter
没执行完又执行enter
等。感兴趣大家可自行去看完整源码实现,这里只对核心实现原理进行了分析。接下来我们看JavaScript
钩子是怎么实现的。
JavaScript钩子实现原理
在知道了Css
类名方式的实现原理后,再理解JavaScript
钩子的实现其实就不难了。钩子的实现方式也是分为enter
和leave
两种状态的,而且代码也是在这两种函数里,只是前面介绍Css
方式忽略掉了,现在我们从钩子的实现视角重新来看这两个状态函数。首先还是看enter
:
export function enter(vnode) {
if (isDef(el._leaveCb)) { // 如果进入enter时,_leaveCb没执行,立刻执行
el._leaveCb.cancelled = true // 执行了_leaveCb的标记位
el._leaveCb() // cb._leaveCb执行后会变成null
}
// el._leaveCb是leave状态里定义的cb函数,表示的是leave状态的回调函数
// 看到下面的enter的cb定义就会知道怎么肥事
const {
beforeEnter,
enter,
afterEnter,
enterCancelled,
duration
... 其他参数
} = data
const userWantsControl = getHookArgumentsLength(enter) // 传入enter钩子
// 如果钩子里enter函数的参数大于1,说明有传入done函数,表示用户想要自己控制
// 这也是为什么enter里动画结束后需要调用done函数
const cb = el._enterCb = once(() => { // 这里定义了el._enterCb函数,对应leave里就是el._leaveCb
if (cb.cancelled) { // 如果在leave的状态里,enter状态的cb函数没执行,则执行enterCancelled钩子
enterCancelled && enterCancelled(el)
} else {
afterEnter && afterEnter(el) // 否则正常的执行afterEnter钩子
}
el._enterCb = null // 执行后el._enterCb就是null了
... 省略css逻辑相关
})
mergeVNodeHook(vnode, 'insert', () => { // 将函数体插入到insert钩子内,在path中模块的created之后执行的钩子
...
enter && enter(el, cb) // 执行enter钩子,传入cb,这里的cb也就是对应enter钩子里的done函数
})
beforeEnter && beforeEnter(el)
nextFrame(() => {
if (!userWantsControl) { // 如果用户不想控制
if (duration) { // 如果有指定合法的过渡时间参数
setTimeout(cb, duration) // setTimeout之后执行cb
} else {
whenTransitionEnds(el, type, cb) // 浏览器过渡结束之后的事件之后执行
}
}
})
}
以上代码就是JavaScript
钩子实现的原理,这里一定要注意它们的执行顺序:
- 首先执行
beforeEnter
钩子,因为这个是同步的,cb
只是定义了,insert
是在created
之后执行,nextFrame
里面的是浏览器的下一帧,是异步的。 - 执行插入到
insert
钩子里的函数体,这也是属于同步,只是在created
之后,执行里面的enter
钩子。 - 如果用户不想控制动画的结束,执行
nextFrame
里的函数体。 - 如果用户想控制,也就是调用了
done
函数,直接直接cb
函数,正常来说执行里面的afterEnter
钩子。
leave
状态还是只贴出核心代码,供大家和enter
比对,它们的区别不是很大:
export function leave(vnode) {
const {
beforeLeave,
leave,
afterLeave,
duration
... 省略其他参数
} = data
const cb = once(() => {
afterLeave && afterLeave(el)
...
})
beforeLeave && beforeLeave(el)
nextFrame(() => {
if (!userWantsControl) { // 用户不想控制
if (isValidDuration(duration)) {
setTimeout(cb, duration)
} else {
whenTransitionEnds(el, type, cb)
}
}
})
leave && leave(el, cb) // 用户想控制这里执行done
}
leave
状态里钩子的执行顺序就是beforeLeave
、leave
、afterLeave
。
至此,transition
内置组件的两种实现原理就全部解析完了。源码里考虑的边界情况会多很多,需要更加全面的了解,则需要看源码了。
笔者看完这个transition
原理后有点小失望,原来并不能让我成为动画高手,最重要的还是Css
的那些动画知识,可见地基打牢的重要性!
最后还是以一道面试官可能会问到的题目作为结束,因为我真的被问到过。
面试官微笑而又不失礼貌的问道:
- 请说明下
transition
组件的实现原理?
怼回去:
-
transition
组件是一个抽象组件,不会渲染出任何的Dom
,它主要是帮助我们更加方便的写出动画。以插槽的形式对内部单一的子节点进行动画的管理,在渲染阶段就会往子节点的虚拟Dom
上挂载一个transition
属性,表示它的一个被transition
组件包裹的节点,在path
阶段就会执行transition
组件内部钩子,钩子里分为enter
和leave
状态,在这个被包裹的子节点上使用v-if
或v-show
进行状态的切换。你可以使用Css
也可以使用JavaScript
钩子,使用Css
方式时会在enter/leave
状态内进行class
类名的添加和删除,用户只需要写出对应类名的动画即可。如果使用JavaScript
钩子,则也是按照顺序的执行指定的函数,而这些函数也是需要用户自己定义,组件只是控制这个的流程而已。
下一篇: 埋头书写中...
顺手点个赞或关注呗,找起来也方便~
参考:
分享一个笔者写的组件库,说不定哪天用的上了 ~ ↓
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。