Vue keep-alive应用与源码分析

前言

在做Vue单页面应用时,经常会遇到页面之间来回切换的情况,每个页面可能会有不少网络请求,但是部分页面的数据不需要每次进入都请求。若每次进入页面都需要进行网络请求,无疑会增加不必要的耗时,并且页面的重新渲染也会造成一定的性能浪费。而Vue 2.0+提供了一个内置组件keep-alive用来缓存组件,有效地减少性能消耗,下面谈谈 keep-alive 的用法与其源码分析。

应用

基本用法

如果把切换出去的组件(页面)保留在内存中,可以保留它的状态或避免重新渲染。Vue 2.0+提供了一个内置组件keep-alive用来缓存组件,减少性能消耗,一般用法如下:

<keep-alive>
  <component>
    <!-- 组件将被缓存 -->
  </component>
</keep-alive>

比较常用的方法是搭配 router 一起使用,缓存不常用的页面。当组件在 <keep-alive> 内被切换,它的 activateddeactivated 这两个生命周期钩子函数将会被对应执行。

<keep-alive>
  <router-view/>
</keep-alive>

使用 vue-cli 新建一个路由demo,新建 CompA 和 CompB 单文件组件。 CompA 与 CompB 可互相切换,并且在 CompB 所有生命周期钩子函数中打印调用 log:

<!-- CompB.vue -->
<template>
  <div>
    <h1></h1>
    <router-link to="/CompA">Go to CompA</router-link>
  </div>
</template>

<script>
export default {
  data () {
    return {
      msg: 'Comp B'
    }
  },
  beforeCreate: function () {
    console.log('1 beforeCreate')
  },
  created: function () {
    console.log('2 created')
  },
  beforeMount: function () {
    console.log('3 beforeMount')
  },
  mounted: function () {
    console.log('4 mounted')
  },
  beforeDestroy: function () {
    console.log('5 beforeDestroy')
  },
  destroyed: function () {
    console.log('6 destroyed')
  },
  activated: function () {
    console.log('activated')
  },
  deactivated: function () {
    console.log('deactivated')
  }
}
</script>

若根组件的 <router-view> 在没有嵌套在 <keep-alive> 标签内,访问路径 CompA -> CompB -> CompA ,控制台将依次打印

// CompA -> CompB
1 beforeCreate
2 created
3 beforeMount
4 mounted
// CompB -> CompA
5 beforeDestroy
6 destroyed

修改 App.vue,引入 <keep-alive>

<!-- App.vue -->
<template>
  <div>
    <router-view/>
  </div>
</template>
·····

重新访问路径 CompA -> CompB -> CompA,此时控制台将依次打印

// CompA -> CompB
1 beforeCreate
2 created
3 beforeMount
4 mounted
activated
// CompB -> CompA
deactivated

可见,使用 keep-alive 后,CompB的销毁钩子函数没有被执行,CompB 被成功缓存(CompA 亦然)。

缓存部分页面或组件

Vue 2.1.0 新增 props: includeexclude,官方解释如下

  • include - 字符串或正则表达式。只有匹配的组件会被缓存。
  • exclude - 字符串或正则表达式。任何匹配的组件都不会被缓存。
<!-- 逗号分隔字符串 -->
<keep-alive include="a,b">
  <router-view/>
</keep-alive>

<!-- 正则表达式 (使用 `v-bind`) -->
<keep-alive :include="/a|b/">
  <router-view/>
</keep-alive>

<!-- 数组 (使用 `v-bind`) -->
<keep-alive :include="['a', 'b']">
  <router-view/>
</keep-alive>

显然,数组类型也是支持的。匹配首先检查组件自身的 name 选项,includeexclude 只作用于具名组件,匿名组件不能被匹配。

源码分析

keep-alive 是Vue的内置组件,源码位于 src/core/components/keep-alive.js

createddestroyed 钩子

created 钩子设置2个关键属性 cachekeys,其中cache用于保存缓存的组件,keys记录缓存组件的顺序 destroyed 钩子对每一个被缓存的组件执行 pruneCacheEntry 方法,该方法内部将调用每一个组件实例的 $destroy 生命周期钩子进行销毁

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

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

render 函数

