一个 DOM 节点的插入和删除或者是显示和隐藏,不想让它特别生硬,通常会考虑加一些过渡效果
Vue.js 除了实现了强大的数据驱动,组件化的能力,也提供了一整套过渡的解决方案
它内置了 组件,可以利用它配合一些 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. }

    当点击按钮切换显示状态的时候,被 包裹的内容会有过渡动画

    内置组件

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

    1. export default {
    2. name: 'transition',
    3. props: transitionProps,
    4. abstract: true, // 抽象组件
    5. // render函数 - 渲染生成vnode
    6. render (h: Function) {
    7. // 1.处理children
    8. // 默认插槽
    9. let children: any = this.$slots.default // 从默认插槽中获取<transition>包裹的子节点
    10. if (!children) {
    11. return
    12. }
    13. // filter out text nodes (possible whitespaces)
    14. children = children.filter(isNotTextNode)
    15. /* istanbul ignore if */
    16. // 长度为0直接返回
    17. if (!children.length) {
    18. return
    19. }
    20. // warn multiple elements
    21. // 长度大于1在开发环境报警告 - <transition>组件是只能包裹一个子节点的
    22. if (process.env.NODE_ENV !== 'production' && children.length > 1) {
    23. warn(
    24. '<transition> can only be used on a single element. Use ' +
    25. '<transition-group> for lists.',
    26. this.$parent
    27. )
    28. }
    29. // 2.处理model
    30. const mode: string = this.mode
    31. // warn invalid mode
    32. // 过渡组件的对mode的支持只有2种 in-out或者是 out-in
    33. if (process.env.NODE_ENV !== 'production' &&
    34. mode && mode !== 'in-out' && mode !== 'out-in'
    35. ) {
    36. warn(
    37. 'invalid <transition> mode: ' + mode,
    38. this.$parent
    39. )
    40. }
    41. // 3.获取rawChild和child
    42. const rawChild: VNode = children[0] // 第一个子节点vnode
    43. // if this is a component root node and the component's
    44. // parent container node also has transition, skip.
    45. // 判断当前<transition>如果是组件根节点并且外面包裹该组件的容器也是<transition>时要跳过
    46. if (hasParentTransition(this.$vnode)) {
    47. return rawChild
    48. }
    49. // apply transition data to child
    50. // use getRealChild() to ignore abstract components e.g. keep-alive
    51. // getRealChild的目的是获取组件的非抽象子节点 - <transition>很可能会包裹一个keep-alive
    52. const child: ?VNode = getRealChild(rawChild)
    53. /* istanbul ignore if */
    54. if (!child) {
    55. return rawChild
    56. }
    57. if (this._leaving) {
    58. return placeholder(h, rawChild)
    59. }
    60. // 4.处理id和data
    61. // ensure a key that is unique to the vnode type and to this transition
    62. // component instance. This key will be used to remove pending leaving nodes
    63. // during entering.
    64. // 根据key等一系列条件获取id
    65. const id: string = `__transition-${this._uid}-`
    66. child.key = child.key == null
    67. ? child.isComment
    68. ? id + 'comment'
    69. : id + child.tag
    70. : isPrimitive(child.key)
    71. ? (String(child.key).indexOf(id) === 0 ? child.key : id + child.key)
    72. : child.key
    73. // 通过extractTransitionData组件实例上提取出过渡所需要的数据
    74. const data: Object = (child.data || (child.data = {})).transition = extractTransitionData(this)
    75. const oldRawChild: VNode = this._vnode
    76. const oldChild: VNode = getRealChild(oldRawChild)
    77. // mark v-show
    78. // so that the transition module can hand over the control to the directive
    79. // child如果使用了v-show指令,也会把child.data.show设置为true
    80. if (child.data.directives && child.data.directives.some(isVShowDirective)) {
    81. child.data.show = true
    82. }
    83. if (
    84. oldChild &&
    85. oldChild.data &&
    86. !isSameChild(child, oldChild) &&
    87. !isAsyncPlaceholder(oldChild) &&
    88. // #6687 component root is a comment node
    89. !(oldChild.componentInstance && oldChild.componentInstance._vnode.isComment)
    90. ) {
    91. // replace old child transition data with fresh one
    92. // important for dynamic transitions!
    93. const oldData: Object = oldChild.data.transition = extend({}, data)
    94. // handle transition mode
    95. if (mode === 'out-in') {
    96. // return placeholder node and queue update when leave finishes
    97. this._leaving = true
    98. mergeVNodeHook(oldData, 'afterLeave', () => {
    99. this._leaving = false
    100. this.$forceUpdate()
    101. })
    102. return placeholder(h, rawChild)
    103. } else if (mode === 'in-out') {
    104. if (isAsyncPlaceholder(child)) {
    105. return oldRawChild
    106. }
    107. let delayedLeave
    108. const performLeave = () => { delayedLeave() }
    109. mergeVNodeHook(data, 'afterEnter', performLeave)
    110. mergeVNodeHook(data, 'enterCancelled', performLeave)
    111. mergeVNodeHook(oldData, 'delayLeave', leave => { delayedLeave = leave })
    112. }
    113. }
    114. return rawChild
    115. }
    116. }

    组件非常灵活,支持的 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. }

    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,也就是 组件的 占位 vnode,只有当它同时作为根 vnode,也就是 vm._vnode 的时候,它的 parent 才不会为空,并且判断 parent 也是 组件,才返回 true
    getRealChild

    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. // 递归找到第一个非抽象组件的vnode并返回
    6. if (compOptions && compOptions.Ctor.options.abstract) {
    7. return getRealChild(getFirstComponentChild(compOptions.children))
    8. } else {
    9. return vnode
    10. }
    11. }

    extractTransitionData

    1. export function extractTransitionData (comp: Component): Object {
    2. const data = {}
    3. const options: ComponentOptions = comp.$options
    4. // props 遍历props赋值到data中
    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. // 遍历所有父组件的事件把事件回调赋值到data中
    12. for (const key in listeners) {
    13. data[camelize(key)] = listeners[key]
    14. }
    15. return data
    16. }

    这样 child.data.transition 中就包含了过渡所需的一些数据
    在例子中得到的child.data如下

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

    transition module

    组件的实现,它的 render 阶段只获取了一些数据,并且返回了渲染的 vnode,并没有任何和动画相关,而动画相关的逻辑全部在 src/platforms/web/modules/transition.js 中 ```javascript 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 2 个钩子函数,知道 create 钩子函数只有当节点的创建过程才会执行,而 remove 会在节点销毁的时候执行,这也就印证了 <transition> 必须要满足 v-if 、动态组件、组件根节点条件之一了,对于 v-show 在它的指令的钩子函数中也会执行相关逻辑<br />**过渡动画提供了 2 个时机,一个是 create activate 的时候提供了 entering 进入动画,一个是 remove 的时候提供了 leaving 离开动画**
  2. <a name="Rd57n"></a>
  3. ## entering
  4. 整个 entering 过程的实现是 enter 函数
  5. ```javascript
  6. export function enter (vnode: VNodeWithData, toggleDisplay: ?() => void) {
  7. const el: any = vnode.elm
  8. // call leave callback now
  9. if (isDef(el._leaveCb)) {
  10. el._leaveCb.cancelled = true
  11. el._leaveCb()
  12. }
  13. // 1.解析过渡数据
  14. const data = resolveTransition(vnode.data.transition) // 解析出过渡相关的一些数据
  15. if (isUndef(data)) {
  16. return
  17. }
  18. /* istanbul ignore if */
  19. if (isDef(el._enterCb) || el.nodeType !== 1) {
  20. return
  21. }
  22. const {
  23. css,
  24. type,
  25. enterClass,
  26. enterToClass,
  27. enterActiveClass,
  28. appearClass,
  29. appearToClass,
  30. appearActiveClass,
  31. beforeEnter,
  32. enter,
  33. afterEnter,
  34. enterCancelled,
  35. beforeAppear,
  36. appear,
  37. afterAppear,
  38. appearCancelled,
  39. duration
  40. } = data
  41. // 2.处理边界情况
  42. // activeInstance will always be the <transition> component managing this
  43. // transition. One edge case to check is when the <transition> is placed
  44. // as the root node of a child component. In that case we need to check
  45. // <transition>'s parent for appear check.
  46. // 当<transition>作为子组件的根节点时检查它的父组件作为appear的检查
  47. // isAppear表示当前上下文实例还没有mounted,第一次出现的时候
  48. let context = activeInstance
  49. let transitionNode = activeInstance.$vnode
  50. while (transitionNode && transitionNode.parent) {
  51. context = transitionNode.context
  52. transitionNode = transitionNode.parent
  53. }
  54. const isAppear = !context._isMounted || !vnode.isRootInsert
  55. // 如果是第一次且<transition>组件没有配置appear的话直接返回
  56. if (isAppear && !appear && appear !== '') {
  57. return
  58. }
  59. // 3.定义过渡类名、钩子函数和其它配置
  60. // startClass定义进入过渡的开始状态,在元素被插入时生效,在下一个帧移除
  61. const startClass = isAppear && appearClass
  62. ? appearClass
  63. : enterClass
  64. // activeClass定义过渡的状态,在元素整个过渡过程中作用,在元素被插入时生效,在transtion/animation完成之后移除
  65. const activeClass = isAppear && appearActiveClass
  66. ? appearActiveClass
  67. : enterActiveClass
  68. // toClass定义进入过渡的结束状态,在元素被插入一帧后生效(与此同时startClass被删除),在transtion/animation完成之后移除
  69. const toClass = isAppear && appearToClass
  70. ? appearToClass
  71. : enterToClass
  72. // beforeEnterHook是过渡开始前执行的钩子函数
  73. const beforeEnterHook = isAppear
  74. ? (beforeAppear || beforeEnter)
  75. : beforeEnter
  76. // enterHook是在元素插入后或者是v-show显示切换后执行的钩子函数
  77. const enterHook = isAppear
  78. ? (typeof appear === 'function' ? appear : enter)
  79. : enter
  80. // afterEnterHook是在过渡动画执行完后的钩子函数
  81. const afterEnterHook = isAppear
  82. ? (afterAppear || afterEnter)
  83. : afterEnter
  84. const enterCancelledHook = isAppear
  85. ? (appearCancelled || enterCancelled)
  86. : enterCancelled
  87. // explicitEnterDuration表示enter动画执行的时间
  88. const explicitEnterDuration: any = toNumber(
  89. isObject(duration)
  90. ? duration.enter
  91. : duration
  92. )
  93. if (process.env.NODE_ENV !== 'production' && explicitEnterDuration != null) {
  94. checkDuration(explicitEnterDuration, 'enter', vnode)
  95. }
  96. // expectsCSS表示过渡动画是受CSS的影响
  97. const expectsCSS = css !== false && !isIE9
  98. const userWantsControl = getHookArgumentsLength(enterHook)
  99. // cb定义的是过渡完成执行的回调函数
  100. const cb = el._enterCb = once(() => {
  101. if (expectsCSS) {
  102. // 把toClass和activeClass移除
  103. removeTransitionClass(el, toClass)
  104. removeTransitionClass(el, activeClass)
  105. }
  106. if (cb.cancelled) {
  107. // 取消则移除startClass并执行enterCancelledHook
  108. if (expectsCSS) {
  109. removeTransitionClass(el, startClass)
  110. }
  111. enterCancelledHook && enterCancelledHook(el)
  112. } else {
  113. // 没有取消
  114. afterEnterHook && afterEnterHook(el)
  115. }
  116. el._enterCb = null
  117. })
  118. // 4.合并insert钩子函数
  119. if (!vnode.data.show) {
  120. // remove pending leave element on enter by injecting an insert hook
  121. mergeVNodeHook(vnode, 'insert', () => {
  122. const parent = el.parentNode
  123. const pendingNode = parent && parent._pending && parent._pending[vnode.key]
  124. if (pendingNode &&
  125. pendingNode.tag === vnode.tag &&
  126. pendingNode.elm._leaveCb
  127. ) {
  128. pendingNode.elm._leaveCb()
  129. }
  130. enterHook && enterHook(el, cb)
  131. })
  132. }
  133. // 5.开始执行过渡动画
  134. // start enter transition
  135. beforeEnterHook && beforeEnterHook(el)
  136. if (expectsCSS) { // 为true表面希望用CSS来控制动画
  137. // 添加了startClass和activeClass
  138. addTransitionClass(el, startClass)
  139. addTransitionClass(el, activeClass)
  140. // nextFrame是requestAnimationFrame的实现
  141. // 下一帧
  142. nextFrame(() => {
  143. // 把 startClass 移除
  144. removeTransitionClass(el, startClass) // 移除fade-enter样式
  145. // 过渡没有被取消
  146. if (!cb.cancelled) {
  147. addTransitionClass(el, toClass) // 添加toClass 例子中添加fade-enter-to
  148. // 用户不通过enterHook钩子函数控制动画
  149. if (!userWantsControl) {
  150. if (isValidDuration(explicitEnterDuration)) {
  151. setTimeout(cb, explicitEnterDuration) // 用户指定了explicitEnterDuration则延时这个时间执行cb
  152. } else {
  153. whenTransitionEnds(el, type, cb) // 决定执行cb的时机
  154. }
  155. }
  156. }
  157. })
  158. }
  159. if (vnode.data.show) {
  160. toggleDisplay && toggleDisplay()
  161. enterHook && enterHook(el, cb)
  162. }
  163. if (!expectsCSS && !userWantsControl) {
  164. cb()
  165. }
  166. }

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. // 通过autoCssTransition处理name属性生成一个用来描述各个阶段的Class名称的对象,扩展到def中并返回给data,这样就可以从data中获取到过渡相关的所有数据
  9. if (def.css !== false) {
  10. extend(res, autoCssTransition(def.name || 'v'))
  11. }
  12. extend(res, def)
  13. return res
  14. } else if (typeof def === 'string') {
  15. return autoCssTransition(def)
  16. }
  17. }
  18. const autoCssTransition: (name: string) => Object = cached(name => {
  19. return {
  20. enterClass: `${name}-enter`,
  21. enterToClass: `${name}-enter-to`,
  22. enterActiveClass: `${name}-enter-active`,
  23. leaveClass: `${name}-leave`,
  24. leaveToClass: `${name}-leave-to`,
  25. leaveActiveClass: `${name}-leave-active`
  26. }
  27. })

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

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

