transition

在我们平时的前端项目开发中,经常会遇到如下需求,一个 DOM 节点的插入和删除或者是显示和隐藏,我们不想让它特别生硬,通常会考虑加一些过渡效果。

Vue.js 除了实现了强大的数据驱动,组件化的能力,也给我们提供了一整套过渡的解决方案。它内置了 <transition> 组件,我们可以利用它配合一些 CSS3 样式很方便地实现过渡动画,也可以利用它配合 JavaScript 的钩子函数实现过渡动画,在下列情形中,可以给任何元素和组件添加 entering/leaving 过渡:

  • 条件渲染 (使用 v-if)
  • 条件展示 (使用 v-show)
  • 动态组件
  • 组件根节点
    那么举一个最简单的实例,如下:
  1. let vm = new Vue({
  2. el: '#app',
  3. template: '<div id="demo">' +
  4. '<button v-on:click="show = !show">' +
  5. 'Toggle' +
  6. '</button>' +
  7. '<transition :appear="true" name="fade">' +
  8. '<p v-if="show">hello</p>' +
  9. '</transition>' +
  10. '</div>',
  11. data() {
  12. return {
  13. show: true
  14. }
  15. }
  16. })
  1. .fade-enter-active, .fade-leave-active {
  2. transition: opacity .5s;
  3. }
  4. .fade-enter, .fade-leave-to {
  5. opacity: 0;
  6. }

当我们点击按钮切换显示状态的时候,被 <transition> 包裹的内容会有过渡动画。那么接下来我们从源码的角度来分析它的实现原理。

内置组件

<transition> 组件和 <keep-alive> 组件一样,都是 Vue 的内置组件,而 <transition> 的定义在 src/platforms/web/runtime/component/transtion.js 中,之所以在这里定义,是因为 <transition> 组件是 web 平台独有的,先来看一下它的实现:

  1. export default {
  2. name: 'transition',
  3. props: transitionProps,
  4. abstract: true,
  5. render (h: Function) {
  6. let children: any = this.$slots.default
  7. if (!children) {
  8. return
  9. }
  10. // filter out text nodes (possible whitespaces)
  11. children = children.filter((c: VNode) => c.tag || isAsyncPlaceholder(c))
  12. /* istanbul ignore if */
  13. if (!children.length) {
  14. return
  15. }
  16. // warn multiple elements
  17. if (process.env.NODE_ENV !== 'production' && children.length > 1) {
  18. warn(
  19. '<transition> can only be used on a single element. Use ' +
  20. '<transition-group> for lists.',
  21. this.$parent
  22. )
  23. }
  24. const mode: string = this.mode
  25. // warn invalid mode
  26. if (process.env.NODE_ENV !== 'production' &&
  27. mode && mode !== 'in-out' && mode !== 'out-in'
  28. ) {
  29. warn(
  30. 'invalid <transition> mode: ' + mode,
  31. this.$parent
  32. )
  33. }
  34. const rawChild: VNode = children[0]
  35. // if this is a component root node and the component's
  36. // parent container node also has transition, skip.
  37. if (hasParentTransition(this.$vnode)) {
  38. return rawChild
  39. }
  40. // apply transition data to child
  41. // use getRealChild() to ignore abstract components e.g. keep-alive
  42. const child: ?VNode = getRealChild(rawChild)
  43. /* istanbul ignore if */
  44. if (!child) {
  45. return rawChild
  46. }
  47. if (this._leaving) {
  48. return placeholder(h, rawChild)
  49. }
  50. // ensure a key that is unique to the vnode type and to this transition
  51. // component instance. This key will be used to remove pending leaving nodes
  52. // during entering.
  53. const id: string = `__transition-${this._uid}-`
  54. child.key = child.key == null
  55. ? child.isComment
  56. ? id + 'comment'
  57. : id + child.tag
  58. : isPrimitive(child.key)
  59. ? (String(child.key).indexOf(id) === 0 ? child.key : id + child.key)
  60. : child.key
  61. const data: Object = (child.data || (child.data = {})).transition = extractTransitionData(this)
  62. const oldRawChild: VNode = this._vnode
  63. const oldChild: VNode = getRealChild(oldRawChild)
  64. // mark v-show
  65. // so that the transition module can hand over the control to the directive
  66. if (child.data.directives && child.data.directives.some(d => d.name === 'show')) {
  67. child.data.show = true
  68. }
  69. if (
  70. oldChild &&
  71. oldChild.data &&
  72. !isSameChild(child, oldChild) &&
  73. !isAsyncPlaceholder(oldChild) &&
  74. // #6687 component root is a comment node
  75. !(oldChild.componentInstance && oldChild.componentInstance._vnode.isComment)
  76. ) {
  77. // replace old child transition data with fresh one
  78. // important for dynamic transitions!
  79. const oldData: Object = oldChild.data.transition = extend({}, data)
  80. // handle transition mode
  81. if (mode === 'out-in') {
  82. // return placeholder node and queue update when leave finishes
  83. this._leaving = true
  84. mergeVNodeHook(oldData, 'afterLeave', () => {
  85. this._leaving = false
  86. this.$forceUpdate()
  87. })
  88. return placeholder(h, rawChild)
  89. } else if (mode === 'in-out') {
  90. if (isAsyncPlaceholder(child)) {
  91. return oldRawChild
  92. }
  93. let delayedLeave
  94. const performLeave = () => { delayedLeave() }
  95. mergeVNodeHook(data, 'afterEnter', performLeave)
  96. mergeVNodeHook(data, 'enterCancelled', performLeave)
  97. mergeVNodeHook(oldData, 'delayLeave', leave => { delayedLeave = leave })
  98. }
  99. }
  100. return rawChild
  101. }
  102. }

