Vue.js 源码学习九 —— 过渡效果 transition 学习

Cricket 2019-06-27

在学习 element ui 时,发现组件的过渡用的是 Vue.js 提供的 <transition> 标签。这里来好好认识下 vue 的过渡到底是如何工作的。

简介

废话不多说,详细的内容请看官方文档,里面有详细的分析和例子够你看懂了(就是费时间~)。简单说说我对 vue 过渡的理解。经过一下午的折腾,总结出以下几点:

  • 有四种情况会触发过渡效果:
    1 v-if
    2 v-show
    3 动态组件(如 component 的 is 属性)
    4 组件根节点发生变化(如 v-if v-else 切换根节点)
  • 过渡效果 CSS 命名规律:(name 属性,默认为 v)-(行为:enter、leave、appear、move)-(阶段:无、active、to)
  • 有三种方式来设置过渡样式:
    1 为 <transition> 标签设定 name 属性。
    2 在 <transition> 标签中插入 enter-active-class 等设置自定义过渡类名。
    3 使用 JavaScript 在过渡的钩子处修改过渡样式。
  • 个人理解:<transition> 标签用于单个元素的进入和离开效果。<transition-group> 标签用于处理如 v-for 遍历这样多个元素的过渡动画。

自己实现个过渡方法

先来两个简单例子理解下 transition(为了节省篇幅和便于查看写在 JSFiddle 中)有兴趣的朋友可以看下~
例1:v-enter 和 v-leave 简单实现
例2:v-move 简单实现

transition 学习

1. 基本原理是什么?

基本原理还是 CSS3 的 transitiontransformanimation 这几个属性。用户定义过渡效果,Vue.js 进行处理。下面我们通过 <transition> 过渡的进入的过程看一下:

  • 插入元素
  • 解析 <transition> 标签,获取对应的过渡类名。这里默认就 v- 开头了。
  • 为元素定义 v-enter 和 v-enter-active 两个类。class="v-enter v-enter-active"
  • 下一帧移除 v-enter,添加 v-enter-to。class="v-enter-active v-enter-to"
  • 获取过渡时间,延时执行回调函数。
  • 回调函数中移除 v-enter、v-enter-active 和 v-enter-to 的这些过渡类名,完成过渡。
  • 在整个过程中调用了 beforeEnterHookenterHookafterEnterHookenterCancelledHook 四个函数,执行相应的 JavaScript 钩子

下面是 enter 函数的代码及注释:

// 进入过渡效果
export function enter (vnode: VNodeWithData, toggleDisplay: ?() => void) {
  const el: any = vnode.elm

  // call leave callback now 执行 leave 回调函数
  if (isDef(el._leaveCb)) {
    el._leaveCb.cancelled = true
    el._leaveCb()
  }

  // 解析 transition 的数据(class、tag、name等)
  const data = resolveTransition(vnode.data.transition)
  if (isUndef(data)) {
    return
  }

  /* istanbul ignore if */
  if (isDef(el._enterCb) || el.nodeType !== 1) {
    return
  }

  const {
    css,
    type,
    enterClass,
    enterToClass,
    enterActiveClass,
    appearClass,
    appearToClass,
    appearActiveClass,
    beforeEnter,
    enter,
    afterEnter,
    enterCancelled,
    beforeAppear,
    appear,
    afterAppear,
    appearCancelled,
    duration
  } = data

  // 将作为子组件的根节点放置时,我们需要检查 <transition> 的父元素是否出现检查。
  let context = activeInstance
  let transitionNode = activeInstance.$vnode
  while (transitionNode && transitionNode.parent) {
    transitionNode = transitionNode.parent
    context = transitionNode.context
  }

  const isAppear = !context._isMounted || !vnode.isRootInsert

  if (isAppear && !appear && appear !== '') {
    return
  }

  // 获取进入的 class
  // v-enter
  const startClass = isAppear && appearClass
    ? appearClass
    : enterClass
  // v-enter-active
  const activeClass = isAppear && appearActiveClass
    ? appearActiveClass
    : enterActiveClass
  // v-enter-to
  const toClass = isAppear && appearToClass
    ? appearToClass
    : enterToClass

  // 4个生命周期钩子函数
  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

  // https://cn.vuejs.org/v2/guide/transitions.html#显性的过渡持续时间
  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)

  // 完成进入过渡后的回调函数
  const cb = el._enterCb = once(() => {
    if (expectsCSS) {
      // 移除 v-enter-to 和 v-enter-active
      removeTransitionClass(el, toClass)
      removeTransitionClass(el, activeClass)
    }
    if (cb.cancelled) {
      if (expectsCSS) {
        // 移除 v-enter
        removeTransitionClass(el, startClass)
      }
      // 调用 enter-cancelled
      enterCancelledHook && enterCancelledHook(el)
    } else {
      afterEnterHook && afterEnterHook(el)
    }
    el._enterCb = null
  })

  if (!vnode.data.show) {
    // 通过注入一个 insert 钩子,将待处理的 leave 元素移除。
    mergeVNodeHook(vnode, 'insert', () => {
      const parent = el.parentNode
      const pendingNode = parent && parent._pending && parent._pending[vnode.key]
      if (pendingNode &&
        pendingNode.tag === vnode.tag &&
        pendingNode.elm._leaveCb
      ) {
        pendingNode.elm._leaveCb()
      }
      enterHook && enterHook(el, cb)
    })
  }

  // start enter transition
  beforeEnterHook && beforeEnterHook(el)
  // 预期 CSS
  if (expectsCSS) {
    // 添加 v-enter v-enter-active
    addTransitionClass(el, startClass)
    addTransitionClass(el, activeClass)
    // 下一帧
    nextFrame(() => {
      // 移除 v-enter
      removeTransitionClass(el, startClass)
      if (!cb.cancelled) {
        // 添加 v-enter-to
        addTransitionClass(el, toClass)
        if (!userWantsControl) {
          // 预期进入时间
          if (isValidDuration(explicitEnterDuration)) {
            setTimeout(cb, explicitEnterDuration)
          } else {
            // 当 transition 结束
            whenTransitionEnds(el, type, cb)
          }
        }
      }
    })
  }

  if (vnode.data.show) {
    toggleDisplay && toggleDisplay()
    enterHook && enterHook(el, cb)
  }

  if (!expectsCSS && !userWantsControl) {
    cb()
  }
}

