Skip to main content

KeepAlive 缓存组件及原理

一、KeepAlive 定义及用法

默认情况下,一个组件实例在销毁后会丢失原有的已变化状态 —— 当这个组件再次被显示时,会创建一个带有初始状态的新实例。如果想要组件再次渲染时保留上一次销毁前的状态,可以用 KeepAlive 内置组件将组件进行包装。

KeepAlive 用于在多个组件动态切换时缓存被移除的组件实例。

使用场景:详情页返回列表页时缓存页数。

1、基本用法

举个例子:

App.vue
<template>
<Comp v-if="show" />
<button @click="switchShow">切换显示</button>
</template>

<script setup>
import { ref } from 'vue'
import Comp from './Comp.vue'

const show = ref(true)

const switchShow = () => {
show.value = !show.value
}
</script>

实现效果:

从上面例子可以看出,当组件被销毁后,会丢失原有的已变化状态。使用 KeepAlive 的效果如下:

App.vue
<template>
<KeepAlive>
<Comp v-if="show" />
</KeepAlive>
<button @click="switchShow">切换显示</button>
</template>

<script setup>
import { ref } from 'vue'
import Comp from './Comp.vue'

const show = ref(true)

const switchShow = () => {
show.value = !show.value
}
</script>

实现效果:

2、包含 / 排除

KeepAlive 的 Props 如下:

interface KeepAliveProps {
/**
* 如果指定,则只有与 include 名称
* 匹配的组件才会被缓存。
*/
include?: MatchPattern
/**
* 任何名称与 exclude
* 匹配的组件都不会被缓存。
*/
exclude?: MatchPattern
/**
* 最多可以缓存多少组件实例。
*/
max?: number | string
}

type MatchPattern = string | RegExp | (string | RegExp)[]

其中 include / exclude 用于匹配 KeepAlive 包裹层中缓存的组件,用法如下:

App.vue
<template>
<label><input type="radio" v-model="currentComp" :value="Comp1" />Comp1</label>
<label><input type="radio" v-model="currentComp" :value="Comp2" />Comp2</label>
<label><input type="radio" v-model="currentComp" :value="Comp3" />Comp3</label>
<!-- 逗号分隔的方式(注意逗号不能加空格) -->
<KeepAlive include="Comp1,Comp3">
<!-- 正则表达式的方式 -->
<KeepAlive :include="/Comp1|Comp3/">
<!-- 数组的方式 -->
<KeepAlive :include="['Comp1, Comp3']">
<component :is="currentComp"></component>
</KeepAlive>
</template>

<script setup>
import { shallowRef } from 'vue'
// 这里三个子组件写法同上面例子的子组件
import Comp1 from './Comp1.vue'
import Comp2 from './Comp2.vue'
import Comp3 from './Comp3.vue'

const currentComp = shallowRef(Comp1)
</script>

KeepAlive 只能包裹一个组件,所以这里用 component 的方式切换组件。

exclude 同理,实现效果:

3、限制缓存组件数

KeepAlivemax 用来限制缓存组件数,使用如下:

<template>
<label><input type="radio" v-model="currentComp" :value="Comp1" />Comp1</label>
<label><input type="radio" v-model="currentComp" :value="Comp2" />Comp2</label>
<label><input type="radio" v-model="currentComp" :value="Comp3" />Comp3</label>
<KeepAlive :max="2">
<component :is="currentComp"></component>
</KeepAlive>
</template>

<script setup>
import { shallowRef } from 'vue'
import Comp1 from './Comp1.vue'
import Comp2 from './Comp2.vue'
import Comp3 from './Comp3.vue'

const currentComp = shallowRef(Comp1)
</script>

原来设置 max 会执行 LRU 策略:如果缓存的实例数量超过 max,则最久没被访问的缓存实例将被销毁,以便为新的实例腾出空间。

由于 max 限制了最多可以缓存 2 个组件实例,但实际上有 3 个组件,因此这里的 KeepAlive 不会进行缓存,当把 max 的值改为 3 后,KeepAlive 才会生效。

4、缓存组件的生命周期

设置了 KeepAlive 缓存的组件,会注册两个生命周期钩子activateddeactivated

使用 KeepAlive 前使用 KeepAlive 后
首次进入组件卸载后再次进入首次进入组件卸载后再次进入
onBeforeMountonBeforeMountonBeforeMount
onMountedonMountedonMounted
onBeforeUpdateonBeforeUpdateonActivatedonActivated
onUpdatedonUpdatedonBeforeUpdateonBeforeUpdate
onBeforeUnmountonBeforeUnmountonUpdatedonUpdated
onUnmountedonUnmountedonDeactivatedonDeactivated