<transition> 组件和 <keep-alive> 组件有几点实现类似,同样是抽象组件,同样直接实现 render 函数,同样利用了默认插槽。<transition> 组件非常灵活,支持的 props 非常多:

  1. export const transitionProps = {
  2. name: String,
  3. appear: Boolean,
  4. css: Boolean,
  5. mode: String,
  6. type: String,
  7. enterClass: String,
  8. leaveClass: String,
  9. enterToClass: String,
  10. leaveToClass: String,
  11. enterActiveClass: String,
  12. leaveActiveClass: String,
  13. appearClass: String,
  14. appearActiveClass: String,
  15. appearToClass: String,
  16. duration: [Number, String, Object]
  17. }

这些配置我们稍后会分析它们的作用,<transition> 组件另一个重要的就是 render 函数的实现,render 函数主要作用就是渲染生成 vnode,下面来看一下这部分的逻辑。

  • 处理 children
  1. let children: any = this.$slots.default
  2. if (!children) {
  3. return
  4. }
  5. // filter out text nodes (possible whitespaces)
  6. children = children.filter((c: VNode) => c.tag || isAsyncPlaceholder(c))
  7. /* istanbul ignore if */
  8. if (!children.length) {
  9. return
  10. }
  11. // warn multiple elements
  12. if (process.env.NODE_ENV !== 'production' && children.length > 1) {
  13. warn(
  14. '<transition> can only be used on a single element. Use ' +
  15. '<transition-group> for lists.',
  16. this.$parent
  17. )
  18. }

先从默认插槽中获取 <transition> 包裹的子节点,并且判断了子节点的长度,如果长度为 0,则直接返回,否则判断长度如果大于 1,也会在开发环境报警告,因为 <transition> 组件是只能包裹一个子节点的。

  • 处理 model
  1. const mode: string = this.mode
  2. // warn invalid mode
  3. if (process.env.NODE_ENV !== 'production' &&
  4. mode && mode !== 'in-out' && mode !== 'out-in'
  5. ) {
  6. warn(
  7. 'invalid <transition> mode: ' + mode,
  8. this.$parent
  9. )
  10. }

过渡组件的对 mode 的支持只有 2 种,in-out 或者是 out-in

  • 获取 rawChild & child
  1. const rawChild: VNode = children[0]
  2. // if this is a component root node and the component's
  3. // parent container node also has transition, skip.
  4. if (hasParentTransition(this.$vnode)) {
  5. return rawChild
  6. }
  7. // apply transition data to child
  8. // use getRealChild() to ignore abstract components e.g. keep-alive
  9. const child: ?VNode = getRealChild(rawChild)
  10. /* istanbul ignore if */
  11. if (!child) {
  12. return rawChild
  13. }

rawChild 就是第一个子节点 vnode,接着判断当前 <transition> 如果是组件根节点并且外面包裹该组件的容器也是 <transition> 的时候要跳过。来看一下 hasParentTransition 的实现:

  1. function hasParentTransition (vnode: VNode): ?boolean {
  2. while ((vnode = vnode.parent)) {
  3. if (vnode.data.transition) {
  4. return true
  5. }
  6. }
  7. }

