记一次 <keep-alive> 缓存及其缓存优化原理

缓存淘汰策略

由于 <keep-alive> 中的缓存优化遵循 LRU 原则,所以首先了解下缓存淘汰策略的相关介绍。

由于缓存空间是有限的,所以不能无限制的进行数据存储,当存储容量达到一个阀值时,就会造成内存溢出,因此在进行数据缓存时,就要根据情况对缓存进行优化,清除一些可能不会再用到的数据。所以根据缓存淘汰的机制不同,常用的有以下三种:

  1. FIFO(fisrt-in-fisrt-out)- 先进先出策略

    我们通过记录数据使用的时间,当缓存大小即将溢出时,优先清楚离当前时间最远的数据。

  1. LRU (least-recently-used)- 最近最少使用策略

    以时间作为参考,如果数据最近被访问过,那么将来被访问的几率会更高,如果以一个数组去记录数据,当有一数据被访问时,该数据会被移动到数组的末尾,表明最近被使用过,当缓存溢出时,会删除数组的头部数据,即将最不频繁使用的数据移除。(keep-alive 的优化处理)

  1. LFU (least-frequently-used)- 计数最少策略

    以次数作为参考,用次数去标记数据使用频率,次数最少的会在缓存溢出时被淘汰。

<keep-alive> 简单示例

首先我们看一个动态组件使用 <keep-alive>例子)。

<div id="dynamic-component-demo">
  <button v-on:click="currentTab = 'Posts'">Posts</button>
    <button v-on:click="currentTab = 'Archive'">Archive</button>
  <keep-alive>
    <component
      v-bind:is="currentTabComponent"
      class="tab"
    ></component>
  </keep-alive>
</div>
Vue.component('tab-posts', { 
  data: function () {
      return {
      count: 0
    }
  },
    template: `
      <div class="posts-tab">
     <button @click="count++">Click Me</button>
         <p>{{count}}</p>
    </div>`
})

Vue.component('tab-archive', { 
    template: '<div>Archive component</div>' 
})

new Vue({
  el: '#dynamic-component-demo',
  data: {
    currentTab: 'Posts',
  },
  computed: {
    currentTabComponent: function () {
      return 'tab-' + this.currentTab.toLowerCase()
    }
  }
})

我们可以看到,动态组件外层包裹着 <keep-alve> 标签。

<keep-alive>
  <component
    v-bind:is="currentTabComponent"
    class="tab"
  ></component>
</keep-alive>

那就意味着,当选项卡 PostsArchive 在来回切换时,所对应的组件实例会被缓存起来,所以当再次切换到 Posts 选项时,其对应的组件 tab-posts 会从缓存中获取,计数器 count 也会保留上一次的状态。

<keep-alive> 缓存及优化处理

就此,我们看完 <keep-alive> 的简单示例之后,让我们一起来分析下源码中它是如何进行组件缓存和缓存优化处理的。

首次渲染

vue模板 -> AST -> render() -> vnode -> 真实Dom 这个转化过程中,会进入 patch 阶段,在patch 阶段,会调用 createElm 函数中会将 vnode 转化为真实 dom

function createPatchFunction (backend) {
  ...
  //生成真实dom
  function createElm (
    vnode,
    insertedVnodeQueue,
    parentElm,
    refElm,
    nested,
    ownerArray,
    index
  ) {
    // 返回 true 代表为 vnode 为组件 vnode,将停止接下来的转换过程
    if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
      return;
    }
    ...
  }
}

在转化节点的过程中,因为 <keep-alive>vnode 会视为组件 vnode,因此一开始会调用 createComponent() 函数,createComponent() 会执行组件初始化内部钩子 init(), 对组件进行初始化和实例化。

function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
    var i = vnode.data;
    if (isDef(i)) {
      // isReactivated 用来判断组件是否缓存
      var isReactivated = isDef(vnode.componentInstance) && i.keepAlive;
      if (isDef(i = i.hook) && isDef(i = i.init)) {
        // 执行组件初始化的内部钩子 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);
        // 将真实 dom 添加到父节点,insert 操作 dom api
        insert(parentElm, vnode.elm, refElm);
        if (isTrue(isReactivated)) {
          reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm);
        }
        return true
      }
    }
  }