2. 过渡的类名自定义过渡的类名如何用于 <transition> 中?

<transition> 中一共有如下属性(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]
}

可以看到其中就有这些自定义过渡类名,如 enterClass。这些属性如被传入到 <transition> 子组件的 data.transition 对象中。

// extractTransitionData 函数返回组件的所有 propsData 和 listener
    const data: Object = (child.data || (child.data = {})).transition = extractTransitionData(this)

而这个 data.transition 对象在 enter 函数中用到:

const data = resolveTransition(vnode.data.transition)

resolveTransition 函数:

// 解析 transition 过渡 CSS
export function resolveTransition (def?: string | Object): ?Object {
  if (!def) {
    return
  }
  // 合并过渡类名和自定义过渡类名
  if (typeof def === 'object') {
    const res = {}
    if (def.css !== false) {
      // 使用 name,默认为 v
      extend(res, autoCssTransition(def.name || 'v'))
    }
    extend(res, def)
    return res
  } else if (typeof def === 'string') {
    return autoCssTransition(def)
  }
}
// 通过 name 属性获取过渡 CSS 类名
const autoCssTransition: (name: string) => Object = cached(name => {
  return {
    enterClass: `${name}-enter`,
    enterToClass: `${name}-enter-to`,
    enterActiveClass: `${name}-enter-active`,
    leaveClass: `${name}-leave`,
    leaveToClass: `${name}-leave-to`,
    leaveActiveClass: `${name}-leave-active`
  }
})

resolveTransition 函数合并了过渡类名和自定义过渡类名,返回最终的过渡类名。之后就是使用这些类名来实现过渡动画。
PS:从源码中可以知道自定义过渡类名要优先于 name 定义的过渡类名。
小结一下就是:Vue.js 通过 <transition> 的 props 获取自定义过渡类名,通过 <transition> 的 name 属性解析获取过渡类名,两者合并成为最终过渡类名,用以实现过渡效果。

3. JavaScript 钩子如何实现?

enter 函数中可以知道,在特定时间点会调用指定 JavaScript 钩子函数,所以我们只需绑定好函数即可按时间点触发。像这样:

enterHook && enterHook(el, cb)

4. transition 组件和 transition-group 标签的基本原理是什么?

其实就是 Vue.js 的组件,在其中实现了过渡效果而已。
transition 中只能包含一个子元素,标签通过 render 函数来渲染子元素(不渲染自身,所以我们在 DOM 中看不到 transition 节点)。主要用于控制元素的进入和离开,当元素离开后元素就从 DOM 中移除了。
transition-group 可以包含多个子元素,也是用 render 函数,渲染为指定标签名的元素。相比 transition 多了一个 v-move 属性用于控制多个组件间的移动速度。