因为传入的是 this.$vnode,也就是 <transition> 组件的 占位 vnode,只有当它同时作为根 vnode,也就是 vm._vnode 的时候,它的 parent 才不会为空,并且判断 parent 也是 <transition> 组件,才返回 true,vnode.data.transition 我们稍后会介绍。

getRealChild 的目的是获取组件的非抽象子节点,因为 <transition> 很可能会包裹一个 keep-alive,它的实现如下:

  1. // in case the child is also an abstract component, e.g. <keep-alive>
  2. // we want to recursively retrieve the real component to be rendered
  3. function getRealChild (vnode: ?VNode): ?VNode {
  4. const compOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions
  5. if (compOptions && compOptions.Ctor.options.abstract) {
  6. return getRealChild(getFirstComponentChild(compOptions.children))
  7. } else {
  8. return vnode
  9. }
  10. }

会递归找到第一个非抽象组件的 vnode 并返回,在我们这个 case 下,rawChild === child

  • 处理 id & data
  1. // ensure a key that is unique to the vnode type and to this transition
  2. // component instance. This key will be used to remove pending leaving nodes
  3. // during entering.
  4. const id: string = `__transition-${this._uid}-`
  5. child.key = child.key == null
  6. ? child.isComment
  7. ? id + 'comment'
  8. : id + child.tag
  9. : isPrimitive(child.key)
  10. ? (String(child.key).indexOf(id) === 0 ? child.key : id + child.key)
  11. : child.key
  12. const data: Object = (child.data || (child.data = {})).transition = extractTransitionData(this)
  13. const oldRawChild: VNode = this._vnode
  14. const oldChild: VNode = getRealChild(oldRawChild)
  15. // mark v-show
  16. // so that the transition module can hand over the control to the directive
  17. if (child.data.directives && child.data.directives.some(d => d.name === 'show')) {
  18. child.data.show = true
  19. }

先根据 key 等一系列条件获取 id,接着从当前通过 extractTransitionData 组件实例上提取出过渡所需要的数据:

  1. export function extractTransitionData (comp: Component): Object {
  2. const data = {}
  3. const options: ComponentOptions = comp.$options
  4. // props
  5. for (const key in options.propsData) {
  6. data[key] = comp[key]
  7. }
  8. // events.
  9. // extract listeners and pass them directly to the transition methods
  10. const listeners: ?Object = options._parentListeners
  11. for (const key in listeners) {
  12. data[camelize(key)] = listeners[key]
  13. }
  14. return data
  15. }

首先是遍历 props 赋值到 data 中,接着是遍历所有父组件的事件也把事件回调赋值到 data 中。

这样 child.data.transition 中就包含了过渡所需的一些数据,这些稍后都会用到,对于 child 如果使用了 v-show 指令,也会把 child.data.show 设置为 true,在我们的例子中,得到的 child.data 如下:

  1. {
  2. transition: {
  3. appear: true,
  4. name: 'fade'
  5. }
  6. }

至于 oldRawChildoldChild 是与后面的判断逻辑相关,这些我们这里先不介绍。

transition module

刚刚我们介绍完 <transition> 组件的实现,它的 render 阶段只获取了一些数据,并且返回了渲染的 vnode,并没有任何和动画相关,而动画相关的逻辑全部在 src/platforms/web/modules/transition.js 中:

  1. function _enter (_: any, vnode: VNodeWithData) {
  2. if (vnode.data.show !== true) {
  3. enter(vnode)
  4. }
  5. }
  6. export default inBrowser ? {
  7. create: _enter,
  8. activate: _enter,
  9. remove (vnode: VNode, rm: Function) {
  10. /* istanbul ignore else */
  11. if (vnode.data.show !== true) {
  12. leave(vnode, rm)
  13. } else {
  14. rm()
  15. }
  16. }
  17. } : {}

在之前介绍事件实现的章节中我们提到过在 vnode patch 的过程中,会执行很多钩子函数,那么对于过渡的实现,它只接收了 createactivate 2 个钩子函数,我们知道 create 钩子函数只有当节点的创建过程才会执行,而 remove 会在节点销毁的时候执行,这也就印证了 <transition> 必须要满足 v-if 、动态组件、组件根节点条件之一了,对于 v-show 在它的指令的钩子函数中也会执行相关逻辑,这块儿先不介绍。

