一文搞懂 keep-alive 使用和实现源码原理(附LRU算法)

1,273 阅读4分钟

Vue 的 keep alive 主要作为缓存 vue 模板组件。

怎么用?

怎么写?

keep-alive 用法,官方文档,直接写标签就可以,因为 keep-aliveslottransition 一样,属于 vue 的内置组件。

<template>
  <keep-alive>
    <!-- 放置要缓存的组件 -->
  </keep-alive>
</template>

怎么配置

可以传三个参数 Props,分别是:

  • include - 字符串或正则表达式。只有名称匹配的组件会被缓存。
  • exclude - 字符串或正则表达式。任何名称匹配的组件都不会被缓存。( 和 include 用法相同 )
  • max - 数字。最多可以缓存多少组件实例。( 下边会讲 LRU 算法 )

可以写两个声明周期函数,分别是:

  • activated - 被 keep-alive 缓存的组件激活时调用。
  • deactivated - 被 keep-alive 缓存的组件失活时调用。(使用时,跟 datamethod 平级)

组件内声明周期执行顺序

第一次:created =》mounted =》activated =》deactivated (调用请求数据方法习惯在 mounted 生命周期调用)

第二次:activated =》deactivated

<template>
  <!-- include="a,b" :include="/a|b/"  :include="['a', 'b']" -->
  <keep-alive  :max="10" >
    <!-- 放置要缓存的组件 只缓存第一个 -->
  </keep-alive>
</template>

<script>
export default {
  activated() {
    console.log('激活调用');
  },
  deactivated() {
    console.log('失活调用');
  }
}
</script>

动态组件使用

<!-- currentTabComponent 代表缓存组件的名称-->
<keep-alive>
  <component v-bind:is="currentTabComponent"></component>
</keep-alive>

用在哪?

结合路由配置,缓存路由匹配的组件

// 路由配置 配置在元信息上  src/route/index.js
routers: [{
  path: '/',
  name: 'Home',
  meta: { 
    keepAlive: false // 不需要缓存
  }
},{
  path: '/page',
  name: 'Page',
  meta: {
    keepAlive: true  // 需要缓存
  }
},]

// 组件配置 src/App.vue
<template>
  <div id="home">
    <keep-alive>
      <!-- 需要缓存 -->
      <router-view v-if="$route.meta.keepAlive"></router-view>
    </keep-alive>
    <!-- 不需要缓存 -->
    <router-view v-if="!$route.meta.keepAlive"></router-view>
  </div>
</template>

缓存列表页。(数据变动频率不是很快的时候)

  • 配置路由
// 配置路由 src/route/index.js
{
  path: '/list',
    name: 'list',
      component: () => import('../views/List.vue'),
        meta: {
    isCache: false, // 设置在列表组件 默认不缓存
      keepAlive: true // 是否使用 keep-alive 缓存
  },
  children: [
    {
      path: '/detail',
      name: 'detail',
      component: () => import('../views/Detail.vue'),
    }
  ]
}
  • 编写逻辑
// 列表页面组件 src/views/List.vue
// 激活组件
activated() {
  // 判断是否是详情页回退跳转
  if(!this.$route.meta.isCache){ //isUseCache 时添加中router中的元信息,判读是否要缓存
    this.listData = []; //清空原有数据
    this.searchForm = {}; // 清空搜索条件
    this.getList(); // 重新加载列表数据
  }
  // 每次跳转到列表组件时,设置列表组件不缓存,防止非详情页跳转时列表组件仍然渲染缓存的组件
  this.$route.meta.isUseCache = false 
},
// 列表页面跳转到 详情页时,设置需要缓存
beforeRouteLeave(to, from, next){
  // 判断即将跳转的页面是否是详情页
  if(to.name=='/list/detail'){
    // 设置当前页面缓存
    from.meta.isCache = true
  }
  next()
}

上源码!

源码位置在 src/core/components/keep-alive.js

/* @flow */

import { isRegExp, remove } from 'shared/util'
import { getFirstComponentChild } from 'core/vdom/helpers/index'

type CacheEntry = {
  name: ?string;
  tag: ?string;
  componentInstance: Component;
};

type CacheEntryMap = { [key: string]: ?CacheEntry };

// 获取当前的组件名称
function getComponentName(opts: ?VNodeComponentOptions): ?string {
  return opts && (opts.Ctor.options.name || opts.tag)
}

// 判断 include 或者 exclude 跟组件的 name 是否匹配成功, props 传参字符串、字符串数组、正则
function matches(pattern: string | RegExp | Array<string>, name: string): boolean {
  // ...
}

// prune 修正缓存
function pruneCache(keepAliveInstance: any, filter: Function) {
  
}

/**
 * 清除缓存
 */
function pruneCacheEntry(
) {
  // ...
}

const patternTypes: Array<Function> = [String, RegExp, Array]