<keep-alive> 组件通过调用内部钩子 init() 方法进行初始化操作。

注:源码中通过函数 installComponentHooks() 可追踪到内部钩子的定义对象 componentVNodeHooks
// inline hooks to be invoked on component VNodes during patch
var componentVNodeHooks = {
  init: function init (vnode, hydrating) {
    if (
      vnode.componentInstance &&
      !vnode.componentInstance._isDestroyed &&
      vnode.data.keepAlive
    ) {
      // kept-alive components, treat as a patch
      var mountedNode = vnode; // work around flow
      componentVNodeHooks.prepatch(mountedNode, mountedNode);
    } else {
      // 第一次运行时,vnode.componentInstance 不存在 ,vnode.data.keepAlive 不存在
      // 将组件实例化,并赋值给 vnode 的 componentInstance 属性
      var child = vnode.componentInstance = createComponentInstanceForVnode(
        vnode,
        activeInstance
      );
      // 进行挂载
      child.$mount(hydrating ? vnode.elm : undefined, hydrating);
    }
  },
  // prepatch 是 patch 过程的核心步骤
  prepatch: function prepatch (oldVnode, vnode) { ... },
  insert: function insert (vnode) { ... },
  destroy: function destroy (vnode) { ... }
};

第一次执行时,很明显组件 vnode 没有 componentInstance 属性,vnode.data.keepAlive 也没有值,所以会调用 createComponentInstanceForVnode() 将组件进行实例化并将组件实例赋值给 vnodecomponentInstance 属性,最后执行组件实例的 $mount 方法进行实例挂载。

createComponentInstanceForVnode()是组件实例化的过程,组件实例化无非就是一系列选项合并,初始化事件,生命周期等初始化操作。

缓存 vnode 节点

<keep-alive> 在执行组件实例化之后会进行组件的挂载(如上代码所示)。

...
// 进行挂载
child.$mount(hydrating ? vnode.elm : undefined, hydrating);
...

挂载 $mount 阶段会调用 mountComponent() 函数进行 vm._update(vm._render(), hydrating); 操作。

Vue.prototype.$mount = function (el, hydrating) {
  el = el && inBrowser ? query(el) : undefined;
  return mountComponent(this, el, hydrating)
};

function mountComponent (vm, el, hydrating) {
  vm.$el = el;
    ...
  callHook(vm, 'beforeMount');
  var updateComponent;
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    ...
  } else { 
    updateComponent = function () {
      // vm._render() 会根据数据的变化为组件生成新的 Vnode 节点
      // vm._update() 最终会为 Vnode 生成真实 DOM 节点
      vm._update(vm._render(), hydrating);
    }
  }
  ...
  return vm
}

vm._render() 函数最终会调用组件选项中的 render() 函数,进行渲染。

function renderMixin (Vue) {
  ...
  Vue.prototype._render = function () {
    var vm = this;
    var ref = vm.$options;
    var render = ref.render;
    ...
    try {  
      ...
      // 调用组件的 render 函数
      vnode = render.call(vm._renderProxy, vm.$createElement);
    }
    ...
    return vnode
  };
}

由于keep-alive 是一个内置组件,因此也拥有自己的 render() 函数,所以让我们一起来看下 render() 函数的具体实现。

