Vue2.0源码阅读笔记(九):内置组件

1,581 阅读8分钟

  Vue2.0中一共有五个内置组件:动态渲染组件的component、用于过渡动画的transition-group与transition、缓存组件的keep-alive、内容分发插槽的slot。
  component组件配合is属性在编译的过程中被替换成具体的组件,而slot组件已经在上一篇文章中加以描述,因此本章主要阐述剩余的三个内置组件。

一、KeepAlive

  <keep-alive> 包裹动态组件时,会缓存不活动的组件实例,而不是销毁它们。<keep-alive> 是一个抽象组件:它自身不会渲染一个 DOM 元素,也不会出现在父组件链中。该组件要求同时只有一个子元素被渲染。

1、KeepAlive组件

  KeepAlive 组件源码如下所示:

{
  name: 'keep-alive',
  abstract: true,

  props: {
    include: patternTypes,
    exclude: patternTypes,
    max: [String, Number]
  },

  created () {
    this.cache = Object.create(null)
    this.keys = []
  },

  destroyed () {
    for (const key in this.cache) {
      pruneCacheEntry(this.cache, key, this.keys)
    }
  },

  mounted () {
    this.$watch('include', val => {
      pruneCache(this, name => matches(val, name))
    })
    this.$watch('exclude', val => {
      pruneCache(this, name => !matches(val, name))
    })
  },

  render () {
    const slot = this.$slots.default
    const vnode: VNode = getFirstComponentChild(slot)
    const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions
    if (componentOptions) {
      const name: ?string = getComponentName(componentOptions)
      const { include, exclude } = this
      if (
        (include && (!name || !matches(include, name))) ||
        (exclude && name && matches(exclude, name))
      ) {
        return vnode
      }

      const { cache, keys } = this
      const key: ?string = vnode.key == null
        ? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '')
        : vnode.key
      if (cache[key]) {
        vnode.componentInstance = cache[key].componentInstance
        remove(keys, key)
        keys.push(key)
      } else {
        cache[key] = vnode
        keys.push(key)
        if (this.max && keys.length > parseInt(this.max)) {
          pruneCacheEntry(cache, keys[0], keys, this._vnode)
        }
      }

      vnode.data.keepAlive = true
    }
    return vnode || (slot && slot[0])
  }
}

  KeepAlive 组件的逻辑相对比较简单,根据传入的 include 与 exclude 规则来决定是否缓存子组件,根据传入的 max 参数来决定最多缓存多少组件。
  从 render 函数中可以看出,如果子组件是缓存对象 cache 的属性,则直接返回该子组件的VNode,如果不是,则添加到缓存对象上,并将缓存的VNode的 data.keepAlive 属性置为 true。
  这里有两点需要注意:keepAlive组件的 abstract 属性为 true、被缓存的子组件 vnode.data.keepAlive 属性为 true。

2、abstract 属性

  当 abstract 属性为 true 时,表示该组件为抽象组件:组件本身不会被渲染成DOM元素、不会出现在父组件链中。
  在完成一系列初始化的过程中,会调用 initLifecycle 方法:

function initLifecycle(vm) {
  const options = vm.$options

  let parent = options.parent
  if (parent && !options.abstract) {
    while (parent.$options.abstract && parent.$parent) {
      parent = parent.$parent
    }
    parent.$children.push(vm)
  }
  /* ... */
}

  由上可知,在 options.abstract 为 true 时,组件实例建立父子关系的时候会被忽略。

3、vnode.data.keepAlive 属性

  在 patch 的过程中会调用 createComponent 方法:

function createComponent(vnode,insertedVnodeQueue,parentElm,refElm){
  let i = vnode.data
  if (isDef(i)) {
    const isReactivated = isDef(vnode.componentInstance) && i.keepAlive
    if (isDef(i = i.hook) && isDef(i = i.init)) {
      i(vnode, false)
    }
    if (isDef(vnode.componentInstance)) {
      initComponent(vnode, insertedVnodeQueue)
      insert(parentElm, vnode.elm, refElm)
      if (isTrue(isReactivated)) {
        reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
      }
      return true
    }
  }
}

  在本系列第七篇文章《组件》中,详细分析过 createComponent 方法,当时没考虑 keepAlive 值为 true的情况,在这里重点介绍。
  在首次渲染时 vnode.componentInstance 的值为空,因此不论 keepAlive 是否为空,isReactivated 值总是 false。再次渲染时,若 keepAlive 值为 true 则isReactivated 为true。