// 抛出默认组件
export default {
  name: 'keep-alive',
  abstract: true, // 代表该组件是抽离组件 render 的时候抽离组件不渲染在父组件链上

  props: {
    include: patternTypes, // 字符串或正则表达式。只有名称匹配的组件会被缓存。
    exclude: patternTypes, //  字符串或正则表达式。任何名称匹配的组件都不会被缓存。( 和 include 用法相同 )
    max: [String, Number] //  数字。最多可以缓存多少组件实例。
  },

  methods: {
    /**
     * 清空缓存虚拟DOM
     */
    cacheVNode() {
      // ...缓存 vnode
    }
  },

  /**
   * 创建空对象
   */
  created() {
    // ... 初始化
  },
  /**
   * keep-alive 销毁生命周期 将所有缓存的组件执行销毁
   */
  destroyed() {
    // ...销毁
  },
  /**
   * keep-alive
   */
  mounted() {
    // ...挂载
  },

  updated() {
     // ...更新
  },
  render() {
    // ...渲染
  }
}

可以看到默认抛出了一个组件,并且有他对应生命周期的执行逻辑。

初始化

/**
* 初始化
*/
created() {
    // 缓存的组件对象 
    this.cache = Object.create(null)
    // 缓存组件的 key
    this.keys = []
}

初始化主要生命了两个变量,一个 cache 对象用来缓存被包裹在 keep-alive 组件里的 vnode 实例,一个 keys 数组用来收集所有被缓存实例的 key。

渲染

/**
   * 渲染
   */
  render() {
    // 通过 $slots.default 获得 <keep-alive>内容</keep-alive>  
    const slot = this.$slots.default
    // 获取第一个子组件
    const vnode: VNode = getFirstComponentChild(slot)
    // 拿到当前组件 options
    const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions
    if (componentOptions) {
      // check pattern
      const name: ?string = getComponentName(componentOptions)
      // 获取传入的 props 
      const { include, exclude } = this

      // 不缓存的情况
      if (
        // not included
        (include && (!name || !matches(include, name))) ||
        // excluded
        (exclude && name && matches(exclude, name))
      ) {
        return vnode
      }

      const { cache, keys } = this

      // 根据组件 options ,获取组件缓存 key
      const key: ?string = vnode.key == null
        // same constructor may get registered as different local components
        // so cid alone is not enough (#3269)
        // 相同的构造函数可以注册为不同的本地组件 所以单独一个 cid 不能满足 需要 cid + 当前组件 tag
        ? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '')
        : vnode.key
      // 看当前缓存里有没有缓存实例 有的话更新位置 (LRU 算法)
      if (cache[key]) {
        vnode.componentInstance = cache[key].componentInstance
        // *   make current key freshest  直译:使当前key称为最新鲜的
        // 删除当前缓存
        remove(keys, key)
        // 添加到队列最后一位
        keys.push(key)
      } else {
        // delay setting the cache until update
        // 没有当前实例就缓存当前实例
        this.vnodeToCache = vnode
        // 把当前实例的 key 
        this.keyToCache = key
      }

      // keepAlive 标记位
      vnode.data.keepAlive = true
    }
    // 返回子组件或者返回插槽的第一个元素
    return vnode || (slot && slot[0])
  }
  1. 获取插槽中第一个的组件
  2. 当前组件没有在缓存的配置项中,直接返回
  3. 看看之前有没有被缓存过,如果被缓存刷新当前组件在缓存队列里的位置(LRU算法),没有被缓存过就设置当前组件为接下来要被缓存
  4. 返回缓存的 vnode

挂载

/**
* 挂载
*/
mounted() {
    // 缓存 vnode
    this.cacheVNode()
    // 使用 vue 的监听函数,监听 include 的最新值 修正需要缓存的组件
    this.$watch('include', val => {
      pruneCache(this, name => matches(val, name))
    })

     // 使用 vue 的监听函数,监听 exclude 的最新值 修正不需要缓存的组件
    this.$watch('exclude', val => {
      pruneCache(this, name => !matches(val, name))
    })
}

挂载 keep-alive 到页面主要做了三件事

缓存 vnode

/**
* 缓存 vnode
*/
cacheVNode() {
      // 从当前实例中解构出 缓存对象 缓存keys 将要被缓存的 vode 和将要被缓存的 key
      const { cache, keys, vnodeToCache, keyToCache } = this;
       // 如果之前在 render 函数中有将要缓存的组件
      if (vnodeToCache) {
        // 缓存组件实例
        const { tag, componentInstance, componentOptions } = vnodeToCache
        // 把当前缓存的对象挂到 cache 对象上,key 是啥看 render 函数注解 
        cache[keyToCache] = {
          name: getComponentName(componentOptions), // 获取组件的名称
          tag, // 组件标签
          componentInstance, // 组件实例
        }
        // 把当前 key 缓存到 keys 数组里
        keys.push(keyToCache)
        // prune oldest entry
        // 判断当前最大缓存数 如果当前缓存的数量大于设置的最大缓存数 缓存之前没有命中的缓存
        if (this.max && keys.length > parseInt(this.max)) {
          // 清除当前第一个缓存组件 (见下方 LRU 算法图)
          pruneCacheEntry(cache, keys[0], keys, this._vnode)
        }
        // 清除将要缓存 vnode 的变量 
        this.vnodeToCache = null
      }
    }
}