过渡动画提供了 2 个时机,一个是 createactivate 的时候提供了 entering 进入动画,一个是 remove 的时候提供了 leaving 离开动画,那么接下来我们就来分别去分析这两个过程。

entering

整个 entering 过程的实现是 enter 函数:

  1. export function enter (vnode: VNodeWithData, toggleDisplay: ?() => void) {
  2. const el: any = vnode.elm
  3. // call leave callback now
  4. if (isDef(el._leaveCb)) {
  5. el._leaveCb.cancelled = true
  6. el._leaveCb()
  7. }
  8. const data = resolveTransition(vnode.data.transition)
  9. if (isUndef(data)) {
  10. return
  11. }
  12. /* istanbul ignore if */
  13. if (isDef(el._enterCb) || el.nodeType !== 1) {
  14. return
  15. }
  16. const {
  17. css,
  18. type,
  19. enterClass,
  20. enterToClass,
  21. enterActiveClass,
  22. appearClass,
  23. appearToClass,
  24. appearActiveClass,
  25. beforeEnter,
  26. enter,
  27. afterEnter,
  28. enterCancelled,
  29. beforeAppear,
  30. appear,
  31. afterAppear,
  32. appearCancelled,
  33. duration
  34. } = data
  35. // activeInstance will always be the <transition> component managing this
  36. // transition. One edge case to check is when the <transition> is placed
  37. // as the root node of a child component. In that case we need to check
  38. // <transition>'s parent for appear check.
  39. let context = activeInstance
  40. let transitionNode = activeInstance.$vnode
  41. while (transitionNode && transitionNode.parent) {
  42. transitionNode = transitionNode.parent
  43. context = transitionNode.context
  44. }
  45. const isAppear = !context._isMounted || !vnode.isRootInsert
  46. if (isAppear && !appear && appear !== '') {
  47. return
  48. }
  49. const startClass = isAppear && appearClass
  50. ? appearClass
  51. : enterClass
  52. const activeClass = isAppear && appearActiveClass
  53. ? appearActiveClass
  54. : enterActiveClass
  55. const toClass = isAppear && appearToClass
  56. ? appearToClass
  57. : enterToClass
  58. const beforeEnterHook = isAppear
  59. ? (beforeAppear || beforeEnter)
  60. : beforeEnter
  61. const enterHook = isAppear
  62. ? (typeof appear === 'function' ? appear : enter)
  63. : enter
  64. const afterEnterHook = isAppear
  65. ? (afterAppear || afterEnter)
  66. : afterEnter
  67. const enterCancelledHook = isAppear
  68. ? (appearCancelled || enterCancelled)
  69. : enterCancelled
  70. const explicitEnterDuration: any = toNumber(
  71. isObject(duration)
  72. ? duration.enter
  73. : duration
  74. )
  75. if (process.env.NODE_ENV !== 'production' && explicitEnterDuration != null) {
  76. checkDuration(explicitEnterDuration, 'enter', vnode)
  77. }
  78. const expectsCSS = css !== false && !isIE9
  79. const userWantsControl = getHookArgumentsLength(enterHook)
  80. const cb = el._enterCb = once(() => {
  81. if (expectsCSS) {
  82. removeTransitionClass(el, toClass)
  83. removeTransitionClass(el, activeClass)
  84. }
  85. if (cb.cancelled) {
  86. if (expectsCSS) {
  87. removeTransitionClass(el, startClass)
  88. }
  89. enterCancelledHook && enterCancelledHook(el)
  90. } else {
  91. afterEnterHook && afterEnterHook(el)
  92. }
  93. el._enterCb = null
  94. })
  95. if (!vnode.data.show) {
  96. // remove pending leave element on enter by injecting an insert hook
  97. mergeVNodeHook(vnode, 'insert', () => {
  98. const parent = el.parentNode
  99. const pendingNode = parent && parent._pending && parent._pending[vnode.key]
  100. if (pendingNode &&
  101. pendingNode.tag === vnode.tag &&
  102. pendingNode.elm._leaveCb
  103. ) {
  104. pendingNode.elm._leaveCb()
  105. }
  106. enterHook && enterHook(el, cb)
  107. })
  108. }
  109. // start enter transition
  110. beforeEnterHook && beforeEnterHook(el)
  111. if (expectsCSS) {
  112. addTransitionClass(el, startClass)
  113. addTransitionClass(el, activeClass)
  114. nextFrame(() => {
  115. removeTransitionClass(el, startClass)
  116. if (!cb.cancelled) {
  117. addTransitionClass(el, toClass)
  118. if (!userWantsControl) {
  119. if (isValidDuration(explicitEnterDuration)) {
  120. setTimeout(cb, explicitEnterDuration)
  121. } else {
  122. whenTransitionEnds(el, type, cb)
  123. }
  124. }
  125. }
  126. })
  127. }
  128. if (vnode.data.show) {
  129. toggleDisplay && toggleDisplay()
  130. enterHook && enterHook(el, cb)
  131. }
  132. if (!expectsCSS && !userWantsControl) {
  133. cb()
  134. }
  135. }