可以看到,当被 KeepAlive 缓存的组件移除时,将触发 deactivated 而非 unmounted,并且再次进入时生命周期将直接从 activated 开始。这两个钩子不仅适用于 KeepAlive 缓存的根组件,还适用于缓存树中的后代组件。

为什么被 KeepAlive 缓存的组件不会再次执行 created、mounted 等钩子函数呢?

主要和 componentVNodeHooks 这个方法有关:

当满足 vnode.componentInstance && !vnode.componentInstance._isDestroyed && vnode.data.keepAlive 的逻辑时不执行 $mount 的操作,而是执行 prepatch,而 prepatch 只做了更新子组件的操作,相当于从缓存中取出了当前组件实例。

// https://github.com/vuejs/vue/blob/main/src/core/vdom/create-component.ts#L36
const componentVNodeHooks = {
init(vnode: VNodeWithData, hydrating: boolean): boolean | void {
if (
vnode.componentInstance &&
!vnode.componentInstance._isDestroyed &&
vnode.data.keepAlive
) {
// kept-alive components, treat as a patch
const mountedNode: any = vnode // work around flow
componentVNodeHooks.prepatch(mountedNode, mountedNode)
} else {
const child = (vnode.componentInstance = createComponentInstanceForVnode(
vnode,
activeInstance
))
child.$mount(hydrating ? vnode.elm : undefined, hydrating)
}
},

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
)
},
// ...
}

二、KeepAlive 原理

KeepAlive 源码位于 vue/src/core/components/keep-alive.ts

1、抽象组件

keep-alive.ts
// https://github.com/vuejs/vue/blob/main/src/core/components/keep-alive.ts
export default {
name: 'keep-alive',
// 设为抽象组件,只对包裹的子组件做处理,不建立父子关系,也不作为节点渲染到页面上
abstract: true,
// ...
}

在组件开头设置 abstracttrue,代表 KeepAlive 是个抽象组件,抽象组件只对包裹的子组件做处理,并不会和子组件建立父子关系,也不会作为节点渲染到页面上。

抽象组件忽略父子关系的原理是在初始化阶段会调用 initLifecycle,里面判断父级是否为抽象组件,如果是则选取抽象组件的上一级作为父级,忽略与抽象组件和子组件之间的层级关系:

lifecycle.ts
// https://github.com/vuejs/vue/blob/main/src/core/instance/lifecycle.ts
export function initLifecycle (vm: Component) {
const options = vm.$options

// locate first non-abstract parent
let parent = options.parent
if (parent && !options.abstract) {
while (parent.$options.abstract && parent.$parent) {
parent = parent.$parent
}
parent.$children.push(vm)
}
vm.$parent = parent
// ...
}

2、pruneCache 方法

KeepAlive 在 mounted 中调用了 pruneCache,监听 include 和 exclude,当它们发生改变时调整缓存和 keys 的顺序:

// https://github.com/vuejs/vue/blob/main/src/core/components/keep-alive.ts#L110
mounted() {
this.cacheVNode()
this.$watch('include', val => {
pruneCache(this, name => matches(val, name))
})
this.$watch('exclude', val => {
pruneCache(this, name => !matches(val, name))
})
},

pruneCache 用于删除缓存,它接受一个 keepAlive 实例和一个 filter 函数,首先从实例中取出 cache、keys 和对应的 vnode。然后遍历整个 cache 对象,如果当前组件实例在缓存中并且参数合法,就执行 pruneCacheEntry 方法。

// https://github.com/vuejs/vue/blob/main/src/core/components/keep-alive.ts#L35
function pruneCache(
keepAliveInstance: { cache: CacheEntryMap; keys: string[]; _vnode: VNode },
filter: Function
) {
const { cache, keys, _vnode } = keepAliveInstance
for (const key in cache) {
const entry = cache[key]
if (entry) {
const name = entry.name
if (name && !filter(name)) {
pruneCacheEntry(cache, key, keys, _vnode)
}
}
}
}

3、pruneCacheEntry

KeepAlive 在 methods 中声明了 cacheVNode 并在 mounted 和 updated 中调用,用于检查并缓存当前组件的实例。

其中当缓存的组件数超过 max 时,会调用 pruneCacheEntry 方法,移除离最近使用时间相隔最久的那一个组件实例:

// https://github.com/vuejs/vue/blob/main/src/core/components/keep-alive.ts#L79
methods: {
cacheVNode() {
// 通过 vnodeToCache 判断该组件是否需要缓存
const { cache, keys, vnodeToCache, keyToCache } = this
if (vnodeToCache) {
const { tag, componentInstance, componentOptions } = vnodeToCache
cache[keyToCache] = {
name: _getComponentName(componentOptions),
tag,
componentInstance
}
// 同时也会把当前组件的 key 存储下来
keys.push(keyToCache)
// 判断缓存组件的数量是否超出 max 的值,即缓存空间不足
// 超出则将最旧的组件从缓存中删除,即 keys[0]
// 之后将组件的 keepAlive 标记为 true,表示它是被缓存的组件
if (this.max && keys.length > parseInt(this.max)) {
// pruneCacheEntry 负责将组件从缓存中删除
pruneCacheEntry(cache, keys[0], keys, this._vnode)
}
this.vnodeToCache = null
}
}
},