这个方法每次走到 mounted 生命周期的时候都会执行,mounted 生命周期没走到之前,先走到的时候 render 函数,render 函数里以及把将要缓存的组件给了当前组件变量 vnodeToCache ,依据 vnodeToCache 我们就可以知道当前是否有要被缓存的组件,有的话挂到 cache 对象上,并且把当前组件的 key 添加到 keys 的最后一位,查看当前缓存的数量是否大于 max 最大缓存量,大于的话清除掉第一个(LRU算法,别急,最后说)。最后清除将要被缓存的 vnode 变量,方便下一次缓存。

监听传入的 Props

/**
   * 挂载
   */
  mounted() {
    // 缓存 vnode
    this.cacheVNode()
    // 使用 vue 的监听函数,监听 include 的最新值 修正需要缓存的组件
    this.$watch('include', val => {
      pruneCache(this, name => matches(val, name))
    })

     // 使用 vue 的监听函数,监听 exclude 的最新值 修正不需要缓存的组件
    this.$watch('exclude', val => {
      pruneCache(this, name => !matches(val, name))
    })
  }

为啥要监听呢,因为可能每次上次和这一次缓存的组件不同,所以要及时修正缓存组件的容器 cache 和 keys。

这里用到了两个方法一个是 pruneCache 修正缓存(prune 本身的意思是修剪),另一个是 matches 匹配。

// prune 修正缓存
function pruneCache(keepAliveInstance: any, filter: Function) {
  // 当前 keep-alive 实例 由 vnode 组成的缓存对象和 keys 和 _vnode
  const { cache, keys, _vnode } = keepAliveInstance
  // 循环遍历
  for (const key in cache) {
    // 获取每个 vnode
    const entry: ?CacheEntry = cache[key]
    // 如果 vnode 存在
    if (entry) {
      const name: ?string = entry.name
      // 当前 vnode 存在,查看是否和传入的 include 和 exclude 匹配
      if (name && !filter(name)) {
        // 不匹配销毁实例
        pruneCacheEntry(cache, key, keys, _vnode)
      }
    }
  }
}

简单说,就是看一下组件里是否有不需要缓存的组件,有的话就销毁。

// 判断 include 或者 exclude 跟组件的 name 是否匹配成功, props 传参字符串、字符串数组、正则
function matches(pattern: string | RegExp | Array<string>, name: string): boolean {
  // 判断 Props 是否为数组
  if (Array.isArray(pattern)) {
    return pattern.indexOf(name) > -1
  
  // 判断 Props 是否为字符串
  } else if (typeof pattern === 'string') {
    return pattern.split(',').indexOf(name) > -1
  
  // 判断 Props 是否为正则
  } else if (isRegExp(pattern)) {
    return pattern.test(name)
  }
  /* istanbul ignore next */
  // 以上三种条件都没匹配上 返回 false
  return false
}

判断一下当前 vnode 的 name 是否是要被缓存的,从三个规则去校验,字符串、正则、字符串数组,这三个规则也是 include 和 exclude 传递 Props 三种不同的类型。

更新

/**
   * 更新
   */
  updated() {
    // 更新生命周期 缓存 vnode
    this.cacheVNode()
  }

更新的时候也看一下是否有组件要被缓存,清除一下长时间未激活的缓存组件。

销毁

 /**
   * keep-alive 销毁生命周期 将所有缓存的组件执行销毁
   */
  destroyed() {
    // 循环遍历删除所有缓存
    for (const key in this.cache) {
      pruneCacheEntry(this.cache, key, this.keys)
    }
  },

keep-alive 需要被销毁的时候,循环遍历所有缓存的组件和 key

/**
 * 清除缓存
 */
function pruneCacheEntry(
  cache: CacheEntryMap,
  key: string,
  keys: Array<string>,
  current?: VNode
) {
  // 获取当前实例
  const entry: ?CacheEntry = cache[key]
  // 有当前实例 且 当前 VNode 不存在 或 当前标签 不等于 当前
  if (entry && (!current || entry.tag !== current.tag)) {
    // 执行当前组件的 destroy 销毁生命周期
    entry.componentInstance.$destroy()
  }
  // 设置当前缓存的实例等于 null ,清除缓存
  cache[key] = null
  // 删除  remove 方法引用了公共方法里的 remove ,通过数组的 splice 方法实现
  remove(keys, key)
}

LRU 算法

算法的精髓的在于思路,理解思路最好的方法在于看图。

凑合看哈,就是这么一个意思。LRU 算法也称为缓存淘汰算法,清除最少被使用的组件,核心思想是“如果组件最近被命中激活,那么将来被命中激活的几率也更高”。

keep-alive 中,使用队列的形式缓存组件,每次都把最不经常命中激活的第一个组件“推”出队列,最经常被命中激活的组件会被添加到队列最后一位。