enter 的代码很长,我们先分析其中的核心逻辑。

  • 解析过渡数据
  1. const data = resolveTransition(vnode.data.transition)
  2. if (isUndef(data)) {
  3. return
  4. }
  5. const {
  6. css,
  7. type,
  8. enterClass,
  9. enterToClass,
  10. enterActiveClass,
  11. appearClass,
  12. appearToClass,
  13. appearActiveClass,
  14. beforeEnter,
  15. enter,
  16. afterEnter,
  17. enterCancelled,
  18. beforeAppear,
  19. appear,
  20. afterAppear,
  21. appearCancelled,
  22. duration
  23. } = data

vnode.data.transition 中解析出过渡相关的一些数据,resolveTransition 的定义在 src/platforms/web/transition-util.js 中:

  1. export function resolveTransition (def?: string | Object): ?Object {
  2. if (!def) {
  3. return
  4. }
  5. /* istanbul ignore else */
  6. if (typeof def === 'object') {
  7. const res = {}
  8. if (def.css !== false) {
  9. extend(res, autoCssTransition(def.name || 'v'))
  10. }
  11. extend(res, def)
  12. return res
  13. } else if (typeof def === 'string') {
  14. return autoCssTransition(def)
  15. }
  16. }
  17. const autoCssTransition: (name: string) => Object = cached(name => {
  18. return {
  19. enterClass: `${name}-enter`,
  20. enterToClass: `${name}-enter-to`,
  21. enterActiveClass: `${name}-enter-active`,
  22. leaveClass: `${name}-leave`,
  23. leaveToClass: `${name}-leave-to`,
  24. leaveActiveClass: `${name}-leave-active`
  25. }
  26. })

resolveTransition 会通过 autoCssTransition 处理 name 属性,生成一个用来描述各个阶段的 Class 名称的对象,扩展到 def 中并返回给 data,这样我们就可以从 data 中获取到过渡相关的所有数据。

  • 处理边界情况
  1. // activeInstance will always be the <transition> component managing this
  2. // transition. One edge case to check is when the <transition> is placed
  3. // as the root node of a child component. In that case we need to check
  4. // <transition>'s parent for appear check.
  5. let context = activeInstance
  6. let transitionNode = activeInstance.$vnode
  7. while (transitionNode && transitionNode.parent) {
  8. transitionNode = transitionNode.parent
  9. context = transitionNode.context
  10. }
  11. const isAppear = !context._isMounted || !vnode.isRootInsert
  12. if (isAppear && !appear && appear !== '') {
  13. return
  14. }

这是为了处理当 <transition> 作为子组件的根节点,那么我们需要检查它的父组件作为 appear 的检查。isAppear 表示当前上下文实例还没有 mounted,第一次出现的时机。如果是第一次并且 <transition> 组件没有配置 appear 的话,直接返回。

  • 定义过渡类名、钩子函数和其它配置
  1. const startClass = isAppear && appearClass
  2. ? appearClass
  3. : enterClass
  4. const activeClass = isAppear && appearActiveClass
  5. ? appearActiveClass
  6. : enterActiveClass
  7. const toClass = isAppear && appearToClass
  8. ? appearToClass
  9. : enterToClass
  10. const beforeEnterHook = isAppear
  11. ? (beforeAppear || beforeEnter)
  12. : beforeEnter
  13. const enterHook = isAppear
  14. ? (typeof appear === 'function' ? appear : enter)
  15. : enter
  16. const afterEnterHook = isAppear
  17. ? (afterAppear || afterEnter)
  18. : afterEnter
  19. const enterCancelledHook = isAppear
  20. ? (appearCancelled || enterCancelled)
  21. : enterCancelled
  22. const explicitEnterDuration: any = toNumber(
  23. isObject(duration)
  24. ? duration.enter
  25. : duration
  26. )
  27. if (process.env.NODE_ENV !== 'production' && explicitEnterDuration != null) {
  28. checkDuration(explicitEnterDuration, 'enter', vnode)
  29. }
  30. const expectsCSS = css !== false && !isIE9
  31. const userWantsControl = getHookArgumentsLength(enterHook)
  32. const cb = el._enterCb = once(() => {
  33. if (expectsCSS) {
  34. removeTransitionClass(el, toClass)
  35. removeTransitionClass(el, activeClass)
  36. }
  37. if (cb.cancelled) {
  38. if (expectsCSS) {
  39. removeTransitionClass(el, startClass)
  40. }
  41. enterCancelledHook && enterCancelledHook(el)
  42. } else {
  43. afterEnterHook && afterEnterHook(el)
  44. }
  45. el._enterCb = null
  46. })