vnode 原本定义了 init、prepatch、insert、destroy 四个钩子函数,而 mergeVNodeHook 函数就是把一些新的钩子函数合并进来,例如在 过程中合并的 insert 钩子函数,就会合并到组件 vnode 的 insert 钩子函数中,这样当组件插入后,就会执行定义的 enterHook 了
addTransitionClass定义在 src/platforms/web/runtime/transition-util.js 中

  1. export function addTransitionClass (el: any, cls: string) {
  2. // 给当前 DOM 元素 el 添加样式 cls
  3. const transitionClasses = el._transitionClasses || (el._transitionClasses = [])
  4. if (transitionClasses.indexOf(cls) < 0) {
  5. transitionClasses.push(cls)
  6. addClass(el, cls)
  7. }
  8. }

nextFrame

  1. // binding to window is necessary to make hot reload work in IE in strict mode
  2. // 简单的requestAnimationFrame的实现,它的参数fn会在下一帧执行
  3. const raf = inBrowser
  4. ? window.requestAnimationFrame
  5. ? window.requestAnimationFrame.bind(window)
  6. : setTimeout
  7. : /* istanbul ignore next */ fn => fn()
  8. export function nextFrame (fn: Function) {
  9. raf(() => {
  10. raf(fn)
  11. })
  12. }

removeTransitionClass

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

whenTransitionEnds
本质上就利用了过渡动画的结束事件来决定 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. }

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 && el.parentNode) {
  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 中真正做移除

Vue 的过渡实现分为以下几个步骤:

  1. 自动嗅探目标元素是否应用了 CSS 过渡或动画,如果是,在恰当的时机添加/删除 CSS 类名
  2. 如果过渡组件提供了 JavaScript 钩子函数,这些钩子函数将在恰当的时机被调用
  3. 如果没有找到 JavaScript 钩子并且也没有检测到 CSS 过渡/动画,DOM 操作 (插入/删除) 在下一帧中立即执行

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