pruneCacheEntry 用到了 LRU 策略,负责将使用时间相隔最久的组件从缓存中删除,它会调用组件 $destroy 方法销毁组件实例,缓存组件置空,并移除对应的 key:

// https://github.com/vuejs/vue/blob/main/src/core/components/keep-alive.ts#L51
function pruneCacheEntry(
cache: CacheEntryMap,
key: string,
keys: Array<string>,
current?: VNode
) {
const entry = cache[key]
if (entry && (!current || entry.tag !== current.tag)) {
// @ts-expect-error can be undefined
entry.componentInstance.$destroy()
}
cache[key] = null
remove(keys, key)
}

4、LRU 策略

KeepAlive 的缓存机制是根据 LRU 策略来设置缓存组件新鲜度,将长时间未被访问的组件从缓存中删除。

LRU(Least recently used)策略根据数据的历史访问记录来进行数据淘汰。其设计原则是,当限定的空间已存满时,会把最久没有被访问到的数据淘汰。演示如下:

  1. 上述假设只允许存 3 个组件,ABC 三个组件依次进入缓存;
  2. D 组件被访问时内存不足,根据 LRU 策略,A 待的时间最久远,因此将 A 从缓存中删除,D 加入到最新位置;
  3. B 组件被再次访问时,由于 B 还在缓存中,因此将 B 移到最新的位置;
  4. E 组件被访问时内存不足,C 待的时间最久远,从缓存中删除,E 加入到最新的位置。

5、KeepAlive render

KeepAlive render 中主要做了以下事情:

  1. 由于 KeepAlive 只能有一个子元素,因此调用 getFirstComponentChild 获取到第一个组件节点;
  2. 接着判断当前组件是否符合缓存条件,如果不匹配则直接返回组件 VNode,不走缓存机制;
  3. 如果当前组件需要缓存,则优先在 cache 缓存中查找是否有该组件的缓存实例,有的话通过 componentInstance 的值返回给当前组件,没有则对当前组件进行缓存;
  4. 最后标记该组件的缓存状态并返回组件 VNode
// https://github.com/vuejs/vue/blob/main/src/core/components/keep-alive.ts#L124
export default {
name: 'keep-alive',
// ...
render() {
// 如果 KeepAlive 存在多个子元素,会要求只能有一个子元素被渲染。
// 因此这里获取默认插槽中的第一个组件节点
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 不在 include 中或在 exclude 中则表示不缓存,直接返回组件 vnode,不走缓存机制
if (
(include && (!name || !matches(include, name))) ||
(exclude && name && matches(exclude, name))
) {
return vnode
}

// 如果组件需要缓存 ↓
// cache 用于缓存组件
// keys 存储组件的 key,根据 LRU 策略来调整缓存组件
const { cache, keys } = this

// 获取组件的 key 值
const key =
vnode.key == null
? componentOptions.Ctor.cid +
(componentOptions.tag ? `::${componentOptions.tag}` : '')
: vnode.key

// 命中缓存则查找 cache 中是否有这个 key 值,有则表示该组件有缓存
if (cache[key]) {
// 将缓存的实例设置到当前组件上
vnode.componentInstance = cache[key].componentInstance
// 然后调整 key 的位置将其放到最后
remove(keys, key)
keys.push(key)
} else {
// 如果没有命中缓存,则将当前 VNode 缓存起来,并加入当前组件的 key
this.vnodeToCache = vnode
this.keyToCache = key
}

// @ts-expect-error can vnode.data can be undefined
vnode.data.keepAlive = true
}
// 最后返回组件的 VNode,也说明了 KeepAlive 渲染的是包裹的子组件
return vnode || (slot && slot[0])
}
}

三、总结

KeepAlive 用于缓存组件被销毁前的状态。

KeepAlive 缓存的组件移除时,会触发 deactivated 生命周期钩子,当组件再次渲染时将直接从 activated 生命周期钩子开始执行。这是由于 KeepAlive 缓存的组件再次渲染时不是执行 mount 操作,而是执行 prepatch,prepatch 只做了更新子组件的操作,相当于从缓存中取出了当前组件实例。

另外,KeepAlive 是抽象组件,只对包裹的子组件做处理,不建立父子关系;

KeepAlive 的实现原理是通过 cachekeys 两个变量来缓存实例,同时根据 LRU 策略来保证缓存个数不超出限制,最后标记该组件的缓存状态并返回组件 VNode。