对于过渡类名方面,startClass 定义进入过渡的开始状态,在元素被插入时生效,在下一个帧移除;activeClass 定义过渡的状态,在元素整个过渡过程中作用,在元素被插入时生效,在 transition/animation 完成之后移除;toClass 定义进入过渡的结束状态,在元素被插入一帧后生效 (与此同时 startClass 被删除),在 <transition>/animation 完成之后移除。

对于过渡钩子函数方面,beforeEnterHook 是过渡开始前执行的钩子函数,enterHook 是在元素插入后或者是 v-show 显示切换后执行的钩子函数。afterEnterHook 是在过渡动画执行完后的钩子函数。

explicitEnterDuration 表示 enter 动画执行的时间。

expectsCSS 表示过渡动画是受 CSS 的影响。

cb 定义的是过渡完成执行的回调函数。

  • 合并 insert 钩子函数
  1. if (!vnode.data.show) {
  2. // remove pending leave element on enter by injecting an insert hook
  3. mergeVNodeHook(vnode, 'insert', () => {
  4. const parent = el.parentNode
  5. const pendingNode = parent && parent._pending && parent._pending[vnode.key]
  6. if (pendingNode &&
  7. pendingNode.tag === vnode.tag &&
  8. pendingNode.elm._leaveCb
  9. ) {
  10. pendingNode.elm._leaveCb()
  11. }
  12. enterHook && enterHook(el, cb)
  13. })
  14. }

mergeVNodeHook 的定义在 src/core/vdom/helpers/merge-hook.js 中:

  1. export function mergeVNodeHook (def: Object, hookKey: string, hook: Function) {
  2. if (def instanceof VNode) {
  3. def = def.data.hook || (def.data.hook = {})
  4. }
  5. let invoker
  6. const oldHook = def[hookKey]
  7. function wrappedHook () {
  8. hook.apply(this, arguments)
  9. // important: remove merged hook to ensure it's called only once
  10. // and prevent memory leak
  11. remove(invoker.fns, wrappedHook)
  12. }
  13. if (isUndef(oldHook)) {
  14. // no existing hook
  15. invoker = createFnInvoker([wrappedHook])
  16. } else {
  17. /* istanbul ignore if */
  18. if (isDef(oldHook.fns) && isTrue(oldHook.merged)) {
  19. // already a merged invoker
  20. invoker = oldHook
  21. invoker.fns.push(wrappedHook)
  22. } else {
  23. // existing plain hook
  24. invoker = createFnInvoker([oldHook, wrappedHook])
  25. }
  26. }
  27. invoker.merged = true
  28. def[hookKey] = invoker
  29. }

mergeVNodeHook 的逻辑很简单,就是把 hook 函数合并到 def.data.hook[hookey] 中,生成新的 invokercreateFnInvoker 方法我们在分析事件章节的时候已经介绍过了。

我们之前知道组件的 vnode 原本定义了 initprepatchinsertdestroy 四个钩子函数,而 mergeVNodeHook 函数就是把一些新的钩子函数合并进来,例如在 <transition> 过程中合并的 insert 钩子函数,就会合并到组件 vnodeinsert 钩子函数中,这样当组件插入后,就会执行我们定义的 enterHook 了。

  • 开始执行过渡动画
  1. // start enter transition
  2. beforeEnterHook && beforeEnterHook(el)
  3. if (expectsCSS) {
  4. addTransitionClass(el, startClass)
  5. addTransitionClass(el, activeClass)
  6. nextFrame(() => {
  7. removeTransitionClass(el, startClass)
  8. if (!cb.cancelled) {
  9. addTransitionClass(el, toClass)
  10. if (!userWantsControl) {
  11. if (isValidDuration(explicitEnterDuration)) {
  12. setTimeout(cb, explicitEnterDuration)
  13. } else {
  14. whenTransitionEnds(el, type, cb)
  15. }
  16. }
  17. }
  18. })
  19. }