if (isDef(i = i.hook) && isDef(i = i.init)) {
  i(vnode, false)
}

  钩子函数 init 在 keepAlive 值为 false 时的功能是调取组件的构造函数生成组件构造实例。

init (vnode, hydrating) {
  if (
    vnode.componentInstance &&
    !vnode.componentInstance._isDestroyed &&
    vnode.data.keepAlive
  ) {
    const mountedNode = vnode
    componentVNodeHooks.prepatch(mountedNode, mountedNode)
  } else {
    /* 省略... */
  }
}

  当 keepAlive 值为 true 时,会调用 prepatch 方法,该方法不会再执行组件的 mount 过程,而是直接调用 updateChildComponent 方法更新子组件,这也是被 keepAlive 包裹的组件在有缓存的时候就不会再执行组件的 created、mounted 等钩子函数的原因。

function prepatch (oldVnode, vnode) {
  var options = vnode.componentOptions;
  var child = vnode.componentInstance = oldVnode.componentInstance;
  updateChildComponent(child,options.propsData,options.listeners,
        vnode,options.children);
}

  在 createComponent 函数最后,如果组件再次渲染且 keepAlive 为 true 时,会调用 reactivateComponent 函数,该函数将缓存的DOM元素直接插入到目标位置。

function reactivateComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
  /* 省略对 transition 动画不触发的问题的处理*/
  insert(parentElm, vnode.elm, refElm);
}

二、Transition

  Vue 提供了 transition 的封装组件,在下列情形中,可以给任何元素和组件添加进入/离开过渡

1、条件渲染 (使用 v-if)。
2、条件展示 (使用 v-show)。
3、动态组件。
4、组件根节点。

  当插入或删除包含在 transition 组件中的元素时,Vue 将会做以下处理:

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

1、Transition组件

  Transition 组件的定义在 /src/platforms/web/runtime/components/transition.js 文件中,精简代码如下:

export default {
  name: 'transition',
  props: transitionProps,
  abstract: true,

  render (h) {
    let children = this.$slots.default
    if (!children) {return}
    children = children.filter(isNotTextNode)
    if (!children.length) {return}

    /* 省略多个子元素警告 */

    const mode = this.mode

    /* 省略无效模式警告 */

    const rawChild = children[0]
    if (hasParentTransition(this.$vnode)) {return rawChild}
    const child = getRealChild(rawChild)
    if (!child) {return rawChild}
    if (this._leaving){return placeholder(h, rawChild)}

    /* 省略获取id与key代码 */

    const data = (child.data || (child.data = {})).transition = extractTransitionData(this)
    const oldRawChild = this._vnode
    const oldChild = getRealChild(oldRawChild)

    if (child.data.directives && child.data.directives.some(isVShowDirective)) {
      child.data.show = true
    }

    /* 省略多元素过渡模式处理代码 */

    return rawChild
  }
}

  Transition 组件与 KeepAlive 组件一样是抽象函数,在该组件定义中比较重要的就是 render 函数,其作用就是渲染生成VNode。
  在该渲染函数中有三个功能比较重要:

1、将 Transition 组件上的参数赋值到 child.data.transition 上。
2、如果 Transition 组件上使用 v-show 指令,则将 child.data.show 设为 true。
3、设置多元素过渡的模式。

2、过渡模式mode

  Vue 提供了两种过渡模式,默认同时生效。

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

  在 render 函数中相关代码如下所示:

const oldData = oldChild.data.transition = extend({}, data)
if (mode === 'out-in') {
  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})
}

  从上述代码可知:当过渡模式为 out-in,在切换元素时,当前元素完全 leave 后才会加载新元素。当过渡模式为 in-out,当前元素延时到新元素 enter 后再 leave。

3、过渡逻辑

  过渡相关的逻辑在 /src/platforms/web/runtime/modules/transition.js 文件中实现,Vue会将相关逻辑插入到 patch 的生命周期中去处理。

