Vue2 keep-alive实现原理

发布时间 2023-11-17 16:12:38作者: 柯基与佩奇

Vue.js 内部将 DOM 节点抽象成了一个个的 VNode 节点,keep-alive 组件的缓存也是基于 VNode 节点的而不是直接存储 DOM 结构。它将满足条件(pruneCache 与 pruneCache)的组件在 cache 对象中缓存起来,在需要重新渲染的时候再将 vnode 节点从 cache 对象中取出并渲染。

keep-alive 是 Vue 中内置的一个抽象组件。它自身不会渲染一个 DOM 元素,也不会出现在父组件链中。当它包裹动态组件时,会缓存不活动的组件实例,而不是销毁它们。

keep-alive 是用来缓存组件的,比如有个列表页,在点击详情页之后,如果返回之后不想刷新列表页,就可以用 keep-alive 组件进行缓存。除此以外,还有很多应用场景。

keepAlive 是在哪个生命周期被调用的

在 created 阶段,初始化 cache、keys,cache 用于缓存虚拟 DOM,是一个 map 集合。keys 用于缓存组件的 key 集合,是一个 Set
在 mounted 阶段,监听 include,exclude 的变化,执行相应操作
destroyed 阶段,删除所有缓存相关实例

用法

用法 1:想要缓存某个组件,只要用 keep-alive 组件将其包裹就行。

<keep-alive>
  <component></component>
</keep-alive>

用法 2:包裹 component 组件缓存动态组件,或者包裹 router-view 缓存路由页面,也就是 keep-alive 配合路由守卫(元信息)实现缓存。

比如常在 router.js 路由表里定义好哪些页面需要缓存,就可以通过下面这样实现了:

{
	path: "/index",
	name: 'index',
	component: () => import(/* webpackChunkName: "index" */ '@/pages/index'),
	meta: {
		title: '首页',
		keepAlive: true
	}
}
<keep-alive>
  <router-view v-if="$route.meta.keepAlive"></router-view>
</keep-alive>
<router-view v-if="!$route.meta.keepAlive && isRouterAlive"></router-view>

属性

include - 逗号分隔字符串或正则表达式或一个数组来表示。只有名称匹配的组件会被缓存。
exclude - 逗号分隔字符串或正则表达式或一个数组来表示。任何名称匹配的组件都不会被缓存。
max - 数字。最多可以缓存多少组件实例。

include 和 exclude 属性允许组件有条件地缓存:

<!-- 逗号分隔字符串 -->
<keep-alive include="a,b">
  <component :is="view"></component>
</keep-alive>

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

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

注意:想要缓存的组件一定要给定 name 属性,并且要和 include,exclude 给定的值一致

生命周期钩子

keep-alive 提供了两个生命钩子,分别是 activated 与 deactivated。

因为 keep-alive 会将组件保存在内存中,并不会销毁以及重新创建,所以不会重新调用组件的 created 等方法,需要用 activated 与 deactivated 这两个生命钩子来得知当前组件是否处于活动状态。

组件一旦被 keep-alive 缓存,那么再次渲染的时候就不会执行 created、mounted 等钩子函数。使用 keep-alive 组件后,被缓存的组件生命周期会多 activated 和 deactivated 两个钩子函数,它们的执行时机分别是 keep-alive 包裹的组件激活时调用和停用时调用。

源码

export default {
  name: "keep-alive",
  abstract: true,

  props: {
    include: [String, RegExp, Array],
    exclude: [String, RegExp, Array],
    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 = getFirstComponentChild(slot);
    /* 获取该组件节点的componentOptions */
    const componentOptions = vnode && vnode.componentOptions;

    if (componentOptions) {
      /* 获取该组件节点的名称,优先获取组件的name字段,如果name不存在则获取组件的tag */
      const name = getComponentName(componentOptions);

      const { include, exclude } = this;
      /* 如果name不在inlcude中或者存在于exlude中则表示不缓存,直接返回vnode */
      if (
        (include && (!name || !matches(include, name))) ||
        // excluded
        (exclude && name && matches(exclude, name))
      ) {
        return vnode;
      }

      const { cache, keys } = this;
      const key =
        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]);
  },
};

可以看到,它有 3 个属性,即有 3 个 props。此外,它有 created,destroyed,mounted,render 四个钩子。

原理

created 和 destroyed 钩子

created 钩子会创建一个 cache 对象,用来作为缓存容器,保存 vnode 节点。
destroyed 钩子则在组件被销毁的时候清除 cache 缓存中的所有组件实例。

created () {
 	/* 缓存对象 */
  this.cache = Object.create(null)
  this.keys = []
},
/* destroyed钩子中销毁所有cache中的组件实例 */
destroyed () {
  for (const key in this.cache) {
    pruneCacheEntry(this.cache, key, this.keys)
  }
},

render 钩子

keep-alive 实现缓存的核心代码就在这个钩子函数里。

  1. 先获取到插槽里的内容
  2. 调用 getFirstComponentChild 方法获取第一个子组件,获取到该组件的 name,如果有 name 属性就用 name,没有就用 tag 名。