首先执行 beforeEnterHook 钩子函数,把当前元素的 DOM 节点 el 传入,然后判断 expectsCSS,如果为 true 则表明希望用 CSS 来控制动画,那么会执行 addTransitionClass(el, startClass)addTransitionClass(el, activeClass),它的定义在 src/platforms/runtime/transition-util.js 中:

  1. export function addTransitionClass (el: any, cls: string) {
  2. const transitionClasses = el._transitionClasses || (el._transitionClasses = [])
  3. if (transitionClasses.indexOf(cls) < 0) {
  4. transitionClasses.push(cls)
  5. addClass(el, cls)
  6. }
  7. }

其实非常简单,就是给当前 DOM 元素 el 添加样式 cls,所以这里添加了 startClassactiveClass,在我们的例子中就是给 p 标签添加了 fade-enterfade-enter-active 2 个样式。

接下来执行了 nextFrame

  1. const raf = inBrowser
  2. ? window.requestAnimationFrame
  3. ? window.requestAnimationFrame.bind(window)
  4. : setTimeout
  5. : fn => fn()
  6. export function nextFrame (fn: Function) {
  7. raf(() => {
  8. raf(fn)
  9. })
  10. }

它就是一个简单的 requestAnimationFrame 的实现,它的参数 fn 会在下一帧执行,因此下一帧执行了 removeTransitionClass(el, startClass)

  1. export function removeTransitionClass (el: any, cls: string) {
  2. if (el._transitionClasses) {
  3. remove(el._transitionClasses, cls)
  4. }
  5. removeClass(el, cls)
  6. }

startClass 移除,在我们的等例子中就是移除 fade-enter 样式。然后判断此时过渡没有被取消,则执行 addTransitionClass(el, toClass) 添加 toClass,在我们的例子中就是添加了 fade-enter-to。然后判断 !userWantsControl,也就是用户不通过 enterHook 钩子函数控制动画,这时候如果用户指定了 explicitEnterDuration,则延时这个时间执行 cb,否则通过 whenTransitionEnds(el, type, cb) 决定执行 cb 的时机:

  1. export function whenTransitionEnds (
  2. el: Element,
  3. expectedType: ?string,
  4. cb: Function
  5. ) {
  6. const { type, timeout, propCount } = getTransitionInfo(el, expectedType)
  7. if (!type) return cb()
  8. const event: string = type === <transition> ? transitionEndEvent : animationEndEvent
  9. let ended = 0
  10. const end = () => {
  11. el.removeEventListener(event, onEnd)
  12. cb()
  13. }
  14. const onEnd = e => {
  15. if (e.target === el) {
  16. if (++ended >= propCount) {
  17. end()
  18. }
  19. }
  20. }
  21. setTimeout(() => {
  22. if (ended < propCount) {
  23. end()
  24. }
  25. }, timeout + 1)
  26. el.addEventListener(event, onEnd)
  27. }

whenTransitionEnds 的逻辑具体不深讲了,本质上就利用了过渡动画的结束事件来决定 cb 函数的执行。

最后再回到 cb 函数:

  1. const cb = el._enterCb = once(() => {
  2. if (expectsCSS) {
  3. removeTransitionClass(el, toClass)
  4. removeTransitionClass(el, activeClass)
  5. }
  6. if (cb.cancelled) {
  7. if (expectsCSS) {
  8. removeTransitionClass(el, startClass)
  9. }
  10. enterCancelledHook && enterCancelledHook(el)
  11. } else {
  12. afterEnterHook && afterEnterHook(el)
  13. }
  14. el._enterCb = null
  15. })

其实很简单,执行了 removeTransitionClass(el, toClass)removeTransitionClass(el, activeClass)toClassactiveClass 移除,然后判断如果有没有取消,如果取消则移除 startClass 并执行 enterCancelledHook,否则执行 afterEnterHook(el)

那么到这里,entering 的过程就介绍完了。

leaving

