Vue - The Good Parts: transition

avatar
@滴滴出行

前言

随着大家对于交互更高的要求,在很多场景下我们的交互设计是都会要求在页面中适当的加入一些动画来增强用户的感知,或者有一些过渡效果来提升连贯性。

在 Vue 中提供了十分友好、极其好用的过渡组件,可以帮助我们很容易的实现过渡动画需求。So easy!

那里边是如何实现的,有哪些是很值得我们学习的?

正文分析

What

Vue 中 transition 相关的有两个组件:单个过渡的 transition 组件以及列表过渡 transition-group 组件。

和过渡相关的工具大概:

image2021-7-6_15-27-24.png

功能还是很多,开发者完全可以根据自己的场景来决定使用什么样的工具去完成需要的过渡动效。

典型的场景:

image2021-7-6_15-33-3.png

当点击 Toggle 的时候,下放的 hello 文本就会有一个透明度的过渡动效。

整个过渡的过程可以详细的描述为:

transition (1).png

还有列表过渡的示例:

image2021-7-6_15-38-44.png

点击 Shuffle 按钮,会打散数的排列,即对 items 进行洗牌,会出现神奇的动画过渡效果。

这些就是 Vue 中 transition 和 transition-group 组件提供的强大能力。

How

那如此神奇的组件到底是怎么实现的呢?我们一起来看下。

transition

先来看 transition,在 github.com/vuejs/vue/b…

// 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]
}
 
// in case the child is also an abstract component, e.g. <keep-alive>
// we want to recursively retrieve the real component to be rendered
// 得到真实的 child,抛掉 abstract 的
function getRealChild (vnode: ?VNode): ?VNode {
  const compOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions
  if (compOptions && compOptions.Ctor.options.abstract) {
    return getRealChild(getFirstComponentChild(compOptions.children))
  } else {
    return vnode
  }
}
 
// 提取 transition 需要的数据 data,包括了 props 和 events
export function extractTransitionData (comp: Component): Object {
  const data = {}
  const options: ComponentOptions = comp.$options
  // props
  for (const key in options.propsData) {
    data[key] = comp[key]
  }
  // events.
  // extract listeners and pass them directly to the transition methods
  const listeners: ?Object = options._parentListeners
  for (const key in listeners) {
    data[camelize(key)] = listeners[key]
  }
  return data
}
 
function placeholder (h: Function, rawChild: VNode): ?VNode {
  if (/\d-keep-alive$/.test(rawChild.tag)) {
    return h('keep-alive', {
      props: rawChild.componentOptions.propsData
    })
  }
}
 
function hasParentTransition (vnode: VNode): ?boolean {
  // 一直查找 vnode 的parent 直至没有,只要有一层出现了 transition 则代表父级有 transition
  while ((vnode = vnode.parent)) {
    if (vnode.data.transition) {
      return true
    }
  }
}
 
function isSameChild (child: VNode, oldChild: VNode): boolean {
  return oldChild.key === child.key && oldChild.tag === child.tag
}
 
const isNotTextNode = (c: VNode) => c.tag || isAsyncPlaceholder(c)
 
const isVShowDirective = d => d.name === 'show'
 