var KeepAlive = {
  ...
  props: {
    include: patternTypes,  // 名称匹配的组件会被缓存,对外暴露 include 属性 api
    exclude: patternTypes,  // 名称匹配的组件不会被缓存,对外暴露 exclude 属性 api
    max: [String, Number]  // 可以缓存的组件最大个数,对外暴露 max 属性 api
  },
  created: function created () {},
  destroyed: function destroyed () {},
    mounted: function mounted () {},
  
  // 在渲染阶段,进行缓存的存或者取
  render: function render () {
    // 首先拿到 keep-alve 下插槽的默认值 (包裹的组件)
    var slot = this.$slots.default;
    // 获取第一个 vnode 节点
    var vnode = getFirstComponentChild(slot); // # 3802 line
    // 拿到第一个子组件实例
    var componentOptions = vnode && vnode.componentOptions;
    // 如果 keep-alive 第一个组件实例不存在
    if (componentOptions) {
      // check pattern
      var name = getComponentName(componentOptions);
      var ref = this;
      var include = ref.include;
      var exclude = ref.exclude;
      // 根据匹配规则返回 vnode 
      if (
        // not included
        (include && (!name || !matches(include, name))) ||
        // excluded
        (exclude && name && matches(exclude, name))
      ) {
        return vnode
      }

      var ref$1 = this;
      var cache = ref$1.cache;
      var keys = ref$1.keys;
      var key = vnode.key == null
        // same constructor may get registered as different local components
        // so cid alone is not enough (#3269)
        // 获取本地组件唯一key
        ? componentOptions.Ctor.cid + (componentOptions.tag ? ("::" + (componentOptions.tag)) : '')
        : vnode.key;
      if (cache[key]) {
        vnode.componentInstance = cache[key].componentInstance;
        // make current key freshest
        // 使用 LRU 最近最少缓存策略,将命中的 key 从缓存数组中删除,并将当前最新 key 存入缓存数组的末尾
        remove(keys, key); // 删除命中已存在的组件
        keys.push(key); // 将当前组件名重新存入数组最末端
      } else {
        // 进行缓存
        cache[key] = vnode;
        keys.push(key);
        // prune oldest entry
        // 根据组件名与 max 进行比较
        if (this.max && keys.length > parseInt(this.max)) { // 超出组件缓存最大数的限制
          // 执行 pruneCacheEntry 对最少访问数据(数组的第一项)进行删除
          pruneCacheEntry(cache, keys[0], keys, this._vnode);
        }
      }
      // 为缓存组件打上标志
      vnode.data.keepAlive = true;
    }
    // 返回 vnode 
    return vnode || (slot && slot[0])
  }
};

从上可得知,在 keep-alive 的源码定义中, render() 阶段会缓存 vnode 和组件名称 key 等操作。

  • 首先会判断是否存在缓存,如果存在,则直接从缓存中获取组件的实例,并进行缓存优化处理(稍后会介绍到)。
  • 如果不存在缓存,会将 vnode 作为值存入 cache 对象对应的 key 中。还会将组件名称存入 keys 数组中。
if (cache[key]) {
  vnode.componentInstance = cache[key].componentInstance;
  // make current key freshest
  // 使用 LRU 最近最少缓存策略,将命中的 key 从缓存数组中删除,并将当前最新 key 存入缓存数组的末尾
  remove(keys, key); // 删除命中已存在的组件
  keys.push(key); // 将当前组件名重新存入数组最末端
} else {
  // 进行缓存
  cache[key] = vnode;
  keys.push(key);
  // prune oldest entry
  // 根据组件名与 max 进行比较
  if (this.max && keys.length > parseInt(this.max)) { // 超出组件缓存最大数的限制
    // 执行 pruneCacheEntry 对最少访问数据(数组的第一项)进行删除
    pruneCacheEntry(cache, keys[0], keys, this._vnode);
  }
}

缓存真实 DOM

回顾之前提到的首次渲染阶段,会调用 createComponent()函数, createComponent()会执行组件初始化内部钩子 init(),对组件进行初始化和实例化等操作。

function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
  var i = vnode.data;
  if (isDef(i)) {
    // isReactivated 用来判断组件是否缓存
    var isReactivated = isDef(vnode.componentInstance) && i.keepAlive;
    if (isDef(i = i.hook) && isDef(i = i.init)) {
        // 执行组件初始化的内部钩子 init()
      i(vnode, false /* hydrating */);
    }
    if (isDef(vnode.componentInstance)) {
      initComponent(vnode, insertedVnodeQueue);
      // 将真实 dom 添加到父节点,insert 操作 dom api
      insert(parentElm, vnode.elm, refElm);
      if (isTrue(isReactivated)) {
        reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm);
      }
      return true
    }
  }
}

createComponet() 函数还会我们通过 vnode.componentInstance 拿到了 <keep-alive> 组件的实例,然后执行 initComponent()initComponent() 函数的作用就是将真实的 dom 保存再 vnode 中。