entering 相对的就是 leaving 阶段了,entering 主要发生在组件插入后,而 leaving 主要发生在组件销毁前。

  1. export function leave (vnode: VNodeWithData, rm: Function) {
  2. const el: any = vnode.elm
  3. // call enter callback now
  4. if (isDef(el._enterCb)) {
  5. el._enterCb.cancelled = true
  6. el._enterCb()
  7. }
  8. const data = resolveTransition(vnode.data.transition)
  9. if (isUndef(data) || el.nodeType !== 1) {
  10. return rm()
  11. }
  12. /* istanbul ignore if */
  13. if (isDef(el._leaveCb)) {
  14. return
  15. }
  16. const {
  17. css,
  18. type,
  19. leaveClass,
  20. leaveToClass,
  21. leaveActiveClass,
  22. beforeLeave,
  23. leave,
  24. afterLeave,
  25. leaveCancelled,
  26. delayLeave,
  27. duration
  28. } = data
  29. const expectsCSS = css !== false && !isIE9
  30. const userWantsControl = getHookArgumentsLength(leave)
  31. const explicitLeaveDuration: any = toNumber(
  32. isObject(duration)
  33. ? duration.leave
  34. : duration
  35. )
  36. if (process.env.NODE_ENV !== 'production' && isDef(explicitLeaveDuration)) {
  37. checkDuration(explicitLeaveDuration, 'leave', vnode)
  38. }
  39. const cb = el._leaveCb = once(() => {
  40. if (el.parentNode && el.parentNode._pending) {
  41. el.parentNode._pending[vnode.key] = null
  42. }
  43. if (expectsCSS) {
  44. removeTransitionClass(el, leaveToClass)
  45. removeTransitionClass(el, leaveActiveClass)
  46. }
  47. if (cb.cancelled) {
  48. if (expectsCSS) {
  49. removeTransitionClass(el, leaveClass)
  50. }
  51. leaveCancelled && leaveCancelled(el)
  52. } else {
  53. rm()
  54. afterLeave && afterLeave(el)
  55. }
  56. el._leaveCb = null
  57. })
  58. if (delayLeave) {
  59. delayLeave(performLeave)
  60. } else {
  61. performLeave()
  62. }
  63. function performLeave () {
  64. // the delayed leave may have already been cancelled
  65. if (cb.cancelled) {
  66. return
  67. }
  68. // record leaving element
  69. if (!vnode.data.show) {
  70. (el.parentNode._pending || (el.parentNode._pending = {}))[(vnode.key: any)] = vnode
  71. }
  72. beforeLeave && beforeLeave(el)
  73. if (expectsCSS) {
  74. addTransitionClass(el, leaveClass)
  75. addTransitionClass(el, leaveActiveClass)
  76. nextFrame(() => {
  77. removeTransitionClass(el, leaveClass)
  78. if (!cb.cancelled) {
  79. addTransitionClass(el, leaveToClass)
  80. if (!userWantsControl) {
  81. if (isValidDuration(explicitLeaveDuration)) {
  82. setTimeout(cb, explicitLeaveDuration)
  83. } else {
  84. whenTransitionEnds(el, type, cb)
  85. }
  86. }
  87. }
  88. })
  89. }
  90. leave && leave(el, cb)
  91. if (!expectsCSS && !userWantsControl) {
  92. cb()
  93. }
  94. }
  95. }

纵观 leave 的实现,和 enter 的实现几乎是一个镜像过程,不同的是从 data 中解析出来的是 leave 相关的样式类名和钩子函数。还有一点不同是可以配置 delayLeave,它是一个函数,可以延时执行 leave 的相关过渡动画,在 leave 动画执行完后,它会执行 rm 函数把节点从 DOM 中真正做移除。

总结

那么到此为止基本的 <transition> 过渡的实现分析完毕了,总结起来,Vue 的过渡实现分为以下几个步骤:

  • 自动嗅探目标元素是否应用了 CSS 过渡或动画,如果是,在恰当的时机添加/删除 CSS 类名。

  • 如果过渡组件提供了 JavaScript 钩子函数,这些钩子函数将在恰当的时机被调用。

  • 如果没有找到 JavaScript 钩子并且也没有检测到 CSS 过渡/动画,DOM 操作 (插入/删除) 在下一帧中立即执行。

所以真正执行动画的是我们写的 CSS 或者是 JavaScript 钩子函数,而 Vue 的 <transition> 只是帮我们很好地管理了这些 CSS 的添加/删除,以及钩子函数的执行时机。

原文: https://ustbhuangyi.github.io/vue-analysis/extend/tansition.html