5. v-if、v-show、component 等组件变化如何监听?

在使用 v-if、v-else 和 component 切换组件的时候,v-if、v-else 需要传入 key 以区分相同标签的不同元素。而 component 标签不需要。在代码中会解析 key 和 component 名组成新的 key,所以两个不同的 component 也会拥有不同的 key 实现切换效果。

var id = "__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;

而对于 v-show,做了特殊标记 —— 当有 v-show 指令时标记 child.data.show 为 true:

if (child.data.directives && child.data.directives.some(d => d.name === 'show')) {
      child.data.show = true
    }

之后再过渡的逻辑中对 v-show 做了些处理,实现过渡效果。
同时,在 v-show 的源码 src/platforms/web/runtime/directives/show.js 中对于 transition 也做了一些处理。比如在 update 方法中获取 transition,如果有过渡则 v-show 使用过渡效果,否则使用 style.display 来隐藏元素。

update (el: any, { value, oldValue }: VNodeDirective, vnode: VNodeWithData) {
    if (value === oldValue) return
    vnode = locateNode(vnode)
    // 过渡效果
    const transition = vnode.data && vnode.data.transition
    if (transition) {
      vnode.data.show = true
      if (value) {
        enter(vnode, () => {
          el.style.display = el.__vOriginalDisplay
        })
      } else {
        leave(vnode, () => {
          el.style.display = 'none'
        })
      }
    } else {
      // 隐藏
      el.style.display = value ? el.__vOriginalDisplay : 'none'
    }
  },

6. transition 中两个相同标签的组件为何要用 key 分开?

使用 key 和 tagName 来判断是否为同一个节点。

function isSameChild (child: VNode, oldChild: VNode): boolean {
  return oldChild.key === child.key && oldChild.tag === child.tag
}

8. 过渡逻辑和过渡组件如何作用于一起

在源码中有四个过渡相关的源码:

  • src/platforms/web/runtime/components/transition.js <transition> 组件源码。
  • src/platforms/web/runtime/components/transition-group.js <transition-group> 组件源码
  • src/platforms/web/runtime/transition-util.js 过渡工具代码。
  • src/platforms/web/runtime/modules/transition.js 过渡逻辑代码。

前三个很好理解,最后一个 transition.js 其实是在 patch 方法中和 v-show 中使用的~

// src/platforms/web/runtime/directives/show.js
import { enter, leave } from '../modules/transition'

v-show 中调用了 transition 的 enter 和 leave 函数,在 v-show 作用于过渡效果时调用。
另外一个使用的地方比较隐蔽,先来看看 transition.js 导出的内容:

// src/platforms/web/runtime/modules/transition.js
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 和 leave 函数在方法中使用并导出(如果是浏览器的话)。继续往下找:

// src/platforms/web/runtime/modules/index.js
import transition from './transition'

导入到 modules 文件夹 index.js,index.js 在 patch.js 中使用了。

// src/platforms/web/runtime/patch.js
import * as nodeOps from 'web/runtime/node-ops'
import { createPatchFunction } from 'core/vdom/patch'
import baseModules from 'core/vdom/modules/index'
import platformModules from 'web/runtime/modules/index'

const modules = platformModules.concat(baseModules)
export const patch: Function = createPatchFunction({ nodeOps, modules })

在此处合并 modules,并且创建了 patch 方法。这个 patch 方法在之前写的Vue.js 源码学习六 —— VNode虚拟DOM学习中提到过,用于对比虚拟 DOM,实现差异化更新。
可以看下 modules 在 createPatchFunction 方法中做了些什么?

const hooks = ['create', 'activate', 'update', 'remove', 'destroy']

export function createPatchFunction (backend) {
  let i, j
  const cbs = {}

  const { modules, nodeOps } = backend

  for (i = 0; i < hooks.length; ++i) {
    cbs[hooks[i]] = []
    for (j = 0; j < modules.length; ++j) {
      if (isDef(modules[j][hooks[i]])) {
        cbs[hooks[i]].push(modules[j][hooks[i]])
      }
    }
  }
  ...
}

这里可以发现 transition.js 中导出的 create、activate 和 remove 方法都是 patch 的生命周期函数。也就是说当元素创建、激活、移除行为时就会执行 transition.js 中的逻辑,而 <transition> 和 <transition-group> 组件都会有组件的这些行为。Vue.js 很巧妙的将组件相关行为都交给了 patch 的生命周期去处理,学习了!