export default {
  name: 'transition',
  props: transitionProps,
  // 抽象组件
  abstract: true,
 
  render (h: Function) {
    let children: any = this.$slots.default
    if (!children) {
      return
    }
 
    // filter out text nodes (possible whitespaces)
    children = children.filter(isNotTextNode)
    /* istanbul ignore if */
    if (!children.length) {
      return
    }
 
    // warn multiple elements
    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
 
    // warn invalid 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 this is a component root node and the component's
    // parent container node also has transition, skip.
    // 注意这里用的是 $vnode
    if (hasParentTransition(this.$vnode)) {
      return rawChild
    }
 
    // apply transition data to child
    // use getRealChild() to ignore abstract components e.g. keep-alive
    const child: ?VNode = getRealChild(rawChild)
    /* istanbul ignore if */
    if (!child) {
      return rawChild
    }
 
    if (this._leaving) {
      return placeholder(h, rawChild)
    }
 
    // ensure a key that is unique to the vnode type and to this transition
    // component instance. This key will be used to remove pending leaving nodes
    // during entering.
    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)
    // 这里用的是 _vnode
    const oldRawChild: VNode = this._vnode
    const oldChild: VNode = getRealChild(oldRawChild)
 
    // mark v-show
    // so that the transition module can hand over the control to the directive
    if (child.data.directives && child.data.directives.some(isVShowDirective)) {
      child.data.show = true
    }
    // 判断 oldChild 以及 oldChild 和 新的是不是相同的
    // 正常情况下,单个元素的情况下,是不会进入的,因为 如果是从隐藏到显示,old就是comment 如果是从显示到隐藏 child 就没有
    if (
      oldChild &&
      oldChild.data &&
      !isSameChild(child, oldChild) &&
      !isAsyncPlaceholder(oldChild) &&
      // #6687 component root is a comment node
      !(oldChild.componentInstance && oldChild.componentInstance._vnode.isComment)
    ) {
      // replace old child transition data with fresh one
      // important for dynamic transitions!
      // 更新 vnode data transition 值
      const oldData: Object = oldChild.data.transition = extend({}, data)
      // handle transition mode
      // 多个元素,当前元素和新元素之间的动画过渡模式 两个 mode
      // 模式 out-in:当前元素先进行过渡,完成之后新元素过渡进入。
      if (mode === 'out-in') {
        // return placeholder node and queue update when leave finishes
        // 标记 _leaving,等待
        this._leaving = true
        // 监控 afterLeave
        mergeVNodeHook(oldData, 'afterLeave', () => {
          // reset & 更新
          this._leaving = false
          // 此时已经 out 完毕,执行 forceUpdate 走 show 然后 in 的逻辑了
          this.$forceUpdate()
        })
        // 考虑 keep-alive 场景,一般就会返回 undefined 了,也就是会触发 patch 然后把元素 remove 掉,即 out 逻辑
        return placeholder(h, rawChild)
      } else if (mode === 'in-out') {
        // 模式 in-out:新元素先进行过渡,完成之后当前元素过渡离开。
        if (isAsyncPlaceholder(child)) {
          // 异步 先返回旧的
          return oldRawChild
        }
        let delayedLeave
        const performLeave = () => { delayedLeave() }
        // 监听 afterEnter 钩子 执行之前保留下来的 leave 回调逻辑
        // 这样实现了先 in 后 out 的效果,因为是等到 afterEnter 之后才走的 leave 逻辑
        mergeVNodeHook(data, 'afterEnter', performLeave)
        mergeVNodeHook(data, 'enterCancelled', performLeave)
        // 回调设置,把 leave 执行的执行逻辑保留下来
        mergeVNodeHook(oldData, 'delayLeave', leave => { delayedLeave = leave })
      }
    }
 
    return rawChild
  }
}

可以看出单纯从组件定义上讲,组件的 render() 主要做了这几件事:

  • 得到真实 children,得到第一个 child,因为只允许有一个 child 所以这里取第一个即可
  • 设置 id 和 key,确保唯一
  • 如果多个元素,根据 mode,决定监听不同的 hook 进行处理

但是,从这些我们是不能够理解咋执行的,那是因为还缺少一部分核心的逻辑,在 github.com/vuejs/vue/b… 中实现的:

PS:需要依赖我们在Vue - The Good Parts: 组件中讲述的 modules 知识,也就是在 patch 的过程中,会调用各个 module 的各个钩子 'create', 'activate', 'update', 'remove', 'destroy',对应的就是 vnode 本身的一些更新。这里最核心的就是利用了 create 和 remove 钩子。

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()
    }
  }
} : {}

先来看下 enter 的逻辑:

export function enter (vnode: VNodeWithData, toggleDisplay: ?() => void) {
  const el: any = vnode.elm
 
  // call leave callback now
  // 如果 还没等到 leave 就又更新了 那么直接结束上次的 leave 相当于取消了 leave
  if (isDef(el._leaveCb)) {
    el._leaveCb.cancelled = true
    el._leaveCb()
  }
 
  const data = resolveTransition(vnode.data.transition)
  if (isUndef(data)) {
    return
  }
 
  /* istanbul ignore if */
  if (isDef(el._enterCb) || el.nodeType !== 1) {
    return
  }
  // 从 transition data 中直接获取配置的一些 props 以及绑定的事件们
  const {
    css,
    type,
    enterClass,
    enterToClass,
    enterActiveClass,
    appearClass,
    appearToClass,
    appearActiveClass,
    beforeEnter,
    enter,
    afterEnter,
    enterCancelled,
    beforeAppear,
    appear,
    afterAppear,
    appearCancelled,
    duration
  } = data
 
  // activeInstance will always be the <transition> component managing this
  // transition. One edge case to check is when the <transition> is placed
  // as the root node of a child component. In that case we need to check
  // <transition>'s parent for appear check.
  // context 就是当前的 transition 组件实例
  let context = activeInstance
  let transitionNode = activeInstance.$vnode
  while (transitionNode && transitionNode.parent) {
    context = transitionNode.context
    transitionNode = transitionNode.parent
  }
  // 是否可见了已经
  const isAppear = !context._isMounted || !vnode.isRootInsert
  // 当然这里依旧可以通过 appear prop 属性来强制改变状态
  if (isAppear && !appear && appear !== '') {
    return
  }
  // 对 class 名字的一对处理
  const startClass = isAppear && appearClass
    ? appearClass
    : enterClass
  const activeClass = isAppear && appearActiveClass
    ? appearActiveClass
    : enterActiveClass
  const toClass = isAppear && appearToClass
    ? appearToClass
    : enterToClass
 
  // hook 各种处理
  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
  )
 
  if (process.env.NODE_ENV !== 'production' && explicitEnterDuration != null) {
    checkDuration(explicitEnterDuration, 'enter', vnode)
  }
 
  const expectsCSS = css !== false && !isIE9
  const userWantsControl = getHookArgumentsLength(enterHook)
  // enter 回调,once 确保执行一次
  const cb = el._enterCb = once(() => {
    if (expectsCSS) {
      removeTransitionClass(el, toClass)
      removeTransitionClass(el, activeClass)
    }
    // 同样会存在 cancel 情况
    if (cb.cancelled) {
      if (expectsCSS) {
        removeTransitionClass(el, startClass)
      }
      enterCancelledHook && enterCancelledHook(el)
    } else {
      // 调用 afterEnter 的 hook
      afterEnterHook && afterEnterHook(el)
    }
    el._enterCb = null
  })
 
  if (!vnode.data.show) {
    // remove pending leave element on enter by injecting an insert hook
    mergeVNodeHook(vnode, 'insert', () => {
      const parent = el.parentNode
      // 动画过程中的当前元素,如果被插入了,那么应该直接结束之前的 leave 动画
      // 数据是挂载到 父元素的 _pending 属性 prop 上的
      const pendingNode = parent && parent._pending && parent._pending[vnode.key]
      if (pendingNode &&
        pendingNode.tag === vnode.tag &&
        pendingNode.elm._leaveCb
      ) {
        pendingNode.elm._leaveCb()
      }
      // 调用 enter 的钩子
      enterHook && enterHook(el, cb)
    })
  }
 
  // start enter transition
  // 调用 beforeEnter 钩子
  beforeEnterHook && beforeEnterHook(el)
  if (expectsCSS) {
    // 增加 css 过渡的 class
    addTransitionClass(el, startClass)
    addTransitionClass(el, activeClass)
    // 下一帧即 requestAnimationFrame 简称 raf
    nextFrame(() => {
      // 移除掉 startClass
      removeTransitionClass(el, startClass)
      if (!cb.cancelled) {
        addTransitionClass(el, toClass)
        if (!userWantsControl) {
          // 利用 timeout 或者监听 transitionend/animationend 结束事件 调用 cb
          if (isValidDuration(explicitEnterDuration)) {
            setTimeout(cb, explicitEnterDuration)
          } else {
            whenTransitionEnds(el, type, cb)
          }
        }
      }
    })
  }
 
  if (vnode.data.show) {
    toggleDisplay && toggleDisplay()
    enterHook && enterHook(el, cb)
  }
 
  if (!expectsCSS && !userWantsControl) {
    cb()
  }
}

enter 的时候最核心的就是在 nextFrame 中移除了 startClass,使得可以有一帧去渲染且马上移除进而做动画。

再来看下 leave 的逻辑:

export function leave (vnode: VNodeWithData, rm: Function) {
  // rm 就是真正的 操作 DOM 移除元素的函数
  const el: any = vnode.elm
 
  // call enter callback now
  // 相对等的逻辑 还没等到 enter 完成 就已经 leave 了
  if (isDef(el._enterCb)) {
    el._enterCb.cancelled = true
    el._enterCb()
  }
 
  const data = resolveTransition(vnode.data.transition)
  if (isUndef(data) || el.nodeType !== 1) {
    return rm()
  }
 
  /* istanbul ignore if */
  if (isDef(el._leaveCb)) {
    return
  }
 
  const {
    css,
    type,
    leaveClass,
    leaveToClass,
    leaveActiveClass,
    beforeLeave,
    leave,
    afterLeave,
    leaveCancelled,
    delayLeave,
    duration
  } = data
 
  const expectsCSS = css !== false && !isIE9
  const userWantsControl = getHookArgumentsLength(leave)
 
  const explicitLeaveDuration: any = toNumber(
    isObject(duration)
      ? duration.leave
      : duration
  )
 
  if (process.env.NODE_ENV !== 'production' && isDef(explicitLeaveDuration)) {
    checkDuration(explicitLeaveDuration, 'leave', vnode)
  }
  // leave 完成的 cb
  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 && afterLeave(el)
    }
    el._leaveCb = null
  })
  // 如果是 delayLeave 即 in-out 模式
  if (delayLeave) {
    delayLeave(performLeave)
  } else {
    performLeave()
  }
  // 真正执行 leave 动画逻辑
  function performLeave () {
    // the delayed leave may have already been cancelled
    if (cb.cancelled) {
      return
    }
    // record leaving element
    // 记录下 移除中 的元素,注意和 enter 中对应,挂载到 父元素上
    if (!vnode.data.show && el.parentNode) {
      (el.parentNode._pending || (el.parentNode._pending = {}))[(vnode.key: any)] = vnode
    }
    // beforeLeave 钩子
    beforeLeave && beforeLeave(el)
    if (expectsCSS) {
      // 增加 class
      addTransitionClass(el, leaveClass)
      addTransitionClass(el, leaveActiveClass)
      // 相似的逻辑 下一帧 移除 class
      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()
    }
  }
}

对比下 enter 的部分,发现基本上的大概逻辑还是一致的。

整体还是利用 transition data 传递了来自 transition 的 prop 值以及对应的监听的事件钩子函数,将他们挂载在 vnode.data 上,这样在 transition module 中可以直接根据 vnode 上挂载的相关数据直接进行操作。

大概总结整体的逻辑关系就是:

  • render() 根据新旧 children 的 vnode 信息决定返回的内容,以此决定了后续 patch 过程中是走 create 还是 remove 的钩子;这个过程中同样会把 transition 组件上传入的 props 以及相关事件监听附在 vnode.data 上
  • 在 transition module 中,注册了 create 和 remove 的钩子,然后结合 vnode.data 中的 transition data 进行 leave 和 enter 相关的过渡动画处理

transition-group

除了上边分析的 transition 组件,Vue 还提供了在列表场景下的过渡组件 transition-group cn.vuejs.org/v2/guide/tr…

image2021-7-9_21-28-58.png

讲述了 transition-group 组件的几个特点以及相关的注意事项。

那么这个组件详细背后的逻辑是啥,一起看看 github.com/vuejs/vue/b…

// extend 了 transition 组件的 props
const props = extend({
  tag: String,
  moveClass: String
}, transitionProps)
// 不支持 mode 了
delete props.mode
 
