Vue不让过渡/动画成为短板之transition组件实现原理

更新日期: 2019-12-24阅读: 1.6k标签: 原理

动画一直是前端比较纠结的点,容易被忽视却又是那么重要,能写出让人感到愉悦自然的交互体验确实能为项目增色不少,毕竟这是上手就能感受到的,所以很有必要对vue的transition组件实现原理一探究竟。transition组件的动画实现分为两种,使用css类名和JavaScript钩子,接下来依次介绍。

transition组件介绍

这是一个抽象组件,也就是说在组件渲染完成后,不会以任何dom的形式表现出现,只是以插槽的形式对内部的子节点进行控制。它的作用是在合适的时机进行Css类名的添加/删除或执行JavaScript钩子来达到动画执行的目的。

transition转VNode

既然是组件,那么在生成为真实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类名实现原理

我们首先重点来看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钩子是怎么实现的。

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钩子实现的原理,这里一定要注意它们的执行顺序:

  1. 首先执行beforeEnter钩子,因为这个是同步的,cb只是定义了,insert是在created之后执行,nextFrame里面的是浏览器的下一帧,是异步的。
  2. 执行插入到insert钩子里的函数体,这也是属于同步,只是在created之后,执行里面的enter钩子。
  3. 如果用户不想控制动画的结束,执行nextFrame里的函数体。
  4. 如果用户想控制,也就是调用了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钩子,则也是按照顺序的执行指定的函数,而这些函数也是需要用户自己定义,组件只是控制这个的流程而已。

链接: https://www.fly63.com/article/detial/8331

CSS定位之BFC背后的神奇原理

BFC已经是一个耳听熟闻的词语了,网上有许多关于 BFC 的文章,介绍了如何触发 BFC 以及 BFC 的一些用处(如清浮动,防止 margin 重叠等)。BFC直译为\"块级格式化上下文\"。它是一个独立的渲染区域,只有Block-level box参与

天天都在使用CSS,那么CSS的原理是什么呢?

作为前端,我们每天都在与CSS打交道,那么CSS的原理是什么呢?开篇,我们还是不厌其烦的回顾一下浏览器的渲染过程,学会使用永远都是最基本的标准,但是懂得原理,你才能触类旁通,超越自我。

JavaScript 中的函数式编程原理

做了一些研究,我发现了函数式编程概念,如不变性和纯函数。 这些概念使你能够构建无副作用的功能,而函数式编程的一些优点,也使得系统变得更加容易维护。我将通过 JavaScript 中的大量代码示例向您详细介绍函数式编程和一些重要概念。

Angular ZoneJS 原理

如果你阅读过关于Angular 2变化检测的资料,那么你很可能听说过zone。Zone是一个从Dart中引入的特性并被Angular 2内部用来判断是否应该触发变化检测

Vue.js响应式原理

updateComponent在更新渲染组件时,会访问1或多个数据模版插值,当访问数据时,将通过getter拦截器把componentUpdateWatcher作为订阅者添加到多个依赖中,每当其中一个数据有更新,将执行setter函数

new运算符的原理

一个继承自 Foo.prototype 的新对象被创建;使用指定的参数调用构造函数 Foo,并将 this 绑定到新创建的对象。new Foo 等同于 new Foo(),也就是没有指定参数时,Foo 不带任何参数调用的情况

彻底弄懂HTTP缓存机制及原理

Http 缓存机制作为 web 性能优化的重要手段,对于从事 Web 开发的同学们来说,应该是知识体系库中的一个基础环节,同时对于有志成为前端架构师的同学来说是必备的知识技能。

https的基本原理

HTTPS = HTTP + TLS/SSL,简单理解 HTTPS 其实就是在 HTTP 上面加多了一层安全层。HTTP 可以是 Http2.0 也可以是 Http1.1,不过现在 Http2.0 是强制要求使用 Https 的。使用非对称密钥(即公钥私钥))和对称密钥)(即共享密钥)相结合

Node中的Cookie和Session

HTTP是无状态协议。例:打开一个域名的首页,进而打开该域名的其他页面,服务器无法识别访问者。即同一浏览器访问同一网站,每次访问都没有任何关系。Cookie的原理是

理解Promise原理

Promise 必须为以下三种状态之一:等待态(Pending)、执行态(Fulfilled)和拒绝态(Rejected)。一旦Promise 被 resolve 或 reject,不能再迁移至其他任何状态(即状态 immutable)。

点击更多...

内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!