KeepAlive 组件是 Vue 的内置组件之一,他的功能是在多个组件间动态切换时缓存被移除的组件实例。缓存了的组件,其内部的状态不会被重置。这在动态切换组件,需要保持组件状态的场景下非常有用。
<template>
<!-- 使用 KeepAlive 组件包裹 -->
<KeepAlive>
<Tab v-if="currentTab === 'a'"><com-a /></Tab>
<Tab v-if="currentTab === 'b'"><com-b /></Tab>
</KeepAlive>
</template>
KeepAlive 组件的实现原理就是缓存管理和特殊的卸载/挂载逻辑。
缓存管理借鉴了 最近最少使用的算法思想。当为缓存设置最大容量,也就是设置了 KeepAlive 组件的 max 属性后,当达到缓存的最大容量时,会将最久没有使用到的组件删除。
KeepAlive 包裹的组件的“卸载”不是真的卸载,而是会将该组件搬运到一个隐藏容器中,从而可以使组件保持当前状态。当被 KeepAlive 的组件“挂载”时,渲染器也不会真的挂载他,而是将他从隐藏容器搬运到原容器。
Tips: 本文中的源码均摘自 Vue.js 3.2.45,为了方便理解,会省略与本文主题无关的代码
KeepAlive 组件是 Vue 的内置组件,具体实现在 packages/runtime-core/src/components/KeepAlive.ts
文件中。KeepAlive 组件内部会有自己的独有标识 __isKeepAlive
。Vue 内部会通过这个标识来判断当前虚拟 DOM 是否为 KeepAlive 组件。
// packages/runtime-core/src/components/KeepAlive.ts
const KeepAliveImpl: ComponentOptions = {
name: `KeepAlive`,
// KeepAlive 组件独有的属性,用作标识
__isKeepAlive: true,
}
// packages/runtime-core/src/components/KeepAlive.ts
// 通过 __isKeepAlive 标识判断当前虚拟 DOM 是否为 KeepAlive 组件
export const isKeepAlive = (vnode: VNode): boolean =>
(vnode.type as any).__isKeepAlive
定义 KeepAlive 组件的 props。
include
,如果指定,则只有与include
名称匹配的组件才会被缓存exclude
,如果指定,则任何名称与exclude
匹配的组件都不会被缓存max
,最多可以缓存多少组件实例
// packages/runtime-core/src/components/KeepAlive.ts
const KeepAliveImpl: ComponentOptions = {
// ...
props: {
include: [String, RegExp, Array],
exclude: [String, RegExp, Array],
max: [String, Number]
},
}
定义 KeepAlive 组件的 setup 函数,setup 函数如果返回函数的话,该返回的函数会被作为组件的渲染函数。首先是获取 KeepAlive 组件的实例和 KeepAlive 组件的上下文对象。渲染器通过此上下文对象与 KeepAlive 组件通信,渲染器会暴露一下内部的方法到 KeepAlive 组件的上下文对象中,其中 move 函数用来将一段 DOM 移动到另一个容器中。
// packages/runtime-core/src/components/KeepAlive.ts
const KeepAliveImpl: ComponentOptions = {
// ...
setup(props: KeepAliveProps, { slots }: SetupContext) {
// 当前 KeepAlive 组件的实例
const instance = getCurrentInstance()!
// KeepAlive 组件的上下文对象,渲染器通过此上下文对象与 KeepAlive 组件通信,
// 渲染器会向此上下文对象暴露一些内部方法,其中 move 函数用来将一段 DOM 移动
// 到另一个容器中。
const sharedContext = instance.ctx as KeepAliveContext
}
}
从 KeepAlive 组件的上下文对象中取得渲染器注入的方法:
- patch:更新组件
- move:移动组件
- _unmount:卸载组件
- createElement:创建元素
// packages/runtime-core/src/components/KeepAlive.ts
const KeepAliveImpl: ComponentOptions = {
// ...
setup(props: KeepAliveProps, { slots }: SetupContext) {
// ...
const {
renderer: {
p: patch,
m: move,
um: _unmount,
o: { createElement }
}
} = sharedContext
}
}
定义缓存对象 cache
,用于保存被 KeepAlive 包裹的组件的虚拟 DOM 。定义一个集合(Set 数据结构),用于保存缓存的 key。定义变量 current
,保存当前被 KeepAlive 包裹组件的虚拟 DOM 。
// packages/runtime-core/src/components/KeepAlive.ts
const cache: Cache = new Map()
const keys: Keys = new Set()
let current: VNode | null = null
创建隐藏容器(storageContainer
)
// packages/runtime-core/src/components/KeepAlive.ts
const storageContainer = createElement('div')
在 KeepAlive 组件上下文对象(sharedContext
)上定义 activate
和 deactivate
函数。这两个函数会在渲染器中执行。activate
函数会将组件从隐藏容器中移到原容器中,并调用 patch 函数更新组件,防止因为 props 更新而没有更新组件,并将用户注册的 activated
生命周期函数推入 Post 任务队列。
// packages/runtime-core/src/components/KeepAlive.ts
sharedContext.activate = (vnode, container, anchor, isSVG, optimized) => {
const instance = vnode.component!
// 将组件从隐藏容器中移到原容器中
move(vnode, container, anchor, MoveType.ENTER, parentSuspense)
// 更新组件,因为 props 可能发生变化
patch(
instance.vnode,
vnode,
container,
anchor,
instance,
parentSuspense,
isSVG,
vnode.slotScopeIds,
optimized
)
// 将用户注册的 `activated` 生命周期函数推入 Post 任务队列
queuePostRenderEffect(() => {
instance.isDeactivated = false
if (instance.a) {
// 执行用户注册的 `activated` 生命周期函数
invokeArrayFns(instance.a)
}
}, parentSuspense)
}
deactivate
函数则会将组件移入隐藏容器中,并将用户注册的 deactivated
生命周期函数推入 Post 任务队列。
// packages/runtime-core/src/components/KeepAlive.ts
sharedContext.deactivate = (vnode: VNode) => {
const instance = vnode.component!
// 将组件移到隐藏容器
move(vnode, storageContainer, null, MoveType.LEAVE, parentSuspense)
// 将用户注册的 `deactivated` 生命周期函数推入 Post 任务队列
queuePostRenderEffect(() => {
if (instance.da) {
// 执行用户注册的 `deactivated` 生命周期函数
invokeArrayFns(instance.da)
}
const vnodeHook = vnode.props && vnode.props.onVnodeUnmounted
if (vnodeHook) {
invokeVNodeHook(vnodeHook, instance.parent, vnode)
}
instance.isDeactivated = true
}, parentSuspense)
}
有关 Vue3 Post 任务队列的更多相关信息可见笔者写的另一篇文章:
定义 unmount
函数,用于卸载传入的组件和重置 keep alive 的标识
// packages/runtime-core/src/components/KeepAlive.ts
function unmount(vnode: VNode) {
resetShapeFlag(vnode)
// 卸载组件
_unmount(vnode, instance, parentSuspense, true)
}
resetShapeFlag
函数则是借助位运算,重置 keep alive 的标识。通过取反(~
)操作,再与(&
)操作,将原本为 1 的标识重置为 0 。
// packages/runtime-core/src/components/KeepAlive.ts
function resetShapeFlag(vnode: VNode) {
vnode.shapeFlag &= ~ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
vnode.shapeFlag &= ~ShapeFlags.COMPONENT_KEPT_ALIVE
}
接下来实现 KeepAlive 组件的 include 和 exclude 的 API 。用户可使用这两个 API 设置哪些组件可以被缓存,哪些组件不能被缓存,实现更灵活的缓存控制。
// packages/runtime-core/src/components/KeepAlive.ts
// 删除缓存对象中不匹配名字的组件
function pruneCache(filter?: (name: string) => boolean) {
cache.forEach((vnode, key) => {
// 获取组件名
const name = getComponentName(vnode.type as ConcreteComponent)
if (name && (!filter || !filter(name))) {
// 如果组件名字不存在,且不匹配(filter(name) 为 false),
// 则删除缓存中的该组件
pruneCacheEntry(key)
}
})
}
// 删除缓存对象中指定 key 的组件
function pruneCacheEntry(key: CacheKey) {
const cached = cache.get(key) as VNode
if (!current || cached.type !== current.type) {
// 卸载除当前组件外的指定 key 的缓存组件,
// 因为当前组件正在使用,是不能被卸载的
unmount(cached)
} else if (current) {
// 重置当前组件的 keep alive 标识,使其后续可被正常卸载
resetShapeFlag(current)
}
// 删除指定 key 的缓存
cache.delete(key)
// 删除 keys 集合中的相关 key
keys.delete(key)
}
// 当 include 、exclude 变化时,需要做相关的处理,
// 与 include 匹配的组件名称,则会被缓存,
// 与 exclude 匹配的组件名称,则不会被缓存
watch(
() => [props.include, props.exclude],
([include, exclude]) => {
include && pruneCache(name => matches(include, name))
exclude && pruneCache(name => !matches(exclude, name))
},
// prune post-render after `current` has been updated
{ flush: 'post', deep: true }
)
matches
函数定义了 include 和 exclude 的匹配逻辑。include 、exclude 可以接收字符串、正则、字符串与正则的数组作为参数。
// packages/runtime-core/src/components/KeepAlive.ts
// 判断传入的 name 是否匹配
function matches(pattern: MatchPattern, name: string): boolean {
if (isArray(pattern)) {
return pattern.some((p: string | RegExp) => matches(p, name))
} else if (isString(pattern)) {
return pattern.split(',').includes(name)
} else if (isRegExp(pattern)) {
return pattern.test(name)
}
return false
}
定义 pendingCacheKey
变量,pendingCacheKey
变量会在组件 render 的时候被赋值为组件虚拟 DOM 的 key 。然后在组件 Mounted 和 Updated 阶段用于重新设置组件的缓存。因为被缓存的组件虚拟 DOM 不一定是最新的,当组件挂载或更新时,需要用组件最新的虚拟 DOM 来更新之前缓存起来的数据。
// packages/runtime-core/src/components/KeepAlive.ts
let pendingCacheKey: CacheKey | null = null
const cacheSubtree = () => {
// fix #1621, the pendingCacheKey could be 0
if (pendingCacheKey != null) {
cache.set(pendingCacheKey, getInnerChild(instance.subTree))
}
}
onMounted(cacheSubtree)
onUpdated(cacheSubtree)
subTree
,组件渲染函数返回的虚拟 DOM ,即组件的子树
当 KeepAlive 组件卸载时,要将 KeepAlive 组件下缓存的所有组件都卸载掉。当前组件不能马上卸载,因为正在使用中,则需要重置 keep alive 相关标识,后续会被渲染器卸载。
// packages/runtime-core/src/components/KeepAlive.ts
onBeforeUnmount(() => {
cache.forEach(cached => {
const { subTree, suspense } = instance
const vnode = getInnerChild(subTree)
// 在缓存对象中找到了当前组件
if (cached.type === vnode.type) {
// 重置当前组件的 keep alive 标识,后续该组件由于没有了 keep alive 标识,
// 则会被渲染器卸载
resetShapeFlag(vnode)
// 将用户注册的 deactivated 生命周期函数推入 Post 任务队列
const da = vnode.component!.da
da && queuePostRenderEffect(da, suspense)
return
}
// 卸载除当前组件外的所有缓存的组件
unmount(cached)
})
})
KeepAlive 组件的 setup 钩子返回的是函数,而 setup 钩子返回的函数则会被当做组件的渲染函数。
首先将 pendingCacheKey
重置为 null 。
// packages/runtime-core/src/components/KeepAlive.ts
const KeepAliveImpl: ComponentOptions = {
setup(props: KeepAliveProps, { slots }: SetupContext) {
// ...
// setup 返回的函数会作为组件的渲染函数使用
return () => {
pendingCacheKey = null
}
}
}
如果 KeepAlive 组件没有直接子节点,则直接返回 null 。
// packages/runtime-core/src/components/KeepAlive.ts
if (!slots.default) {
return null
}
如果直接子节点不止 1 个,在 DEV 环境下则会给出警告,并直接返回子组件,不走缓存的逻辑。在生产环境虽会免去警告,但也会直接返回子组件,不走缓存逻辑。 KeepAlive 只能有 1 个活跃的直接子组件在 Vue 的官方文档中也有说明。
// packages/runtime-core/src/components/KeepAlive.ts
const children = slots.default()
const rawVNode = children[0]
if (children.length > 1) {
if (__DEV__) {
warn(`KeepAlive should contain exactly one component child.`)
}
current = null
return children
}
获取 KeepAlive 组件的虚拟 DOM
// packages/runtime-core/src/components/KeepAlive.ts
let vnode = getInnerChild(rawVNode)
获取组件虚拟 DOM 的 type 属性,type 属性表示组件实例的具体类型,可以使用 type 属性得到用户定义组件时为组件设置的名字。
注意 KeepAlive 组件的直接子组件是异步组件的话(isAsyncWrapper(vnode)
为 true),则需要取 type 对象上的 __asyncResolved
属性,因为对于异步组件来说,__asyncResolved
属性存储了异步加载器加载成功后返回的组件。
// packages/runtime-core/src/components/KeepAlive.ts
const comp = vnode.type as ConcreteComponent
const name = getComponentName(
isAsyncWrapper(vnode)
? (vnode.type as ComponentOptions).__asyncResolved || {}
: comp
)
// packages/runtime-core/src/apiAsyncComponent.ts
// 判断传入的虚拟 DOM 是否为 AsyncWrapper
export const isAsyncWrapper = (i: ComponentInternalInstance | VNode): boolean =>
!!(i.type as ComponentOptions).__asyncLoader
有关 Vue3 异步组件更多相关信息可见笔者写的另一篇文章:
从 KeepAlive 组件的 props 中取得 include 、exclude 和 max 。任何与 include 不匹配的组件或任何与 exclude 匹配的组件都不走缓存。
// packages/runtime-core/src/components/KeepAlive.ts
const { include, exclude, max } = props
if (
(include && (!name || !matches(include, name))) ||
(exclude && name && matches(exclude, name))
) {
current = vnode
return rawVNode
}
取得 KeepAlive 直接子组件的虚拟 DOM 中的 key 属性,使用该 key 取得缓存对象(cache
)中的组件缓存。
// packages/runtime-core/src/components/KeepAlive.ts
const key = vnode.key == null ? comp : vnode.key
const cachedVNode = cache.get(key)
将 KeepAlive 直接子组件的虚拟 DOM 中的 key 赋值给 pendingCacheKey
变量。
// packages/runtime-core/src/components/KeepAlive.ts
pendingCacheKey = key
如果在缓存对象中可以找到当前 KeepAlive 直接子组件相关 key 的缓存,则将缓存虚拟 DOM 中的 el
和 component
属性赋值给 KeepAlive 直接子组件虚拟 DOM 的 el
和 component
属性。因为在 Updated 生命周期时,会用最新的虚拟 DOM 更新缓存的内容,因此缓存中的虚拟 DOM 是最新的。
然后从 keys 集合中删除对应的 key ,并将 key 重新 add 进 keys 集合中,这样就保证了最新的 key 位于集合 keys 的最后面。
当缓存的组件实例数超过 max 值时,则删除 keys 集合中第一个 key 。因为新的缓存 key 都是在 keys 的结尾添加的,所以当缓存的个数超过 max 的时候,就从最前面开始删除,符合 最近最少使用的算法思想。
// packages/runtime-core/src/components/KeepAlive.ts
if (cachedVNode) {
// copy over mounted state
vnode.el = cachedVNode.el
vnode.component = cachedVNode.component
// 给虚拟 DOM 打上 COMPONENT_KEPT_ALIVE 标识,
// 防止其走真正的挂载逻辑
vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
// 保证 key 在 keys 集合中最新的位置
keys.delete(key)
keys.add(key)
} else {
keys.add(key)
// 缓存的组件实例数超出了 max 值
if (max && keys.size > parseInt(max as string, 10)) {
// 删除 keys 集合中第一个 key 的组件缓存
pruneCacheEntry(keys.values().next().value)
}
}
// packages/runtime-core/src/components/KeepAlive.ts
// 在 Updated 生命周期中更新缓存,保证缓存对象中的组件虚拟 DOM 是最新的
onUpdated(cacheSubtree)
通过或运算,将组件标识为被 KeepAlive 包裹的组件。下次可通过与(&
) ShapeFlags.COMPONENT_KEPT_ALIVE
是否为 true 来判断是否为被 KeepAlive 包裹的组件。
// packages/runtime-core/src/components/KeepAlive.ts
vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
在渲染器中,通过与(&
)ShapeFlags.COMPONENT_KEPT_ALIVE
是否为 true 来判断该组件是否为被 KeepAlive 包裹的组件,如果是被 KeepAlive 包裹的组件,则不走真正的挂载逻辑。而是执行 activate 函数,将组件实例从隐藏容器移入原容器。
// packages/runtime-core/src/renderer.ts
const processComponent = (
n1: VNode | null,
n2: VNode,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
if (n1 == null) {
if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
// 执行与 KeepAlive 相关的逻辑,将组件实例从隐藏容器移入原容器
;(parentComponent!.ctx as KeepAliveContext).activate(
n2,
container,
anchor,
isSVG,
optimized
)
} else {
// 不是 KeepAlive 包裹的组件,执行组件真正的挂载逻辑
mountComponent()
}
} else {
// 更新组件
}
}
将 KeepAlive 直接子组件的虚拟 DOM 打上 COMPONENT_SHOULD_KEEP_ALIVE 标识。然后返回 KeepAlive 直接子组件的虚拟 DOM 。从这里可以看出,KeepAlive 组件渲染的内容是他的直接子组件。KeepAlive 组件提供的是缓存的作用。
// packages/runtime-core/src/components/KeepAlive.ts
const KeepAliveImpl: ComponentOptions = {
// ...
setup(props: KeepAliveProps, { slots }: SetupContext) {
// ...
return () => {
// ...
vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
current = vnode
return vnode
}
}
}
当渲染器卸载组件的时候,通过与(&
)ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
判断是否为被 KeepAlive 包裹的组件,如果是被 KeepAlive 包裹的组件,则执行 deactivate
函数将组件移入隐藏容器,不走真正的组件卸载逻辑。
// packages/runtime-core/src/renderer.ts
const unmount: UnmountFn = (
vnode,
parentComponent,
parentSuspense,
doRemove = false,
optimized = false
) => {
const {
shapeFlag
} = vnode
if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
// 被 KeepAlive 包裹的组件,执行 deactivate 函数,将组件
// 移入隐藏容器
;(parentComponent!.ctx as KeepAliveContext).deactivate(vnode)
return
}
// 卸载组件
}
总结
KeepAlive 组件的实现原理就是缓存管理和特殊的卸载/挂载逻辑。KeepAlive 组件使用 include 、exclude 和 max 为用户提供更加灵活的缓存控制。
KeepAlive 组件内部会有个隐藏容器,KeepAlive 包裹的组件的卸载不是真正的卸载,而是把组件实例移入缓存容器。KeepAlive 包裹的组件的挂载也不是真正的挂载,而是把组件实例从隐藏容器移入原容器中。