深入学习keep-alive

1,382 阅读4分钟

keep-alive是什么,它有什么作用

  • keep-alive是vue的内置组件,无需单独安装手动注册,keep-alive不会向DOM添加额外节点。
  • keep-alive是一个能提供缓存功能,保存子组件内部状态的组件。
  • keep-alive包裹动态组件时,会缓存不活动的组件实例,而不是销毁它们。
  • 当被缓存的子组件再次切换为活动状态时,不会执行其完整的生命周期,而是相应的执行activated、deactivated生命周期函数。

keep-alive的用法

Props:
    include - 字符串或正则表达式。只有名称匹配的组件会被缓存。
    exclude - 字符串或正则表达式。任何名称匹配的组件都不会被缓存。
    max - 数字。最多可以缓存多少组件实例。
  • 路由中使用:
<keep-alive :include="includeList" :exclude="excludeList" :max="maxNum">
  <router-view />
</keep-alive>
  • 态组件中使用:
<keep-alive>
  <component :is="view"/>
</keep-alive>

源码分析

// 源码 => vue/src/core/components/keep-alive.js 
export default {
  name: 'keep-alive',
  abstract: true, //定义抽象组件 判断当前组件虚拟dom是否渲染成真实dom

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

  created () {
    this.cache = Object.create(null) // 缓存VNode
    this.keys = [] // 缓存VNode的key
  },

  destroyed () {
    // 销毁时删除所有缓存的VNode
    for (const key in this.cache) {
      pruneCacheEntry(this.cache, key, this.keys)
    }
  },

  mounted () {
    // 监听 include和exclude属性,及时的更新缓存
    // pruneCache 对cache做遍历,把不符合新规则的VNode从缓存中移除
    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 (
        // not included || excluded
        (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)
        // 超出缓存最大数量,移除最前面的VNode
        if (this.max && keys.length > parseInt(this.max)) {
          pruneCacheEntry(cache, keys[0], keys, this._vnode)
        }
      }

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

分析

1、created时创建cache、keys缓存容器,用于收集需要缓存的VNode。
2、mounted时监听include、exclude,在缓存规则改变的时候过滤更新缓存集合cache、keys。其中pruneCachecache做遍历,它的核心是pruneCacheEntrypruneCacheEntry会调用需要过滤的组件实例的$destroy(),销毁组件实例。

function pruneCache (keepAliveInstance: any, filter: Function) {
  const { cache, keys, _vnode } = keepAliveInstance
  for (const key in cache) {
    const cachedNode: ?VNode = cache[key]
    if (cachedNode) {
      const name: ?string = getComponentName(cachedNode.componentOptions)
      if (name && !filter(name)) {
        pruneCacheEntry(cache, key, keys, _vnode)
      }
    }
  }
}
  
 function pruneCacheEntry (
  cache: VNodeCache,
  key: string,
  keys: Array<string>,
  current?: VNode
) {
  const cached = cache[key]
  if (cached && (!current || cached.tag !== current.tag)) {
    cached.componentInstance.$destroy()
  }
  cache[key] = null
  remove(keys, key)
}

3、destroyed 删除所有缓存的VNode,并调用对应组件实例的destory钩子函数。
4、keep-alive不是使用常规的templete模版的方式,而是直接实现了一个render函数,所以每次触发渲染的时候都会执行render函数。render函数的作用就是获取第一个子组件,先对其进行include、exclude判断,接下来再判断如果不存在于cache中,则将其添加至cache的末尾;如果已存在于cache中,则将其调整至cache的末尾。这样做的目的是将较为活跃的缓存组件保存在cache的末尾,当缓存的组件数量大于设定的max值时,销毁cache中位置靠前(不活跃)的组件实例。
5、keep-alive是一个抽象组件,不会生成真实的节点。由上知,keep-alive定义了abstract:true属性。那vue会忽略该组件的实例,因此不会在DOM树上生成相应的节点。

// src/core/instance/lifecycle.js
export function initLifecycle (vm: Component) {
    const options= vm.$options
    // 找到第一个非abstract父组件实例
    let parent = options.parent
    if (parent && !options.abstract) {
        while (parent.$options.abstract && parent.$parent) {
              parent = parent.$parent
        }
        parent.$children.push(vm)
    }
    vm.$parent = parent
    // ...
}

keep-alive渲染过程

Vue的渲染过程:new Vue -> init -> $mount -> compile -> render -> vnode -> patch -> DOM

那被keep-alive包裹的组件和普通的组件有哪些不同呢?我想熟悉vue的人都应该能回答的上来:保存了组件内部的状态,被缓存后不会执行created、mounted等钩子函数,相应的会执行存activated、deactivated两个生命周期钩子函数。

可那又是怎么实现的呢??我们带着问题接着往下看。

举一例子:

<template>
  <div id="app">
    <keep-alive>
      <component :is='view'></component>
      <p>这里是一个文本</p>
    </keep-alive>
    <el-button @click="changeView">切换组件</el-button>
  </div>
</template>

<script>
import A from './views/A'
import B from './views/B'
export default {
  name: 'App',
  components: {
    A,
    B
  },
  data(){
    return {
      view:'A'
    }
  },
  methods: {
    changeView(){
      this.view = this.view === 'A' ? 'B' : 'A'
    }
  },
};
</script>

首次渲染A:
这里没有渲染p标签也验证了keep-alive只渲染第一个子组件。

Vue 的渲染最后都会到patch过程,而组件的patch过程会执行createComponent方法。

// src/core/vdom/patch.js
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 */)
    }
    if (isDef(vnode.componentInstance)) {
      initComponent(vnode, insertedVnodeQueue)
      insert(parentElm, vnode.elm, refElm)
      if (isTrue(isReactivated)) {
        reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
      }
      return true
    }
  }
}

