vue中的keep-alive(源码分析)

785 阅读2分钟

高频面试题:keep-alive是如何实现的?

vue中支持组件化,并且也有用于缓存的内置组件keep-alive可直接使用,使用场景为路由组件动态组件,接下来举个动态组件的例子

// main.js文件
import Vue from "vue";
let A = {
  template: '<p>component-a</p>',
  name: 'A',
}

let B = {
  template: '<p>component-b</p>',
  name: 'B',
}

new Vue({
  el: '#app',
  template: '<div><keep-alive><component :is="currentComponent"></component></keep-alive><button @click="change">switch</button></div>',
  data: {
    currentComponent: 'A'
  },
  methods: {
    change() {
      this.currentComponent = this.currentComponent === 'A' ? 'B' : 'A'
    }
  },
  components: {
    A,
    B
  }
})

一、注册

initGlobalAPI(Vue)阶段,通过extend(Vue.options.components, builtInComponents)的方式在Vue.options中扩展componentsbuiltInComponents指的就是KeepAlive

export default {
  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 () {
    // 会在后面介绍
  }
}

keep-alive组件是一个包含nameabstractpropscreateddestroyedmountedrender属性的对象。

二、首次渲染

1、div渲染逻辑中的render

在执行vm._update(vm._render(), hydrating)进行渲染的过程中,编译生成的render函数为:

with(this) {
    return _c('div', [
        _c('keep-alive', [_c(currentComp, {
            tag: "component"
        })], 1),
        _c('button', {
            on: {
                "click": change
            }
        }, [_v("switch")])
    ], 1)
}

可以看出通过_c创建了tagdiv的节点,节点中children部分为通过_c创建的以keep-alivetag的节点,其节点中children部分为通过_c创建的以currentComp(首次渲染时其值为A)为标签的节点。

换个角度说,div为根节点,keep-alive为第二层,currentComp为叶子层。

当前渲染中遇到div标签时,会执行createElm逻辑中的createChildren部分逻辑:

function createChildren (vnode, children, insertedVnodeQueue) {
    if (Array.isArray(children)) {
      if (process.env.NODE_ENV !== 'production') {
        checkDuplicateKeys(children)
      }
      for (let i = 0; i < children.length; ++i) {
        createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i)
      }
    } else if (isPrimitive(vnode.text)) {
      nodeOps.appendChild(vnode.elm, nodeOps.createTextNode(String(vnode.text)))
    }
}

这里会有两个vNode节点需要渲染,通过for循环的方式依次去调用createElm方法,我们重点关注keep-alive

2、keep-alive渲染逻辑中的render

当遇到keep-alive对应的标签时,会执行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 /* hydrating */)
      }
      // after calling the init hook, if the vnode is a child component
      // it should've created a child instance and mounted it. the child
      // component also has set the placeholder vnode's elm.
      // in that case we can just return the element and be done.
      if (isDef(vnode.componentInstance)) {
        initComponent(vnode, insertedVnodeQueue)
        insert(parentElm, vnode.elm, refElm)
        if (isTrue(isReactivated)) {
          reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
        }
        return true
      }
    }
}

其中的i指的是钩子函数init

init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
    if (
      vnode.componentInstance &&
      !vnode.componentInstance._isDestroyed &&
      vnode.data.keepAlive
    ) {
      // kept-alive components, treat as a patch
      const mountedNode: any = vnode // work around flow
      componentVNodeHooks.prepatch(mountedNode, mountedNode)
    } else {
      const child = vnode.componentInstance = createComponentInstanceForVnode(
        vnode,
        activeInstance
      )
      child.$mount(hydrating ? vnode.elm : undefined, hydrating)
    }
},

init阶段通过createComponentInstanceForVnode先创建child的实例,在当前过程中会执行组件构造函数的this._init方法,其中initRender时会通过vm.$slots = resolveSlots(options._renderChildren, renderContext)的方式解析slot

export function resolveSlots (
  children: ?Array<VNode>,
  context: ?Component
): { [key: string]: Array<VNode> } {
  if (!children || !children.length) {
    return {}
  }
  const slots = {}
  for (let i = 0, l = children.length; i < l; i++) {
    const child = children[i]
    const data = child.data
    // remove slot attribute if the node is resolved as a Vue slot node
    if (data && data.attrs && data.attrs.slot) {
      delete data.attrs.slot
    }
    // named slots should only be respected if the vnode was rendered in the
    // same context.
    if ((child.context === context || child.fnContext === context) &&
      data && data.slot != null
    ) {
      const name = data.slot
      const slot = (slots[name] || (slots[name] = []))
      if (child.tag === 'template') {
        slot.push.apply(slot, child.children || [])
      } else {
        slot.push(child)
      }
    } else {
      (slots.default || (slots.default = [])).push(child)
    }
  }
  // ignore slots that contains only whitespace
  for (const name in slots) {
    if (slots[name].every(isWhitespace)) {
      delete slots[name]
    }
  }
  return slots
}

这里通过(slots.default || (slots.default = [])).push(child)的方式将vnode推入到slotsdefault中。

然后,通过child.$mount(hydrating ? vnode.elm : undefined, hydrating)的方式去挂载子组件节点到vnode.elm上。其中,会走到渲染逻辑,获取到的renderkeep-alive内置组件中的render函数:

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

      const { cache, keys } = this
      const key: ?string = vnode.key == null
        // same constructor may get registered as different local components
        // so cid alone is not enough (#3269)
        ? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '')
        : vnode.key
      if (cache[key]) {
        vnode.componentInstance = cache[key].componentInstance
        // make current key freshest
        remove(keys, key)
        keys.push(key)
      } else {
        cache[key] = vnode
        keys.push(key)
        // prune oldest entry
        if (this.max && keys.length > parseInt(this.max)) {
          pruneCacheEntry(cache, keys[0], keys, this._vnode)
        }
      }

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