export default {
  props,
 
  beforeMount () {
    // beforeMount 钩子中所做的事情
    // 劫持(代理了) _update 函数,我们知道这个函数的核心是 执行 patch 以及 更新当前活动的组件实例 active instance
    const update = this._update
    this._update = (vnode, hydrating) => {
      // 当 _update 被调用的时候 传入的 vnode 就是调用 render() 得到的
      const restoreActiveInstance = setActiveInstance(this)
      // force removing pass
      // 此时是将上次的 vnode 和 kept(列表中需要保留的)vnode 进行 patch
      // 进而将那些需要移除的节点删除
      this.__patch__(
        this._vnode,
        this.kept,
        false, // hydrating
        true // removeOnly (!important, avoids unnecessary moves)
      )
      // 更新此时的新的 _vnode 为 kept 中的
      this._vnode = this.kept
      // 恢复 active instance
      restoreActiveInstance()
      // 执行原本的 patch 正常逻辑
      update.call(this, vnode, hydrating)
    }
  },
 
  render (h: Function) {
    const tag: string = this.tag || this.$vnode.data.tag || 'span'
    const map: Object = Object.create(null)
    const prevChildren: Array<VNode> = this.prevChildren = this.children
    const rawChildren: Array<VNode> = this.$slots.default || []
    const children: Array<VNode> = this.children = []
    const transitionData: Object = extractTransitionData(this)
    // 首先遍历现在的 children
    for (let i = 0; i < rawChildren.length; i++) {
      const c: VNode = rawChildren[i]
      if (c.tag) {
        // 记录 tag 元素相关的
        // 一定要有 key,放入 children 中,且这个 key 不能是 自动生成的 key
        // 同时利用 map 做了一个按 key 缓存
        if (c.key != null && String(c.key).indexOf('__vlist') !== 0) {
          children.push(c)
          map[c.key] = c
          ;(c.data || (c.data = {})).transition = transitionData
        } else if (process.env.NODE_ENV !== 'production') {
          const opts: ?VNodeComponentOptions = c.componentOptions
          const name: string = opts ? (opts.Ctor.options.name || opts.tag || '') : c.tag
          warn(`<transition-group> children must be keyed: <${name}>`)
        }
      }
    }
    if (prevChildren) {
      const kept: Array<VNode> = []
      const removed: Array<VNode> = []
      // 遍历之前的 children
      for (let i = 0; i < prevChildren.length; i++) {
        const c: VNode = prevChildren[i]
        // 保存好之前的 transition data
        c.data.transition = transitionData
        // 增加位置信息
        c.data.pos = c.elm.getBoundingClientRect()
        // 如果这个 key 的元素在新的里边也存在 那么就放入 kept 中
        if (map[c.key]) {
          kept.push(c)
        } else {
          removed.push(c)
        }
      }
      // 保存需要保留的元素
      this.kept = h(tag, null, kept)
      // 保存需要删除的元素 其实这个是没有用的
      this.removed = removed
    }
    // 返回包含 children 的指定 tag 的 vnode 元素
    return h(tag, null, children)
  },
  updated () {
    // 之前的 children
    const children: Array<VNode> = this.prevChildren
    // move 的 class
    const moveClass: string = this.moveClass || ((this.name || 'v') + '-move')
    if (!children.length || !this.hasMove(children[0].elm, moveClass)) {
      return
    }
    // 下面的就是实现和 https://cn.vuejs.org/v2/guide/transitions.html#%E5%88%97%E8%A1%A8%E7%9A%84%E6%8E%92%E5%BA%8F%E8%BF%87%E6%B8%A1 FLIP 相关动画
    // we divide the work into three loops to avoid mixing DOM reads and writes
    // in each iteration - which helps prevent layout thrashing.
    // 所有的 children 一起:
    // 1. 调用每个 child 的 _moveCb 和 _enterCb,上一次还没完成的,所以是 pending 的命名
    // 2. 记录每个 child 的位置信息
    // 3. 给每一个应用 0s 的 位置差 transform 动画 让元素”恢复“在原位(位置差)
    children.forEach(callPendingCbs)
    children.forEach(recordPosition)
    children.forEach(applyTranslation)
 
    // force reflow to put everything in position
    // assign to this to avoid being removed in tree-shaking
    // $flow-disable-line
    // 强制 reflow 确保浏览器重新绘制到指定位置
    this._reflow = document.body.offsetHeight
 
    children.forEach((c: VNode) => {
      if (c.data.moved) {
        // 如果有移动
        const el: any = c.elm
        const s: any = el.style
        // 增加 move class
        addTransitionClass(el, moveClass)
        // 重置 transform & transitionDuration
        s.transform = s.WebkitTransform = s.transitionDuration = ''
        el.addEventListener(transitionEndEvent, el._moveCb = function cb (e) {
          // 动画结束回调
          if (e && e.target !== el) {
            return
          }
          if (!e || /transform$/.test(e.propertyName)) {
            el.removeEventListener(transitionEndEvent, cb)
            el._moveCb = null
            // 移除 class
            removeTransitionClass(el, moveClass)
          }
        })
      }
    })
  },
 
  methods: {
    // 判断是否应该应用移动 feat
    hasMove (el: any, moveClass: string): boolean {
      /* istanbul ignore if */
      if (!hasTransition) {
        return false
      }
      /* istanbul ignore if */
      // 缓存
      if (this._hasMove) {
        return this._hasMove
      }
      // Detect whether an element with the move class applied has
      // CSS transitions. Since the element may be inside an entering
      // transition at this very moment, we make a clone of it and remove
      // all other transition classes applied to ensure only the move class
      // is applied.
      const clone: HTMLElement = el.cloneNode()
      // clone 一个 将之前的 _transitionClasses 移除
      if (el._transitionClasses) {
        el._transitionClasses.forEach((cls: string) => { removeClass(clone, cls) })
      }
      // 只加上 move 的 class
      addClass(clone, moveClass)
      clone.style.display = 'none'
      this.$el.appendChild(clone)
      // 得到新的 transition 信息
      const info: Object = getTransitionInfo(clone)
      // 移除这个 clone 的元素
      this.$el.removeChild(clone)
      // 判断 动画中有没有做 transform 相关的动画
      return (this._hasMove = info.hasTransform)
    }
  }
}
 