// src/core/components/keep-alive.js
······
export default {
  ......

  render () {
    const vnode: VNode = getFirstComponentChild(this.$slots.default)
    const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions
    if (componentOptions) {
      // check pattern for include/exclude
      ······
      
      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
  }
}

render 函数只会返回其包裹的子组件,意味着本身作为一个抽象组件,不会渲染一个 DOM 元素,也不会出现在父组件链中。注意到,render 函数返回的是 VNode 对象,即 Virtual DOM 对象,用 JavaScript 对象来代替 DOM 节点。若子组件已被缓存,则直接返回,并刷新组件顺序;若未被缓存,则将其插入 cache,此时如果缓存的组件数量达到设置的上限,就移除最早缓存的组件,缓存组件的数量默认不设置,即无上限。

includeexclude

在上面的应用中说到:includeexclude 只作用于具名组件,匿名组件不能被匹配。这里的不能被匹配是指 includeexclude 在匿名组件上不起作用。 依旧以 CompA 与 CompB 的 DEMO 为例,设置 include 属性,并对 CompA 设置 name 属性

<keep-alive include="CompA">
  <router-view/>
</keep-alive>

从 CompA -> CompB,控制台显示 CompB 依然执行了 activated 钩子,即被缓存了。若对 CompB 设置 name 属性,则不会执行 activated 钩子。render 函数中关键代码如下,匿名组件无法读取 name 属性,将参与缓存处理。

render () {
······
  const name: ?string = getComponentName(componentOptions)
  if (name && (
    (this.exclude && matches(this.exclude, name)) ||
    (this.include && !matches(this.include, name))
  )) {
    return vnode
  }
······
}

最大缓存组件数量

keep-alive 组件定义了 props 属性

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

官方文档没有提供 max 属性的使用说明。上面分析 render 函数可知如果缓存的组件数量达到设置的上限,就移除最早缓存的组件。 在上面 CompA 与 CompB 的基础上,增加组件 CompC,并设置 max 属性为 2

<!-- App.vue -->
<keep-alive max=2>
  <router-view/>
</keep-alive>

因为最大缓存组件数为 2,则内存中缓存的组件始终只有 2 个。依次访问 CompB -> CompC -> CompA -> CompB,控制台输出

// CompB -> CompC
1 beforeCreate
2 created
3 beforeMount
4 mounted
activated

// CompC -> CompA
deactivated
5 beforeDestroy
6 destroyed

// CompA -> CompB
1 beforeCreate
2 created
3 beforeMount
4 mounted
activated

CompC 切换至 CompA,内存中已有 [ CompB, CompC ],已达到上限。当 CompA 需要被缓存时,最先被缓存的 CompB 将会被销毁。 有个有趣的现象,如果将 max 属性设为 1,进行组件切换,发现组件的 beforeDestorydestroyed 钩子不会被调用,原因在 keep-alive 的移除实例的函数 pruneCacheEntry。例如,从 CompB -> CompC,则 CompC 需要添加至缓存,此时判断 CompB 为当前实例,因此 $destroy 没有被执行。虽然 CompB 已经被移出 keep-alive 的缓存,但是其实例componentInstance并没有被销毁,这会导致内存泄露。

function pruneCacheEntry (cache: VNodeCache, key: string, keys: Array<string>, current?: VNode) {
  const cached = cache[key]
  if (cached && cached !== current) {
    cached.componentInstance.$destroy()
  }
  cache[key] = null
  remove(keys, key)
}

vnode.data.keepAlive

成功缓存的子组件 data 选项的 keepAlive 属性将会被设为 true。那么这个属性有什么作用呢? 引入一个概念 虚拟 DOM(Virtual DOM),Vue 使用 JavaScript 对象表示 DOM 信息和结构,所包含的信息会告诉 Vue 页面上需要渲染什么样的节点,及其子节点。我们把这样的节点描述为虚拟节点 (Virtual Node),也常简写它为VNode虚拟 DOM是我们对由 Vue 组件树建立起来的整个 VNode 树的称呼。当状态发生变更时,可以使用新渲染的对象树与旧的树进行对比(DOM diff),记录两棵树差异,接下来对页面真正的 DOM 操作,然后把它们应用在真正的 DOM 树上,页面就变更了。 将 DOM diff 得到的差异去修改 DOM 树的关键操作为 patch 函数,源码传送 src/core/vdom/patch.js

// oldVnode: 旧的Virtual DOM或者旧的真实DOM
// vnode:新的Virtual DOM
function patch(oldVnode, vnode, hydrating, removeOnly, parentElm, refElm) {
  // ...
}

假设有 CompA -> CompB 的切换路径,切换时将对 CompA 和 CompB 的虚拟 DOM 树进行差异化比较,将所得到的差异作用于真正 DOM 树完成视图更新。在 patch 函数中,或调用多个组件钩子,钩子源码传送 src/core/vdom/create-component.js。 初始化 CompB 节点,初始化钩子(init hook)将会调用:

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

因为是首次创建,调用 createComponentInstanceForVnode 方法返回实例属性。若再次进入,由于设置了 keepAlive 属性,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
    )
  }

插入 CompB 节点,insert 钩子将会调用:

insert (vnode: MountedComponentVNode) {
  const { context, componentInstance } = vnode
  if (!componentInstance._isMounted) {
    componentInstance._isMounted = true
    callHook(componentInstance, 'mounted')
  }
  if (vnode.data.keepAlive) {
    if (context._isMounted) {
      queueActivatedComponent(componentInstance)
    } else {
      activateChildComponent(componentInstance, true /* direct */)
    }
  }
}

首先 CompB 是新路径,其生命周期的 mounted 钩子会被执行;其次,因为设置了 keepAlive 属性,在 activateChildComponent 方法中会执行生命周期的 activated 钩子。 CompA 虚拟节点会被移出虚拟 DOM 树,执行destroy hook:

destroy (vnode: MountedComponentVNode) {
  const { componentInstance } = vnode
    if (!componentInstance._isDestroyed) {
    if (!vnode.data.keepAlive) {
      componentInstance.$destroy()
    } else {
      deactivateChildComponent(componentInstance, true /* direct */)
    }
  }
}

若没有设置 keepAlive 属性,则 CompA 组件将会调用 $destroy 函数进行销毁。而设置了 keepAlive 属性,则只会调用组件生命周期的 deactivated 钩子函数,组件实例得以保存在内存中。 综上所述,虚拟节点的 keepAlive 属性是关键,通过该属性在调用 init 钩子时避免实例被多次创建,同时在切换时调用destroy 钩子避免被销毁。

References

  1. 戴嘉华 深度剖析:如何实现一个 Virtual DOM 算法