动画一直是前端比较纠结的点,容易被忽视却又是那么重要,能写出让人感到愉悦自然的交互体验确实能为项目增色不少,毕竟这是上手就能感受到的,所以很有必要对vue
的transition
组件实现原理一探究竟。transition
组件的动画实现分为两种,使用Css
类名和JavaScript
钩子,接下来依次介绍。
这是一个抽象组件,也就是说在组件渲染完成后,不会以任何Dom
的形式表现出现,只是以插槽的形式对内部的子节点进行控制。它的作用是在合适的时机进行Css
类名的添加/删除或执行JavaScript
钩子来达到动画执行的目的。
既然是组件,那么在生成为真实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
类名实现方式的原理,现在已经拿到对应的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
钩子是怎么实现的。
在知道了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
钩子,则也是按照顺序的执行指定的函数,而这些函数也是需要用户自己定义,组件只是控制这个的流程而已。下一篇: 埋头书写中...
顺手点个赞或关注呗,找起来也方便~
分享一个笔者写的组件库,说不定哪天用的上了 ~ ↓