function callPendingCbs (c: VNode) {
  /* istanbul ignore if */
  if (c.elm._moveCb) {
    c.elm._moveCb()
  }
  /* istanbul ignore if */
  if (c.elm._enterCb) {
    c.elm._enterCb()
  }
}
 
function recordPosition (c: VNode) {
  c.data.newPos = c.elm.getBoundingClientRect()
}
 
function applyTranslation (c: VNode) {
  const oldPos = c.data.pos
  const newPos = c.data.newPos
  const dx = oldPos.left - newPos.left
  const dy = oldPos.top - newPos.top
  if (dx || dy) {
    c.data.moved = true
    const s = c.elm.style
    s.transform = s.WebkitTransform = `translate(${dx}px,${dy}px)`
    s.transitionDuration = '0s'
  }
}

现在让我们梳理下上边的逻辑搭配 Vue 的渲染逻辑的最终样子:

  • beforeMount
    • 劫持了 _update
  • 第一次 render()
    • children 节点设置 transition data,通过上边 transition 的分析我们知道 只要节点上保存了 transition data 信息,就可以执行对应的 transition 相关过渡动画逻辑
  • 调用 _update
    • 约等于做了一次空 patch
    • 执行真正的原始 patch 逻辑
  • 当有更新的时候,第二次 render()
    • 核心对比,新旧 children,找出 kept 的节点数据
    • 返回新的 vnode 数据
  • 再次 _update
    • 进行一次 kept 和 现有的 vnode 数据的 patch,结论就是把需要删除的元素移除掉
    • 然后进行正常的原始的 patch 逻辑,这个时候 patch 进行比较的是 kept 和 新得到的 vnode 数据
  • updated
    • 根据新的 DOM 得到最新的列表节点的位置信息
    • 利用 transform 根据元素位置差 将元素“恢复”到原位
    • 给元素增加 move 的 class(做过渡动画)

代理 _update 实现了列表项的增加、删除动画逻辑,updated 钩子中则完成了 move 这个 feature 的(如应用 FLIP 动画)逻辑。

Why

个人认为,大概的原因,最核心的一个点,Vue 是一个框架,需要根据开发者的日常场景提供功能 feature(当然,选择权在 Vue 团队),考虑到在 Web 中过渡动画类需求如此场景,尤其是有了 CSS 相关的过渡动画规范之后,而且基本没啥兼容性了,当然考虑到利用 JS 精细控制过渡动画,还提供了友好的钩子,开发者可以自行选择。

而且,Vue 不但要做,还是做到很好,通过上边的分析,我们知道了过渡相关组件提供的能力,基本可以和官网说的一样:唯一的限制是你的想象力。Vue 已经提供了如此灵活强大的组件,你的场景都可以满足!

总结

