KeepAlive组件实现原理
KeepAlive 一词借鉴于 HTTP 协议。在 HTTP 协议中,KeepAlive又称 HTTP 持久连接(HTTP persistent connection),其作用是允许多个请求或响应共用一个 TCP 连接。在没有 KeepAlive 的情况下,一个HTTP 连接会在每次请求/响应结束后关闭,当下一次请求发生时,会建立一个新的 HTTP 连接。频繁地销毁、创建 HTTP 连接会带来额外的性能开销,KeepAlive 也许就是为了解决这个问题而生的。
默认情况下,如果一个组件实例在被替换后就会被销毁掉,这样的话,相当于会丢失组件实例所有已变化的状态;当在被显示时,相当于重置所有的状态,创建一个新实例
参数:
props: {
include: [String, RegExp, Array],
exclude: [String, RegExp, Array],
max: [String, Number],
},
-
<KeepAlive>默认会缓存内部所有的组件实例,当然可以通过include(包含)和exclude(排除)来设置缓存行为,此外,还可以设置max来限制最大缓存的组件实例数。 -
<KeepAlive>会根据组件的name选项进行匹配,如果需要选择性的缓存组件、需要声明一个name,在vue3.3+中可以通过defineOptions设置,或者根据组件文件名称进行自动生成 -
如果设置了一个
max,那么类似于一个LRU,如果缓存的实例数量即将超过max,那么最久没有被使用的(访问)组件实例将会被销毁,以腾出新的空间来自百度百科的LRU:LRU是Least Recently Used的缩写,即最近最少使用,是一种常用的页面置换算法,选择最近最久未使用的页面予以淘汰。该算法赋予每个页面一个访问字段,用来记录一个页面自上次被访问以来所经历的时间 t,当须淘汰一个页面时,选择现有页面中其 t 值最大的,即最近最少(也就是最久没被访问使用)使用的页面予以淘汰。
生命周期
- 当一个组件实例从DOM上被移除时,会触发相应的卸载钩子(如
onBeforeUnmount、onUnmounted);但在被<KeepAlive>缓存的组件实例,并不会触发这些生命周期钩子(换句话说就是,并不会被销毁或者卸载),而是变为“不活跃状态”,当再次被插入到DOM时,将会被重新激活。 - 一个被缓存持续存在的组件,通过
onActivated()和onDeactivated()注册相应的生命周期钩子 onActivated()会在组件首次挂载时、从缓存重新激活时会被调用onDeactivated()会在组件销毁时、从DOM移到缓存(变为不活跃状态)调用
当一个被 <KeepAlive> 包裹的组件(根组件)被激活时,它内部的所有后代组件也会触发 activated 钩子;同样,当这个根组件被停用时,它内部的所有后代组件也会触发 deactivated 钩子。
示例
创建两个子组件A.vue和``B.vue`,一个包含A和B的父组件
// A.vue
<script setup lang="ts">
import {onActivated, onDeactivated, ref} from "vue";
import {chalkLog} from "./log.ts";
onActivated(()=>{
chalkLog('子组件A缓存被激活')
})
onDeactivated(()=>{
chalkLog('子组件A缓存被销毁')
})
const aText = ref('')
</script>
<template>
<el-input placeholder="A组件" v-model="aText"></el-input>
</template>
<style scoped lang="scss">
</style>
// B.vue
<script setup lang="ts">
import {onActivated, onDeactivated, ref} from "vue";
import {chalkLog} from "./log.ts";
onActivated(()=>{
chalkLog('子组件B缓存被激活', true)
})
onDeactivated(()=>{
chalkLog('子组件B缓存被销毁', true)
})
const bText = ref('')
</script>
<template>
<el-input placeholder="B组件" v-model="bText"></el-input>
</template>
<style scoped lang="scss">
</style>
// App.vue
<script setup lang="ts">
import ChildrenA from "./A.vue";
import ChildrenB from "./B.vue";
import {ref} from "vue";
const toggleCom = ref('A')
const handleToggle = ()=> {
toggleCom.value = toggleCom.value ==='A' ? 'B': 'A'
}
</script>
<template>
<el-button @click="handleToggle">切换{{toggleCom==='A' ? 'B': 'A'}}</el-button>
<keep-alive>
<component :is="toggleCom==='A' ?ChildrenA : ChildrenB">
</component>
</keep-alive>
</template>
<style scoped lang="scss">
</style>
-
可以看到,使用了缓存,组件动态切换时,依然保持原有的状态
-
首次挂载组件时,会执行
onActivated()钩子或者从缓存中挂起(激活)时,也会执行该钩子 -
从DOM组件移除到缓存时,会执行
onDeactivated()钩子 -
如果没有设置组件名称
name时,会使用组件文件名称,即A和B -
上面的缓存相当于
<keep-alive include="A,B"> <component :is="toggleCom==='A' ?ChildrenA : ChildrenB"> </component> </keep-alive> // 等价于 <keep-alive include="A,B"> <children-a v-if="toggleCom==='A'"></children-a> <children-b v-else></children-b> // 如果使用v-if,比如要有一个 v-else,即确保要有一个组件被渲染 </keep-alive> -
如果在组件中,通过
defineOptions设置了名称name,那么<KeepAlive>应该使用该name,否则不会生效(该过程可以自行测试) -
此外,如果在
<KeepAlive>中使用v-if的话,需要确保有且仅有一个子组件被渲染,否则将会报错:Vue: <KeepAlive> expects exactly one child component(只需要一个子组件)(该过程可以自行测试)如,这样是错误的:
// 错误, Vue: <KeepAlive> expects exactly one child component. <keep-alive include="A,B"> <children-a v-if="toggleCom==='A'"></children-a> <children-b v-if="toggleCom==='B'"f></children-b> </keep-alive>编译器警告
原理浅析
首先定位到该组件的源码位置:runtime-core/src/components/KeepAlive.ts
const KeepAliveImpl: ComponentOptions = {
name: `KeepAlive`,
// 用于渲染器内部特殊处理的标记
// 直接在渲染器中检查KeepAlive,因为直接导入它,可以防止它被树摇动。
__isKeepAlive: true,
// 参数
props: {
include: [String, RegExp, Array],
exclude: [String, RegExp, Array],
max: [String, Number],
},
setup(props: KeepAliveProps, { slots }: SetupContext) {
const instance = getCurrentInstance()!
// KeepAlive通过以下方式与实例化的渲染器通信
// ctx,渲染器在其内部传递,
// 和KeepAlive实例公开已激活的激活实现。
// 这样做的全部目的是避免直接在渲染器,以方便树摇。
const sharedContext = instance.ctx as KeepAliveContext
// 省略部分代码。。。。。
// 缓存
const cache: Cache = new Map()
// 缓存的键、一般按道理应该是name
const keys: Keys = new Set()
// 当前激活的节点
let current: VNode | null = null
// 省略部分代码。。。。。
const parentSuspense = instance.suspense
const {
renderer: {
p: patch,
m: move,
um: _unmount,
o: { createElement },
},
} = sharedContext
// 将节点缓存的容器,猜测出,缓存的实现主要通过将节点隐藏到另一个容器中,需要激活时,再从该容器提取出来
const storageContainer = createElement('div')
sharedContext.activate = (
vnode,
container,
anchor,
namespace,
optimized,
) => {
// 拿到组件实例
const instance = vnode.component!
// 将缓存组件移动到容器中
move(vnode, container, anchor, MoveType.ENTER, parentSuspense)
// 省略部分代码。。。。。
}
// 渲染后缓存子树
let pendingCacheKey: CacheKey | null = null
const cacheSubtree = () => {
// fix #1621, the pendingCacheKey could be 0
if (pendingCacheKey != null) {
// if KeepAlive child is a Suspense, it needs to be cached after Suspense resolves
// avoid caching vnode that not been mounted
if (isSuspense(instance.subTree.type)) {
queuePostRenderEffect(() => {
cache.set(pendingCacheKey!, getInnerChild(instance.subTree))
}, instance.subTree.suspense)
} else {
cache.set(pendingCacheKey, getInnerChild(instance.subTree))
}
}
}
// 在组件挂载或者更新时,缓存子树节点
onMounted(cacheSubtree)
onUpdated(cacheSubtree)
// 组件被卸载之前,清理缓存
onBeforeUnmount(() => {
cache.forEach(cached => {
const { subTree, suspense } = instance
const vnode = getInnerChild(subTree)
if (cached.type === vnode.type && cached.key === vnode.key) {
// current instance will be unmounted as part of keep-alive's unmount
resetShapeFlag(vnode)
// but invoke its deactivated hook here
const da = vnode.component!.da
da && queuePostRenderEffect(da, suspense)
return
}
// 清理掉缓存
unmount(cached)
})
})
return () => {
pendingCacheKey = null
// 如果 没有默认插槽。即子节点,直接返回空
if (!slots.default) {
return null
}
// 拿到子节点内容
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
} else if (
!isVNode(rawVNode) ||
(!(rawVNode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) &&
!(rawVNode.shapeFlag & ShapeFlags.SUSPENSE))
) {
current = null
return rawVNode
}
let vnode = getInnerChild(rawVNode)
const comp = vnode.type as ConcreteComponent
// 根据组件,拿到具备缓存的组件名称
// 对于异步组件,名称检查应基于其加载的内部组件(如果可用)
const name = getComponentName(
isAsyncWrapper(vnode)
? (vnode.type as ComponentOptions).__asyncResolved || {}
: comp,
)
const { include, exclude, max } = props
// matches 应该是一个根据include中name排除掉缓存的组件
if (
(include && (!name || !matches(include, name))) ||
(exclude && name && matches(exclude, name))
) {
current = vnode
return rawVNode
}
// 然后,根据组件名称,也就是提到的基于文件名称或者组件名称的缓存key
// 拿到具备缓存条件的组件
const key = vnode.key == null ? comp : vnode.key
const cachedVNode = cache.get(key)
// 克隆vnode,防止原始的节点被修改
if (vnode.el) {
vnode = cloneVNode(vnode)
if (rawVNode.shapeFlag & ShapeFlags.SUSPENSE) {
rawVNode.ssContent = vnode
}
}
// 省略代码。。。
// 如果有缓存节点
if (cachedVNode) {
// 将缓存的节点复制并覆盖当前的节点
vnode.el = cachedVNode.el
vnode.component = cachedVNode.component
// 如果是动画组件
if (vnode.transition) {
// 递归更新subTree上的转换钩子
setTransitionHooks(vnode, vnode.transition!)
}
// 避免将vnode作为新挂载
vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
// 删除旧的,确保当前的组件key为最新的
keys.delete(key)
keys.add(key)
} else {
// 如果没有缓存,直接添加
keys.add(key)
// 如果设置了max,会裁剪掉最久没有使用的缓存实例
if (max && keys.size > parseInt(max as string, 10)) {
pruneCacheEntry(keys.values().next().value)
}
}
// 避免卸载vnode
vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
// 将需要激活的覆盖当前的节点
current = vnode
return isSuspense(rawVNode.type) ? rawVNode : vnode
}
},
}
- 简单来说,
KeepAlive组件是一个抽象的组件,普通的组件相比,KeepAlive组件本身并不会渲染额外的内容,它的渲染函数最终返回需要被缓存的组件或者原始组件(exclude排除的组件) KeepAlive内部会通过onMounted和onUpdated对第一个子节点进行缓存- 在卸载之前
onBeforeUnmount钩子时,会清理掉缓存节点 - 如果组件的 vnode 对象中存在 __isKeepAlive标识,则渲染器不会重新挂载它,而是会通过
sharedContext.activate函数来激活它 - 如设置了
max,超过该值会裁剪掉最久没有使用的缓存实例 - 缓存时,组件并不会真的被卸载,而会被移动到一个隐藏容器中。当重新“挂载”该组件时,它也不会被真的挂载,而会被从隐藏容器中取出,再“放回”原来的容器中(即
KeepAlive),因此会触发相应的钩子(onActivated、onDeactivated)
总结
<KeepAlive> 用于缓存组件实例,提升应用性能,避免频繁销毁和重建组件实例
-
参数配置:
include:指定哪些组件需要被缓存。exclude:指定哪些组件不需要被缓存。max:设置最大缓存的组件实例数,超出时会根据 LRU(最近最少使用)策略裁剪缓存。
-
生命周期钩子:
onActivated:组件被激活时调用(首次挂载或者从缓存中恢复)。onDeactivated:组件从 DOM 移除到缓存时调用。
-
缓存机制:
<KeepAlive>通过Map数据结构来缓存组件实例。- 缓存的键通常是组件的
name或文件名,此外如果在组件中,通过defineOptions设置了名称name,那么<KeepAlive>应该使用该name,否则不会生效 - 当组件被移除时,并不会是真正的被销毁,而是被移动到一个隐藏的容器中;当组件需要重新激活时,从隐藏容器中取出并重新插入到 DOM 中。
-
渲染逻辑:
-
<KeepAlive>的渲染函数会处理其子节点,确保有且仅有一个子组件被渲染。 -
如果子组件符合缓存条件(不在
exclude列表中且在include列表中),则会进行缓存。 -
缓存的组件实例会通过
cloneVNode克隆并覆盖当前的节点,避免直接修改原始节点。 -
组件实例
onMounted和onUpdated钩子在组件挂载和更新时缓存子节点。 -
使用
onBeforeUnmount钩子在组件卸载前清理缓存。
-