我正在参与掘金创作者训练营第4期,点击了解活动详情,一起学习吧!
前言
在我们日常开发中,经常会用到缓存路由或者组件的功能,如需要在两个组件之间来回切换;又比如从一个列表页跳转到详情页,在返回的时候,仍然能保持列表页的上一次操作状态(分页、搜索条件等等),这时候我们就可以用到keep-alive
组件。
KeepAlive
组件的设计思想其实起源于 HTTP 协议中的持久连接 KeepAlive:为了避免频繁地创建、销毁HTTP 连接带来的额外性能开销。在 vue 中KeepAlive
的作用就是用来缓存组件,不但能避免频繁的创建销毁组件,还能够用来记忆组件的状态(一般用于缓存路由)。
基本使用
我们分别定义组件test-a
和test-b
,为方便书写,下文简称为 a 组件和 b 组件:
test-a
<template>
<div @click="increment()">i am test a, number: {{ number }}</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
const number = ref(0)
function increment() {
number.value++
}
</script>
这是一个简单的累加器组件,点击文本部分就能让number
加一
test-b
<template>
<div>i am test b</div>
</template>
然后在index.vue
中引入这两个文件:
<script lang="ts" setup>
import { ref } from 'vue'
import TestA from '../components/test-a.vue'
import TestB from '../components/test-b.vue'
const flag = ref(true)
</script>
<template>
<keep-alive>
<test-a v-if="flag"></test-a>
<test-b v-else></test-b>
</keep-alive>
<button @click="flag = !flag">切换</button>
</template>
注意:
KeepAlive
只能缓存组件类型的节点,非组件会被直接渲染。且要保证子组件同时只能有一个存在。
v-if
的作用大家应该都很清楚:会将值为false
的组件从dom 树中完全移除
我们先点击test-a
两下,让number
的值变为2,点击切换按钮再切换回来,可以发现test-a
中的number
并不会随着切换而清零。
打开 vue devTools 可以很清楚的看到,在我们从组件 a 切换到组件 b 之后,组件 a 并没有被销毁,而是打上了一个
inactive
标签,反之亦然:
这就是KeepAlive
的作用了:缓存组件的全部状态,避免重复创建和销毁
实现原理
KeepAlive
实现原理其实很简单:
在我们卸载keep-alive
包裹的 a 组件之前,会将 a 组件从原来的位置移动到一个隐藏容器里,等到需要被再次挂载的时候,就从隐藏容器里移动到原来的位置。如下图所示:
源码分析
KeepAlive 源码地址,注:本文是基于 latest (2022/2/27) 代码,版本v3.2.31
我把源码的主要流程梳理一下,可以分成六个阶段:
- 获取渲染器方法
- 创建隐藏容器
- 给共享上下文对象添加
activate
和deactivate
钩子 - 判断子组件是否满足缓存要求
- 获取缓存内容/添加缓存内容
- 渲染组件
0.获取渲染器方法
const instance = getCurrentInstance()
const parentSuspense = instance.suspense
const sharedContext = instance.ctx
const { renderer: { p: patch, m: move, um: _unmount, o: { createElement } } } = sharedContext
KeepAlive 组件是通过渲染器实例的上下文对象instance.ctx
与渲染器进行通信,这里我们主要是获取更新patch
、移动move
、卸载_unmount
和创造元素createElement
四个方法
1. 创建隐藏容器
接着用从渲染器拿到的createElement
方法创建一个隐藏容器
const storageContainer = createElement('div')
2. 给共享上下文对象添加activate
和deactivate
钩子
activate
和deactivate
这两个钩子,是被KeepAlive
缓存的组件中特有的函数,分别会在激活和停用组件的时候被触发,避免重复调用mountComponent
挂载缓存组件和unmount
方法卸载缓存组件
activate
sharedContext.activate = (vnode, container, anchor, isSVG, optimized) => {
const instance = vnode.component
move(vnode, container, anchor, 0 /* ENTER */, parentSuspense)
patch(instance.vnode, vnode, container, anchor, instance, parentSuspense, isSVG, vnode.slotScopeIds, optimized)
queuePostRenderEffect(() => {
instance.isDeactivated = false
if (instance.a) {
invokeArrayFns(instance.a)
}
const vnodeHook = vnode.props && vnode.props.onVnodeMounted
if (vnodeHook) {
invokeVNodeHook(vnodeHook, instance.parent, vnode)
}
}, parentSuspense)
}
activate
的作用就是将被缓存的组件节点从隐藏节点移动会原来的容器当中,并在渲染队列中将组件实例的isDeactivated
属性标记为 false。在 move 函数后面,调用了更新方法 patch:
const patch(n1, n2, container, achor) {
if (shapeFlag & ShapeFlags.COMPONENT) {
if (n1 == null) {
if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
parentComponent.ctx.activate(n2, container, anchor, parentComponent, parentSuspense)
} else {
// 挂载组件
mountComponent(n2, container, anchor, parentComponent, parentSuspense)
}
}
}
}
patch 更新节点过程中,如果节点类型中存在COMPONENT_KEPT_ALIVE
标识,则渲染器不会重新挂载它,而是调用activate
来激活它本身
deactivate
sharedContext.deactivate = (vnode) => {
const instance = vnode.component
move(vnode, storageContainer, null, 1 /* LEAVE */, parentSuspense)
queuePostRenderEffect(() => {
if (instance.da) {
invokeArrayFns(instance.da)
}
const vnodeHook = vnode.props && vnode.props.onVnodeUnmounted
if (vnodeHook) {
invokeVNodeHook(vnodeHook, instance.parent, vnode)
}
instance.isDeactivated = true
}, parentSuspense)
}
当deactivate
被触发时,会将被缓存的组件节点从父容器parentSuspense
的位置移动到隐藏容器storageContainer
中去,并将组件实例的isDeactivated
标记为 true
3. 判断子组件是否满足缓存要求
当且仅当需要被缓存的子节点是组件节点时,KeepAlive才会生效
首先是判断KeepAlive
组件包裹的部分中有没有内容,没有直接返回 null
if (!slots.default) {
return null
}
如果KeepAlive
包裹的子组件超过了一个,则会在生产环境抛出警告并返回子组件列表
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
中的不是组件节点,则返回原生节点
if (
!isVNode(rawVNode) ||
(!(rawVNode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) &&
!(rawVNode.shapeFlag & ShapeFlags.SUSPENSE))
) {
current = null
return rawVNode
}
经过层层筛选后,我们就可以对真正需要被缓存的子组件进行缓存了:
4. 获取/添加缓存内容
先创建一个缓存对象 cache
const cache = new Map()
同时创建一个没有重复值的 keys,它就是专门为 KeepAlive 的缓存设计的,这样每一个子节点都能有一个唯一的 key
const keys = new Set()
再创建一个pendingCacheKey
用于在渲染后缓存子树组件
let pendingCacheKey = null
在挂载节点之前先判断一下缓存中有没有需要被挂载的内容
const key = vnode.key == null ? comp : vnode.key
const cachedVNode = cache.get(key)
同时将 pendingCacheKey 赋值为key,做好为这个实例缓存的准备
pendingCacheKey = key
如果有,说明不需要执行挂载
if (cachedVNode) {
// 继承被缓存的组件实例
vnode.el = cachedVNode.el
vnode.component = cachedVNode.component
// 如果组件使用了transition过渡动画则执行动画
if (vnode.transition) {
setTransitionHooks(vnode, vnode.transition!)
}
// 更改节点的 shapeFlag 类型为COMPONENT_KEPT_ALIVE, 避免节点被当做新节点挂载
vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
// 保证 key 值为最新,这一步主要是为了缓存管理,下文会讲到
keys.delete(key)
keys.add(key)
}
如果没有,将组件的 key 添加到 keys 中
else {
keys.add(key)
// 如果组件中传入了max属性且已缓存的组件数超过了max,就将最旧的缓存实例删除
if (max && keys.size > parseInt(max, 10)) {
pruneCacheEntry(keys.values().next().value)
}
}
这里有个语法:
keys.values().next().value
, 我们使用 new Set()创建的 set 对象底层是一个顺序存储的结构,所以可以通过keys.values().next().value
来或者keys.values().next().value
获取第一个(最早)存储的值
删除最旧缓存实例的函数:
function pruneCacheEntry(key: CacheKey) {
const cached = cache.get(key)
// 如果需要删除的缓存实例不在当前页面存在的实例,就直接通过unmount卸载实例
if (!current || cached.type !== current.type) {
unmount(cached)
// 如果删除的是当前页面存在的实例,那么这个实例不应该再被缓存
// 但是我们也不该将它删除,所以我们删除该实例ShapeFlag中的COMPONENT_KEPT_ALIVE
} else if (current) {
resetShapeFlag(current) // current.shapeFlag -= ShapeFlags.COMPONENT_KEPT_ALIVE
}
cache.delete(key)
keys.delete(key)
}
还记得上面 pendingCacheKey 吗?他保存了需要被缓存的组件实例,在生命周期的onMounted
阶段将需要缓存的组件实例存到 cache 当中:
const cacheSubtree = () => {
if (pendingCacheKey != null) {
cache.set(pendingCacheKey, getInnerChild(instance.subTree))
}
}
onMounted(cacheSubtree)
// onUpdated触发时也会添加一次缓存
onUpdated(cacheSubtree)
5. 渲染节点
最后,添加节点类型,然后渲染KeepAlive
中的第一个子组件
vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
current = vnode
// 可以往上翻翻,这个是第一个子组件const rawVNode = children[0]
return rawVNode
所以 KeepAlive
可以看作是一个虚拟组件,因为它并没有真实存在 dom 树中,返回的是它的第一个子组件实例。
缓存原理
默认情况下,所有使用KeepAlive
包含的子组件都会被缓存,当缓存组件过多时,无可避免的会出现性能问题。我们可以使用max
属性来指定最大缓存数量。
还记得我们上面提到过的,在判断已有缓存实例存在时,将 key 删了有加回来的操作吗?
keys.delete(key)
keys.add(key)
还有当组件中传入了max属性且已缓存的组件数超过了max,就将最旧的缓存实例删除
if (max && keys.size > parseInt(max, 10)) {
pruneCacheEntry(keys.values().next().value)
}
这就是KeepAlive
用到的缓存淘汰算法:LRU(Least Recently Used)
它的原理比较简单:就是设定一个有限的容量往里面塞缓存,当容量满了就将最早塞进去的缓存删除,给最新的缓存腾位置。
在KeepAlive
中具体的实现是这样的,流程图如下:
include & exclude
include
用于显示配置应该被缓存的组件exclude
用于显示配置不应该被缓存的组件
当我们配置了这两个属性后,它会在生成缓存实例之前进行判断:
if (
(include && (!name || !matches(include, name))) ||
(exclude && name && matches(exclude, name))
) {
current = vnode
return rawVNode
}
当组件 name
与include
的值/正则不符合,或者与exclude
值/正则匹配时,直接返回原生节点
参考
- 《Vue.js 设计与实现》【霍春阳著】
- KeepAlive源码 core/KeepAlive.ts at main · vuejs/core
- 渲染器源码 core/renderer.ts at main · vuejs/core