这次我们基本分析了 Vue 中提供的两个强大的过渡组件 transition 和 transition-group,对应的代码虽然不是很多,但是给开发者提供的功能却有很多,基本上涵盖了日常开发过渡动画的大多数场景,甚至借助于其动态过渡能力,可以很方便的自定义出满足复杂场景的过渡动画效果。

那从 transition 以及 transition-group 的分析中,我们可以学到什么呢?

过渡动画

从上边分析我们知道,Vue 中实现过渡动画分为了三种过渡类名:

  • 过渡生效状态——定义整个过渡阶段,一般用于设置过渡时间、延迟等,会在整个动画结束后移除
  • 过渡开始状态——元素插入后下一帧就会移除
  • 过渡结束状态——开始状态移除后,就设置了结束状态,会在动画结束后移除

以及提供了钩子(以Enter举例):

  • before-enter 未进入之前,元素还没插入
  • enter 元素插入之后
  • after-enter 过渡动画结束后
  • enter-cancelled 过渡动画被取消

不仅仅满足了大家使用 CSS 做过渡动画的场景,同时也满足了利用 JS 做动画的诉求。API 或者类名切换的理解成本很低,这个是我们可以在自己抽象和设计过渡动画相关的 API 的时候,可以学习参考的。

代理模式

在 transition-group 中,利用对 _update 的代理,实现了一次更新,两次 patch 的目标。代理模式是一种很有用的模式,在很多场景中我们都是可以使用,这种方式意味着我们可以在不修改原有代码的基础上就可以实现功能扩展,很符合开闭原则。

异步回调

在分析中,我们看到了关于异步回调的处理,如 _enterCb、_leaveCb、_moveCb 这种处理,这些回调函数在正常流程中调用的话没问题,但是会存在一些异常情况,这里的典型就是数据再次更新,再次需要 enter 或者 leave 怎么办,因为处理过程是一个异步的,这个时候需要清除上一次更新的影响,Vue 中此时的处理是统一都会调用这个回调,回调内部根据这个 cb 的 cancelled 属性决定是取消模式还是支持回调的。

同时这里边也可以看到及时的消除引用关系,释放内存。

模块解耦

我们此时已经知道了,transition 的功能实现依赖两个:一个是 transition 组件的定义,一个是 transition module 模块的钩子处理。本来这部分应该是一个强耦合的逻辑,在 Vue 中,因为有了 vnode 的存在,他们可以彼此解耦。

transition 过渡动画的核心逻辑都在 transition module 中,也正是因为这个,很容易实现了 transition-group 组件。

这里边的另一个解耦,就是利用钩子的处理,我们已经明显感知到了他们存在。

延迟设值

在 transition 组件的 render 中存在这样一段逻辑:

// mode === 'in-out'
let delayedLeave
const performLeave = () => { delayedLeave() }
// 监听 afterEnter 钩子 执行之前保留下来的 leave 回调逻辑
// 这样实现了先 in 后 out 的效果,因为是等到 afterEnter 之后才走的 leave 逻辑
mergeVNodeHook(data, 'afterEnter', performLeave)
mergeVNodeHook(data, 'enterCancelled', performLeave)
// 回调设置,把 leave 执行的执行逻辑保留下来
mergeVNodeHook(oldData, 'delayLeave', leave => { delayedLeave = leave })

这是一段异步加异步的情况,通过在 delayLeave 钩子中,设置了 delayedLeave 这个回调的值,然后在其他场景(时机)去调用这个 delayedLeave 回调。

同时这里为了确保 afterEnter 的钩子函数是一定存在的,所以新增加了一个函数 performLeave,巧用了闭包的技巧实现了访问后设置的 delayedLeave。

其他小Tips

  • 在组件中对 vnode 的各种使用,例如如何获取上一次的 children 信息,如果获取当前 children 信息
  • mergeVNodeHook 是如何实现的 github.com/vuejs/vue/b…
  • FLIP 动画 aerotwist.com/blog/flip-y…
  • 强制reflow
  • once 的实现以及应用
  • $forceUpdate API 以及其应用

滴滴前端技术团队的团队号已经上线,我们也同步了一定的招聘信息,我们也会持续增加更多职位,有兴趣的同学可以一起聊聊。