export default inBrowser ? {
  create: _enter,
  activate: _enter,
  remove (vnode, rm) {
    if (vnode.data.show !== true) {
      leave(vnode, rm)
    } else {
      rm()
    }
  }
} : {}

function _enter (_,vnode) {
  if (vnode.data.show !== true) {
    enter(vnode)
  }
}

  可以看出过渡的逻辑本质上就是在元素插入时调用 enter 函数,在元素移除时调用 leave 函数。
  因为在使用 v-show 指令时元素始终会被渲染并保留在 DOM 中,只是简单地切换元素的 CSS 属性 display。所以会对使用 v-show 指令的情况进行特殊处理,在下一小结阐述具体处理过程。
  总体来看 enter 函数与 leave 函数几乎是一个镜像过程,下面仅分析 enter 函数。

function enter (vnode, toggleDisplay) {
  const el = vnode.elm
  /* 省略... */
  const expectsCSS = css !== false && !isIE9
  const userWantsControl = getHookArgumentsLength(enterHook)
  /* 省略 cb 函数实现 */
  /* 合并 insert 钩子函数 */
  if (!vnode.data.show) {
    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)
    })
  }
  /* 开始执行过渡动画 */
  beforeEnterHook && beforeEnterHook(el)
  if (expectsCSS) {
    addTransitionClass(el, startClass)
    addTransitionClass(el, activeClass)
    nextFrame(/* 省略... */)
  }
  /* 省略使用 v-show 指令的情况 */
  if (!expectsCSS && !userWantsControl) {
    cb()
  }
}

  enter 函数看上去很复杂,其核心代码是开始执行过渡动画的部分。首先执行 beforeEnterHook 钩子函数,若使用 css 过渡类,则接着执行:

addTransitionClass(el, startClass)
addTransitionClass(el, activeClass)

  addTransitionClass 函数作用就是给元素添加样式,然后执行 nextFrame 函数。

function nextFrame (fn: Function) {
  raf(() => {
    raf(fn)
  })
}

const raf = inBrowser
  ? window.requestAnimationFrame
    ? window.requestAnimationFrame.bind(window)
    : setTimeout

  nextFrame 函数在支持 requestAnimationFrame 方法的浏览器中使用该方法,参数 fn 会在下一帧执行。如果不支持则使用 setTimeout 代替。

nextFrame(() => {
  removeTransitionClass(el, startClass)
  if (!cb.cancelled) {
    addTransitionClass(el, toClass)
    if (!userWantsControl) {
      if (isValidDuration(explicitEnterDuration)) {
        setTimeout(cb, explicitEnterDuration)
      } else {
        whenTransitionEnds(el, type, cb)
      }
    }
  }
})

  在下一帧时,首先移除 startClass 样式,然后判断过渡是否被取消。如果没有取消,添加 toClass 样式,然后根据是否通过 enterHook 钩子函数控制动画来决定 cb 函数的执行时机。

const cb = el._enterCb = once(() => {
  if (expectsCSS) {
    removeTransitionClass(el, toClass)
    removeTransitionClass(el, activeClass)
  }
  if (cb.cancelled) {
    if (expectsCSS) {
      removeTransitionClass(el, startClass)
    }
    enterCancelledHook && enterCancelledHook(el)
  } else {
    afterEnterHook && afterEnterHook(el)
  }
  el._enterCb = null
})

  cb 函数首先移除 toClass 与 activeClass 样式,如果过渡被取消则先移除 startClass 样式,再执行 enterCancelledHook 钩子函数。如果过渡没有被取消,则调用 afterEnterHook 钩子函数。

4、v-show

  对于在 Transition 组件上使用 v-show 指令的情况,在 v-show 指令的实现中有特殊处理。相关代码在 /src/platforms/web/runtime/directives/show.js 文件中。

export default {
  bind (el, { value }, vnode) {
    /* ... */
    const transition = vnode.data && vnode.data.transition
    const originalDisplay = el.__vOriginalDisplay =
      el.style.display === 'none' ? '' : el.style.display
    if (value && transition) {
      vnode.data.show = true
      enter(vnode, () => {
        el.style.display = originalDisplay
      })
    }
    /* ... */
  },

  update (el, { value, oldValue }, 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'
        })
      }
    }
  },
  /* 省略... */

  可以看到在 v-show 指令的实现中,若在 Transition 组件上使用则调用 enter 与 leave 函数,与 patch 生命周期调用这两个函数不同的会额外的传入第二个参数。
  在 enter 与 leave 函数也有对应的处理,以保证在DOM元素没有新增和移除的情况下实现过渡效果。

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