8. 过渡模式 mode 的实现原理是啥

在 <transition> 组件的 render 函数中有这么一段:

// 控制离开/进入的过渡时间序列。有效的模式有 "out-in" 和 "in-out";默认同时生效。
      if (mode === 'out-in') {
        // return placeholder node and queue update when leave finishes
        this._leaving = true
        mergeVNodeHook(oldData, 'afterLeave', () => {
          this._leaving = false
          this.$forceUpdate()
        })
        return placeholder(h, rawChild)
      } else if (mode === 'in-out') {
        if (isAsyncPlaceholder(child)) {
          return oldRawChild
        }
        let delayedLeave
        const performLeave = () => { delayedLeave() }
        mergeVNodeHook(data, 'afterEnter', performLeave)
        mergeVNodeHook(data, 'enterCancelled', performLeave)
        mergeVNodeHook(oldData, 'delayLeave', leave => { delayedLeave = leave })
      }

这里就是 mode 的实现代码了,先看看两种 mode 的用法

  • in-out:新元素先进行过渡,完成之后当前元素过渡离开。
  • out-in:当前元素先进行过渡,完成之后新元素过渡进入。

可以看到,在 out-in 逻辑中,当切换元素时,先不渲染第二个组件而是返回,之后才会返回 placeholder 函数结果,当第一个元素完全 leave 后加载第二个元素。而在 in-out 元素中做的是将第一个元素延时到第二个元素 enter 后再 leave。

9. <transition-group> 的 v-move 重新排序一组内容,如何实现的移动变化?

比如我们有一个 1-5 的数组使用 v-for 遍历显示到 transition-group 中,当数组发生变化时,会做如下操作:

  • 初始数组 [ 1, 2, 3, 4, 5 ]
  • 数组发生变化 [ 1, 4, 3, 2, 5 ]
  • 在 render 函数中记录变化前后额数组 preChildren 和 children 两个 VNode 数组。
  • 在 render 函数中使用 getBoundingClientRect() 方法记录变化前每个元素的位置 oldPos。
  • 获取要保留和移除的元素数组。
  • 渲染变化后的数组元素。
  • 在 beforeUpdate 方法中使用 patch 方法移除要移除的元素。
  • 进入 Updated 方法中,注意此时渲染结果已经是新数组 [ 1, 4, 3, 2, 5 ] 了。
  • 获取过渡类名和子元素数组 children。
  • 遍历调用

    • 执行回调函数
    • 计算当前各元素位置 newPos
    • 根据 oldPos 和 newPos,使用内联样式 translate(${dx}px,${dy}px) 将元素移动到之前的位置,看着就像是 [ 1, 2, 3, 4, 5 ]
  • 最后遍历元素,添加 moveClass 类名,移除 translate(${dx}px,${dy}px) 内联样式。绑定 transitionend 事件。

代码太长,就不多贴了~可以点击这里跳转查看。总结下来就是先改变元素,然后把元素移动成之前的样子,然后使用过渡类名定义过渡时间实现过渡效果。
v-move 的关键就是“假装元素位置没变”的行为。让我们看上去像是慢慢移动的。

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) {
    // 定义 0 秒的 translate 内联样式把元素移动到原来的样子
    c.data.moved = true
    const s = c.elm.style
    s.transform = s.WebkitTransform = `translate(${dx}px,${dy}px)`
    s.transitionDuration = '0s'
  }
}

10. vue 的 transition 和 CSS3 的 transition 有何不同?

基本原理都是使用了 CSS3 的 transition,但是 Vue 的 transition 组件是配合着 VDOM 来写的、同时提供了过渡各阶段效果的 CSS 和 JS 控制,便于我们快捷、精确、安全地实现一些简单或复杂的过渡效果。

最后

原本只是想看看 transition 如何实现,却扯出这么一堆问题。其中关于 transition 和 transition-group 组件讲的有点草率,有兴趣可以再深入学习下~
从本次学习中我学到了:

  • 更加优雅高效的 JS 逻辑写法(patch 中的生命周期统一处理 DOM 操作中的逻辑)
  • 更加熟悉 CSS3 的 transition 过渡属性。
  • 解决了我对 transition 的各种疑问。

OK,关于 Vue 的过渡效果就聊到这儿了,写了三天……我得去休息休息了 0.0

相关推荐