...
if (isDef(vnode.componentInstance)) {
  // 其中的一个作用就是保存真实 dom 到 vnode 中
  initComponent(vnode, insertedVnodeQueue);
  // 将真实 dom 添加到父节点,(insert 操作 dom api)
  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;
    }
    // 保存真是 dom 节点到 vnode 
    vnode.elm = vnode.componentInstance.$el;
    ...
}

缓存优化处理

在文章开头,我们介绍了三种缓存优化策略(它们各有优劣),而在 vue 中对 <keep-alive> 的缓存优化处理的实现上,便用到了上述的 LRU 缓存策略 。

上面介绍到,<keep-alive> 组件在存取缓存的过程中,是在渲染阶段进行的,所以我们回过头来看 render() 函数的实现。

var KeepAlive = {
  ...
  props: {
    include: patternTypes,  // 名称匹配的组件会被缓存,对外暴露 include 属性 api
    exclude: patternTypes,  // 名称匹配的组件不会被缓存,对外暴露 exclude 属性 api
    max: [String, Number]  // 可以缓存的组件最大个数,对外暴露 max 属性 api
  },
  // 创建节点生成缓存对象
  created: function created () {
    this.cache = Object.create(null); // 缓存 vnode 
    this.keys = []; // 缓存组件名
  },
 
  // 在渲染阶段,进行缓存的存或者取
  render: function render () {
    // 首先拿到 keep-alve 下插槽的默认值 (包裹的组件)
    var slot = this.$slots.default;
    // 获取第一个 vnode 节点
    var vnode = getFirstComponentChild(slot); // # 3802 line
    // 拿到第一个子组件实例
    var componentOptions = vnode && vnode.componentOptions;
    // 如果 keep-alive 第一个组件实例不存在
    if (componentOptions) {
      // check pattern
      var name = getComponentName(componentOptions);
      var ref = this;
      var include = ref.include;
      var exclude = ref.exclude;
      // 根据匹配规则返回 vnode 
      if (
        // not included
        (include && (!name || !matches(include, name))) ||
        // excluded
        (exclude && name && matches(exclude, name))
      ) {
        return vnode
      }

      var ref$1 = this;
      var cache = ref$1.cache;
      var keys = ref$1.keys;
      var key = vnode.key == null
        // same constructor may get registered as different local components
        // so cid alone is not enough (#3269)
        // 获取本地组件唯一key
        ? componentOptions.Ctor.cid + (componentOptions.tag ? ("::" + (componentOptions.tag)) : '')
        : vnode.key;
      if (cache[key]) {
        vnode.componentInstance = cache[key].componentInstance;
        // make current key freshest
        // 使用 LRU 最近最少缓存策略,将命中的 key 从缓存数组中删除,并将当前最新 key 存入缓存数组的末尾
        remove(keys, key); // 删除命中已存在的组件
        keys.push(key); // 将当前组件名重新存入数组最末端
      } else {
        // 进行缓存
        cache[key] = vnode;
        keys.push(key);
        // prune oldest entry
        // 根据组件名与 max 进行比较
        if (this.max && keys.length > parseInt(this.max)) { // 超出组件缓存最大数的限制
          // 执行 pruneCacheEntry 对最少访问数据(数组的第一项)进行删除
          pruneCacheEntry(cache, keys[0], keys, this._vnode);
        }
      }
      // 为缓存组件打上标志
      vnode.data.keepAlive = true;
    }
    // 返回 vnode 
    return vnode || (slot && slot[0])
  }
};

<keep-alive> 组件会在创建阶段生成缓存对象,在渲染阶段对组件进行缓存,并进行缓存优化。我们重点来看下段带代码。

if (cache[key]) {
  ...
  // 使用 LRU 最近最少缓存策略,将命中的 key 从缓存数组中删除,并将当前最新 key 存入缓存数组的末尾
  remove(keys, key); // 删除命中已存在的组件
  keys.push(key); // 将当前组件名重新存入数组最末端
} else {
  // 进行缓存
  cache[key] = vnode;
  keys.push(key);
  // 根据组件名与 max 进行比较
  if (this.max && keys.length > parseInt(this.max)) { // 超出组件缓存最大数的限制
    // 执行 pruneCacheEntry 对最少访问数据(数组的第一项)进行删除
    pruneCacheEntry(cache, keys[0], keys, this._vnode);
  }
}