三、TransitionGroup

  Vue 使用 <transition-group> 组件完成列表过渡效果,该组件有以下几个特点:

1、该组件不是抽象组件,会以一个真实元素呈现,默认是 <span>,可以通过 tag参数 指定。
2、过渡模式不可用。
3、内部元素总是需要提供唯一的 key 属性值。
4、CSS 过渡的类将会应用在内部的元素中,而不是这个组/容器本身。

1、TransitionGroup组件

  TransitionGroup 组件定义在 /src/platforms/web/runtime/components/transition-group.js 文件中,精简代码如下:

const props = extend({
  tag,
  moveClass
}, transitionProps)

delete props.mode

export default {
  props,
  beforeMount () { /* 省略具体实现 */ },
  render (h) { /* 省略具体实现 */ },
  updated () { /* 省略具体实现 */ },
  methods: {
    hasMove (el, moveClass){ /* 省略具体实现 */ }
  }
}

  TransitionGroup 组件有两种过渡效果:基本过渡效果、平滑过渡效果,后者通过 v-move 特性来实现。
  在源码实现中,基本过渡效果由组件的 render 函数完成,当数据发生变化时的平滑过渡效果由 updated 生命周期钩子函数完成。

2、基本过渡实现

  TransitionGroup 组件 render 方法的完整代码如下所示:

render (h) {
  const tag = this.tag || this.$vnode.data.tag || 'span'
  const map = Object.create(null)
  const prevChildren = this.prevChildren = this.children
  const rawChildren = this.$slots.default || []
  const children = this.children = []
  const transitionData = extractTransitionData(this)

  for (let i = 0; i < rawChildren.length; i++) {
    const c = rawChildren[i]
    if (c.tag) {
      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 = c.componentOptions
        const name = opts ? (opts.Ctor.options.name || opts.tag || '') : c.tag
        warn(`<transition-group> children must be keyed: <${name}>`)
      }
    }
  }

  if (prevChildren) {
    const kept = []
    const removed = []
    for (let i = 0; i < prevChildren.length; i++) {
      const c = prevChildren[i]
      c.data.transition = transitionData
      c.data.pos = c.elm.getBoundingClientRect()
      if (map[c.key]) {
        kept.push(c)
      } else {
        removed.push(c)
      }
    }
    this.kept = h(tag, null, kept)
    this.removed = removed
  }

  return h(tag, null, children)
}

  render 函数的本质功能就是生成VNode,其中该函数的参数 h 为用来生成VNode的 createElement 函数。
  函数中首先声明的几个变量具体含义如下所示:

1、tag:TransitionGroup 组件最终渲染的元素类型,默认是 span。
2、map:存储原始子节点 key 与值的对象。
3、prevChildren:存储上一次的子节点数组。
4、rawChildren:原始子节点数组。
5、children:当前子节点数组。
5、transitionData:TransitionGroup 组件上提取的过渡参数。

  紧接着的 for 循环是处理原始子节点的,因为 TransitionGroup 组件要求所有子节点都显式提供 key 值,如果没有提供 key 值在开发环境下会报错。

if (c.key != null && String(c.key).indexOf('__vlist') !== 0)

  判断是否显式提供 key 值的条件语句之所以这样写,是因为在 for 循环的渲染过程中,在没有提供 key 值的情况下,会自动加上 __vlist 为开头的字符串作为 key 值。
  这个 for 循环还有一个重要的功能是将组件过渡参数赋值给子组件的 data.transition 属性,在上一节讲述 Transition 组件时有讲过,在元素进入和移除时会根据这个属性来显示相应的过渡效果。
  最后处理改变前的子节点,调用了原生 DOM 的 getBoundingClientRect 方法获取到原生 DOM 的位置信息,记录到 vnode.data.pos 中。然后将存在的节点放入 kept 中,将删除的节点放入 removed 中。最后返回由 createElement 函数生成的VNode。
  TransitionGroup 组件的 render 方法由于将过渡信息下沉到子节点上,是可以实现基本的子节点添加删除的过渡效果的。由于插入和删除操作与需要移动的元素没有过渡效果控制的关联,所以并没有平滑过渡的效果。