注意其中定义的isReactivated变量,表示是否是已经被缓存的组件。由于是首次渲染,vnode.componentInstanceundefined,所以isReactivated为false。因此和普通组件的渲染的流程一样,接下来执行initComponent方法。

// src/core/vdom/patch.js
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)
  }
}

接下来我们切换到组件B

由于B也是首次渲染,可以看到与普通组件几乎没有区别。多执行了activated生命周期。

再次切换到A组件

这里在patch过程之前会执行prepatch的钩子函数。流程如下:

// src/core/vdom/create-component
const componentVNodeHooks = {
  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
    )
  }
}

prepatch核心逻辑就是执行updateChildComponent方法:

// src/core/instance/lifecycle.js
export function updateChildComponent (
  vm: Component,
  propsData: ?Object,
  listeners: ?Object,
  parentVnode: MountedComponentVNode,
  renderChildren: ?Array<VNode>
) {
  const hasChildren = !!(
    renderChildren ||          
    vm.$options._renderChildren ||
    parentVnode.data.scopedSlots || 
    vm.$scopedSlots !== emptyObject 
  )

  // ...
  if (hasChildren) {
    vm.$slots = resolveSlots(renderChildren, parentVnode.context)
    vm.$forceUpdate()
  }
}

updateChildComponent方法中,我们需要关注hasChildren部分。因为<keep-alive>组件本质上相当于存在一个default slot,所以hasChildren为真,即执行updateChildComponent时需要对slots做重新解析。并触发<keep-alive>组件实例$forceUpdate逻辑,也就是重新执行<keep-alive>render方法。然后命中cache缓存,直接返回缓存中的vnode.componentInstance,接着进入patch过程,执行createComponent方法。

// src/core/vdom/patch.js
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 */)
    }
    if (isDef(vnode.componentInstance)) {
      initComponent(vnode, insertedVnodeQueue)
      insert(parentElm, vnode.elm, refElm)
      if (isTrue(isReactivated)) {
        reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
      }
      return true
    }
  }
}

和首次渲染不同的是,这里isReactivatedtrue,在执行initComponent函数的时候不会再执行组件的mount过程了,这也就是被<keep-alive>包裹的组件在有缓存的时候就不会在执行组件的created、mounted等钩子函数的原因了。

然后执行reactivateComponent

// src/core/vdom/patch.js
function reactivateComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
  let i
  let innerNode = vnode
  while (innerNode.componentInstance) {
    innerNode = innerNode.componentInstance._vnode
    if (isDef(i = innerNode.data) && isDef(i = i.transition)) {
      for (i = 0; i < cbs.activate.length; ++i) {
        cbs.activate[i](emptyNode, innerNode)
      }
      insertedVnodeQueue.push(innerNode)
      break
    }
  }
  // unlike a newly created component,
  // a reactivated keep-alive component doesn't insert itself
  insert(parentElm, vnode.elm, refElm)
}