/* 获取该组件节点的名称 */
const name = getComponentName(componentOptions);

/* 优先获取组件的name字段,如果name不存在则获取组件的tag */
function getComponentName(opts: ?VNodeComponentOptions): ?string {
  return opts && (opts.Ctor.options.name || opts.tag);
}
  1. 接下来会将这个 name 通过 include 与 exclude 属性进行匹配,匹配不成功(说明不需要进行缓存)则不进行任何操作直接返回这个组件的 vnode(vnode 是一个 VNode 类型的对象),否则的话走下一步缓存。匹配:
/* 检测name是否匹配 */
function matches(pattern: string | RegExp, name: string): boolean {
  if (typeof pattern === "string") {
    /* 字符串情况,如a,b,c */
    return pattern.split(",").indexOf(name) > -1;
  } else if (isRegExp(pattern)) {
    /* 正则 */
    return pattern.test(name);
  }
  /* istanbul ignore next */
  return false;
}

检测 include 与 exclude 属性匹配的函数很简单,include 与 exclude 属性支持字符串如"a,b,c"这样组件名以逗号隔开的情况以及正则表达式。matches 通过这两种方式分别检测是否匹配当前组件。

const { include, exclude } = this;
/* 如果name与include规则不匹配或者与exclude规则匹配则表示不缓存,直接返回vnode */
if (
  (include && (!name || !matches(include, name))) ||
  // excluded
  (exclude && name && matches(exclude, name))
) {
  return vnode;
}
  1. 缓存机制:接下来的事情很简单,根据 key 在 this.cache 中查找,如果存在则说明之前已经缓存过了,直接将缓存的 vnode 的 componentInstance(组件实例)覆盖到目前的 vnode 上面。否则将 vnode 存储在 cache 中。最后返回 vnode(有缓存时该 vnode 的 componentInstance 已经被替换成缓存中的了)。缓存的处理:
/* 如果命中缓存,则直接从缓存中拿 vnode 的组件实例 */
if (cache[key]) {
  vnode.componentInstance = cache[key].componentInstance;
  /* 调整该组件key的顺序,将其从原来的地方删掉并重新放在最后一个 */
  remove(keys, key);
  keys.push(key);
} else {
  /* 如果没有命中缓存,则将其设置进缓存 */
  cache[key] = vnode;
  keys.push(key);
  /* 如果配置了max并且缓存的长度超过了this.max,则从缓存中删除第一个 */
  if (this.max && keys.length > parseInt(this.max)) {
    pruneCacheEntry(cache, keys[0], keys, this._vnode);
  }
}
/* 最后设置keepAlive标记位 */
vnode.data.keepAlive = true;

命中缓存时会直接从缓存中拿 vnode 的组件实例,此时重新调整该组件 key 的顺序,将其从原来的地方删掉并重新放在 this.keys 中最后一个。

如果没有命中缓存,即该组件还没被缓存过,则以该组件的 key 为键,组件 vnode 为值,将其存入 this.cache 中,并且把 key 存入 this.keys 中。此时再判断 this.keys 中缓存组件的数量是否超过了设置的最大缓存数量值 this.max,如果超过了,则把第一个缓存组件删掉。

为什么要删除第一个缓存组件并且为什么命中缓存了还要调整组件 key 的顺序?这其实应用了一个缓存淘汰策略 LRU:
LRU(Least recently used,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。

this.keys 的逻辑:
将新数据从尾部插入到 this.keys 中
每当缓存命中(即缓存数据被访问),则将数据移到 this.keys 的尾部
当 this.keys 满的时候,将头部的数据丢弃

mounted 钩子

在这个钩子函数里,调用了 pruneCache 方法,以观测 include 和 exclude 的变化。

mounted () {
  this.$watch('include', val => {
    pruneCache(this, name => matches(val, name))
  })
  this.$watch('exclude', val => {
    pruneCache(this, name => !matches(val, name))
  })
},
watch: {
  /* 监视include以及exclude,在被修改的时候对cache进行修正 */
  include (val: string | RegExp) {
    pruneCache(this.cache, this._vnode, name => matches(val, name))
  },
  exclude (val: string | RegExp) {
    pruneCache(this.cache, this._vnode, name => !matches(val, name))
  }
},

如果 include 或 exclude 发生了变化,即表示定义需要缓存的组件的规则或者不需要缓存的组件的规则发生了变化,那么就执行 pruneCache 函数,函数如下:

function pruneCache(keepAliveInstance, filter) {
  const { cache, keys, _vnode } = keepAliveInstance;
  for (const key in cache) {
    const cachedNode = cache[key];
    if (cachedNode) {
      const name = getComponentName(cachedNode.componentOptions);
      if (name && !filter(name)) {
        pruneCacheEntry(cache, key, keys, _vnode);
      }
    }
  }
}

在该函数内对 this.cache 对象进行遍历,取出每一项的 name 值,用其与新的缓存规则进行匹配,如果匹配不上,则表示在新的缓存规则下该组件已经不需要被缓存,则调用 pruneCacheEntry 函数将其从 this.cache 对象删除即可。