首先,通过const slot = this.$slots.default获取slot

然后,通过getFirstComponentChild获取到第一个vNode,逻辑如下:

export function getFirstComponentChild (children: ?Array<VNode>): ?VNode {
  if (Array.isArray(children)) {
    for (let i = 0; i < children.length; i++) {
      const c = children[i]
      if (isDef(c) && (isDef(c.componentOptions) || isAsyncPlaceholder(c))) {
        return c
      }
    }
  }
}

接着就是excludeinclude属性,如果不在include中或者在exclude中,直接返回当前vnode

再看缓存组件,在缓存内直接返回vnode,并修改缓存vnode的顺序;如果不在缓存内则进行缓存处理。

最后修改vnode.data中的keepAlivetrue

3、currentCom渲染中的render

当渲染currentCom(首次渲染时为A)时,会执行createComponent逻辑。在当前阶段,触发init的钩子函数,通过createComponentInstanceForVnode先创建child的实例。

然后,通过child.$mount(hydrating ? vnode.elm : undefined, hydrating)的方式去挂载子组件节点到vnode.elm上。挂载过程中获取到的render为:

with(this) {
    return _c('p', [_v("component-a")])
}

至此可以看出,从divkeep-alive再到currentCom的过程中,是从根节点开始到叶子节点路线开始,再通过递归的方式由叶子节点获取DOM节点开始一步一步的获取到根节点的方式,完成整棵dom树的构建。

三、缓存组件

A组件和B组件都完成渲染后,再次执行到keep-alive组件渲染时,vnode获取过程中render函数通过vnode.componentInstance = cache[key].componentInstancevnode中挂载缓存中的componentInstance(在首次渲染时已经完成了$el的渲染)。

keep-alive组件执行钩子函数init阶段,满足条件if ( vnode.componentInstance && !vnode.componentInstance._isDestroyed && vnode.data.keepAlive ),执行var mountedNode = vnode; componentVNodeHooks.prepatch(mountedNode, mountedNode),即执行其钩子函数prepatch

prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
    const options = vnode.componentOptions
    const child = vnode.componentInstance = oldVnode.componentInstance
    updateChildComponent(
      child,
      options.propsData, // updated props
      options.listeners, // updated listeners
      vnode, // new parent vnode
      options.children // new children
    )
},

updateChildComponent的主要逻辑为:

export function updateChildComponent (
  vm: Component,
  propsData: ?Object,
  listeners: ?Object,
  parentVnode: MountedComponentVNode,
  renderChildren: ?Array<VNode>
) {
  // ...
  // Any static slot children from the parent may have changed during parent's
  // update. Dynamic scoped slots may also have changed. In such cases, a forced
  // update is necessary to ensure correctness.
  const needsForceUpdate = !!(
    renderChildren ||               // has new static slots
    vm.$options._renderChildren ||  // has old static slots
    hasDynamicScopedSlot
  )
  // ...
  // resolve slots + force update if has children
  if (needsForceUpdate) {
    vm.$slots = resolveSlots(renderChildren, parentVnode.context)
    vm.$forceUpdate()
  }
}

满足需要强制更新的条件,通过vm.$slots = resolveSlots(renderChildren, parentVnode.context)为当前vm中定义$slots,并通过vm.$forceUpdate()强制更新vm,在下一个队列queue中推入一个watcher

执行到组件currentCom(当前为A)的渲染时,也执行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 /* hydrating */)
      }
      // after calling the init hook, if the vnode is a child component
      // it should've created a child instance and mounted it. the child
      // component also has set the placeholder vnode's elm.
      // in that case we can just return the element and be done.
      if (isDef(vnode.componentInstance)) {
        initComponent(vnode, insertedVnodeQueue)
        insert(parentElm, vnode.elm, refElm)
        if (isTrue(isReactivated)) {
          reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
        }
        return true
      }
}
function initComponent (vnode, insertedVnodeQueue) {
    if (isDef(vnode.data.pendingInsert)) {
      insertedVnodeQueue.push.apply(insertedVnodeQueue, vnode.data.pendingInsert)
      vnode.data.pendingInsert = null
    }
    vnode.elm = vnode.componentInstance.$el
    if (isPatchable(vnode)) {
      invokeCreateHooks(vnode, insertedVnodeQueue)
      setScope(vnode)
    } else {
      // empty component root.
      // skip all element-related modules except for ref (#3455)
      registerRef(vnode)
      // make sure to invoke the insert hook
      insertedVnodeQueue.push(vnode)
    }
}
function invokeCreateHooks (vnode, insertedVnodeQueue) {
    for (let i = 0; i < cbs.create.length; ++i) {
      cbs.create[i](emptyNode, vnode)
    }
    i = vnode.data.hook // Reuse variable
    if (isDef(i)) {
      if (isDef(i.create)) i.create(emptyNode, vnode)
      if (isDef(i.insert)) insertedVnodeQueue.push(vnode)
    }
}  

执行i(init钩子函数)的时候执行完init函数以后,满足条件isDef(vnode.componentInstance),接着执行initComponent(vnode, insertedVnodeQueue),这里将缓存组件实例中渲染的$el直接赋值给vnode.elm

然后再通过insert(parentElm, vnode.elm, refElm)的方式将缓存vnode中的elm挂载到父节点中,我们发现,该过程没有进行A组件的实例化和挂载的过程,而是直接进行缓存节点载入,是平时项目开发中的一种性能优化的方案。

总结

keep-alive作为内置组件,通过render函数实现keep-alive中第一个组件的缓存、include匹配到组件的进行缓存、exclude排除的组件不进行缓存和max限制最多能缓存数量的功能。