从注释中我们可以得知,当 keep-alive 被激活时(触发 activated 钩子),会执行 remove(keys, key) 函数,从缓存数组中 keys 删除已存在的组件,之后会进行 push 操作,将当前组件名重新存入 keys 数组的最末端,正好符合 LRU

LRU:以时间作为参考,如果数据最近被访问过,那么将来被访问的几率会更高,如果以一个数组去记录数据,当有一数据被访问时,该数据会被移动到数组的末尾,表明最近被使用过,当缓存溢出时,会删除数组的头部数据,即将最不频繁使用的数据移除。
remove(keys, key); // 删除命中已存在的组件
keys.push(key); // 将当前组件名重新存入数组最末端

function remove (arr, item) {
  if (arr.length) {
    var index = arr.indexOf(item);
    if (index > -1) {
      return arr.splice(index, 1)
    }
  }
}

至此,我们可以回过头看我们上边的 <keep-alive> 示例,示例中包含 tab-poststab-archive 两个组件,通过 componentis 属性动态渲染。当 tab 来回切换时,会将两个组件的 vnode 和组件名称存入缓存中,如下。

keys = ['tab-posts', 'tab-archive']
cache = {
    'tab-posts':   tabPostsVnode,
    'tab-archive': tabArchiveVnode
}

假如,当再次激活到 tabPosts 组件时,由于命中了缓存,会调用源码中的 remove()方法,从缓存数组中 keys tab-posts 删除,之后会使用 push 方法将 tab-posts 推到末尾。这时缓存结果变为:

keys = ['tab-archive', 'tab-posts']
cache = {
    'tab-posts':   tabPostsVnode,
    'tab-archive': tabArchiveVnode
}

现在我们可以得知,keys 用开缓存组件名是用来记录缓存数据的。 那么当缓存溢出时, <keep-alive>又是如何 处理的呢?

我们可以通过 max 属性来限制最多可以缓存多少组件实例。

在上面源码中的 render() 阶段,还有一个 pruneCacheEntry(cache, keys[0], keys, this._vnode) 函数,根据 LRU 淘汰策略,会在缓存溢出时,删除缓存中的头部数据,所以会将 keys[0] 传入pruneCacheEntry()

if (this.max && keys.length > parseInt(this.max)) { // 超出组件缓存最大数的限制
  // 执行 pruneCacheEntry 对最少访问数据(数组的第一项)进行删除
  pruneCacheEntry(cache, keys[0], keys, this._vnode);
}

pruneCacheEntry() 具体逻辑如下:

  • 首先,通过cached$$1 = cache[key]` 获取头部数据对应的值 `vnode`,执行 `cached$$1.componentInstance.$destroy() 将组件实例销毁。
  • 其次,执行 cache[key] = null 清空组件对应的缓存节点。
  • 最后,执行 remove(keys, key) 删除缓存中的头部数据 keys[0]

至此,关于 <keep-alive> 组件的首次渲染、组件缓存和缓存优化处理相关的实现就到这里。

最后

最后记住这几个点:

  • <keep-alive>vue 内置组件,在源码定义中,也具有自己的组件选项如 datamethodcomputedprops 等。
  • <keep-alive> 具有抽象组件标识 abstract,通常会与动态组件一同使用。
  • <keep-alive> 包裹动态组件时,会缓存不活动的组件实例,将它们停用,而不是销毁它们。
  • <keep-alive> 缓存的组件会触发 activateddeactivated 生命周期钩子。
  • <keep-alive> 会缓存组件实例的 vnode 对象 ,和真实 dom 节点,所以会有 max 属性设置。
  • <keep-alive> 不会在函数式组件中正常工作,因为它们没有缓存实例。
阅读 1.4k

推荐阅读
前端 1943
用户专栏

前端技术文章

2463 人关注
21 篇文章
专栏主页