3、平滑过渡实现

  当数据改变时会调用 updated 生命周期钩子,TransitionGroup 组件当子节点添加与删除的平滑过渡效果在该钩子函数中实现。

updated () {
  const children = this.prevChildren
  const moveClass = this.moveClass || ((this.name || 'v') + '-move')
  if (!children.length || !this.hasMove(children[0].elm, moveClass)) {
    return
  }

  children.forEach(callPendingCbs)
  children.forEach(recordPosition)
  children.forEach(applyTranslation)

  this._reflow = document.body.offsetHeight

  children.forEach((c) => {
    if (c.data.moved) {
      const el = c.elm
      const s = el.style
      addTransitionClass(el, moveClass)
      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
          removeTransitionClass(el, moveClass)
        }
      })
    }
  })
}

  updated 函数首先使用 hasMove 方法判断子节点是否定义了 move 相关的动画样式,接着对子节点进行预处理:

1、callPendingCbs:在前一个过渡动画没执行完又再次执行到该方法的时候,会提前执行 _moveCb 和 _enterCb。
2、recordPosition:记录节点的新位置,赋值给 data.newPos 属性。
3、applyTranslation:先计算节点新位置和旧位置的差值,把需要移动的节点的位置又偏移到之前的旧位置,目的是为了做 move 缓动做准备。

  接着通过读取 document.body.offsetHeight 强制触发浏览器重绘。
  然后遍历子节点,先给子节点添加 moveClass,接着把子节点的 style.transform 设置为空,由于之前使用 applyTranslation 方法将子节点偏移到旧位置,此时会按照设置的过渡效果偏移到当前位置,进而实现平滑过渡的效果。
  最后监听 transitionEndEvent 过渡结束的事件,做一些清理的操作。

4、子元素更新算法不稳定的处理

  Vue 中虚拟 DOM 的子元素更新算法是不稳定的,它不能保证被移除元素的相对位置。Vue 在 beforeMount 生命周期钩子函数中对这种情况进行了处理。

beforeMount () {
  const update = this._update
  this._update = (vnode, hydrating) => {
    const restoreActiveInstance = setActiveInstance(this)
    this.__patch__(this._vnode, this.kept, false, true)
    this._vnode = this.kept
    restoreActiveInstance()
    update.call(this, vnode, hydrating)
  }
}

  在 beforeMount 函数中,首先重写了 _update 方法,_update 方法本身的作用是根据VNode生成真实DOM的。重写后的 _update 方法主要有两步:首先移除需要移除的 vnode,同时触发它们的 leaving 过渡;然后需要把插入和移动的节点达到它们的最终态,同时还要保证移除的节点保留在应该的位置。
  Vue 通过这两步处理来解决子元素更新算法是不稳定的问题,作者在 TransitionGroup 组件实现的文件中也有详细的注释说明。

四、总结

  KeepAlive 组件不渲染真实DOM节点,会将缓存的子组件放入 cache 数组中,并将被缓存子组件的 data.keepAlive 属性置为 true。如果需要再次渲染被缓存的子组件,则直接返回该子组件的VNode,而组件的 created、mounted 等钩子函数不会再执行。
   Transition 组件的 render 函数会将组件上的参数赋值到 child.data.transition 上,然后在 patch 的过程中会调用 enter 与 leave 函数完成相关过渡效果。在使用 v-show 指令时,DOM元素并没有新增和删除,Vue 对这种情况进行了特别处理,保证在DOM元素没有新增和移除的情况下实现过渡效果。
  TransitionGroup 组件的基本过渡效果跟 Transition 组件实现效果一样。修改列表数据的时候,如果是添加或者删除数据,则会触发相应元素本身的过渡动画。平滑过渡效果本质上就是先将元素移动到旧位置,然后再根据定义的过渡效果将其移动到新位置。

欢迎关注公众号:前端桃花源,互相交流学习!