reactivateComponent通过执行insert(parentElm,vnode.elm, refElm)就把缓存的 DOM对象直接插入到目标元素中。

以上完成了数据更新的情况下的渲染过程,那activated、deactivated生命周期是在什么情况下执行的呢?上面说到,在渲染的最后一步,会执行invokeInsertHook(vnode,insertedVnodeQueue,isInitialPatch)函数执行vnodeinsert钩子函数:

// src/core/vdom/create-component.js
const componentVNodeHooks = {
  insert (vnode: MountedComponentVNode) {
    const { context, componentInstance } = vnode
    if (!componentInstance._isMounted) {
      componentInstance._isMounted = true
      callHook(componentInstance, 'mounted')
    }
    if (vnode.data.keepAlive) {
      if (context._isMounted) {
        // vue-router#1212
        // During updates, a kept-alive component's child components may
        // change, so directly walking the tree here may call activated hooks
        // on incorrect children. Instead we push them into a queue which will
        // be processed after the whole patch process ended.
        queueActivatedComponent(componentInstance)
      } else {
        activateChildComponent(componentInstance, true /* direct */)
      }
    }
  },
  // ...
}

这里判断如果是被<keep-alive>包裹的组件已经mounted,那么则执行queueActivatedComponent(componentInstance) ,否则执行activateChildComponent(componentInstance, true)

// src/core/instance/lifecycle.js
export function activateChildComponent (vm: Component, direct?: boolean) {
  if (direct) {
    vm._directInactive = false
    if (isInInactiveTree(vm)) {
      return
    }
  } else if (vm._directInactive) {
    return
  }
  if (vm._inactive || vm._inactive === null) {
    vm._inactive = false
    for (let i = 0; i < vm.$children.length; i++) {
      activateChildComponent(vm.$children[i])
    }
    callHook(vm, 'activated')
  }
}

可以看到这里就是执行组件的acitvated钩子函数,并且递归去执行它的所有子组件的activated 钩子函数。

//src/core/observer/scheduler.js
export function queueActivatedComponent (vm: Component) {
  vm._inactive = false
  activatedChildren.push(vm)
}

这个逻辑很简单,把当前vm实例添加到activatedChildren 数组中,等所有的渲染完毕,在nextTick后会执行flushSchedulerQueue

function flushSchedulerQueue () {
  // ...
  const activatedQueue = activatedChildren.slice()
  callActivatedHooks(activatedQueue)
  // ...
} 

function callActivatedHooks (queue) {
  for (let i = 0; i < queue.length; i++) {
    queue[i]._inactive = true
    activateChildComponent(queue[i], true)  }
}

也就是遍历所有的activatedChildren,执行activateChildComponent方法。通过队列调的方式就是把整个activated时机延后了。

activated钩子函数,也就有对应的deactivated钩子函数,它是发生在vnodedestory钩子函数:

// src/core/vdom/create-component.js
const componentVNodeHooks = {
  destroy (vnode: MountedComponentVNode) {
    const { componentInstance } = vnode
    if (!componentInstance._isDestroyed) {
      if (!vnode.data.keepAlive) {
        componentInstance.$destroy()
      } else {
        deactivateChildComponent(componentInstance, true /* direct */)
      }
    }
  }
}

对于<keep-alive>包裹的组件而言,它会执行deactivateChildComponent(componentInstance, true)方法

// src/core/instance/lifecycle.js
export function deactivateChildComponent (vm: Component, direct?: boolean) {
  if (direct) {
    vm._directInactive = true
    if (isInInactiveTree(vm)) {
      return
    }
  }
  if (!vm._inactive) {
    vm._inactive = true
    for (let i = 0; i < vm.$children.length; i++) {
      deactivateChildComponent(vm.$children[i])
    }
    callHook(vm, 'deactivated')
  }
}

总结

通过以上分析,我们已经知道keep-alive组件能够缓存子组件,也知道了它实现的原理。在需要反复创建组件的时候可以使用keep-alive提高性能。

这个时候我又想到了v-ifv-show:

v-if: 渲染开销小,切换即重新渲染, 切换开销大。
v-show:一开始便渲染所有,渲染开销大。
keep-alive: 初始渲染开销小,切换时如果不存在缓存则开始渲染,如果已存在缓存则读取缓存。

可以看到keep-alive似乎就是存在于v-ifv-show之间的一个综合考虑渲染开销和切换开销的特殊存在。

参考