keep-alive基本实现
keep-alive 组件的实现需要渲染器层面的支持。当组件需要卸载的时候,不能真的卸载,否则就无法维持组件当前的状态了。
因此正确的做法是:将需要 keep-alive 的组件搬运到一个隐藏的容器里面,从而实现“假卸载”。
当 keep-alive 的组件需要重新挂载的时候,也是直接从隐藏的容器里面再次搬运到原来的容器。
这个过程其实就对应了组件的两个生命周期:
- activated
- deactivated
一个最基本的 keep-alive 组件,实现起来并不复杂,代码如下:
const KeepAlive = {
// 这是 keepalive 组件独有的属性,用于标识这是一个 keepalive 组件
__isKeepAlive: true,
setup(props, { slots }) {
// 这是一个缓存对象
// key:vnode.type
// value: vnode
const cache = new Map()
// 存储当前 keepalive 组件的实例
const instance = currentInstance;
// 这里从组件实例上面解构出来两个方法,这两个方法实际上是由渲染器注入的
const { move, createElement } = instance.keepAliveCtx;
// 创建隐藏容器
const storageContainer = createElement("div");
// 这两个方法所做的事情,就是将组件从页面和隐藏容器之间进行移动
// 这两个方法在渲染器中会被调用
instance._deActivate = (vnode) => {
move(vnode, storageContainer);
};
instance._activate = (vnode, container, anchor) => {
move(vnode, container, anchor);
};
return () => {
// 获取到默认插槽里面的内容
let rawVNode = slots.default();
// 如果不是对象,说明是非组件的虚拟节点,直接返回
if (typeof rawVNode.type !== "object") {
return rawVNode;
}
// 接下来我们从缓存里面找一下,看当前的组件是否存在于缓存里面
const cachedVNode = cache.get(rawVNode.type);
if (cachedVNode) {
// 缓存中存在
// 如果缓存中存在,直接使用缓存的组件实例
rawVNode.component = cachedVNode.component;
// 并且挂上一个 keptAlive 属性
rawVNode.keptAlive = true;
} else {
// 缓存中不存在
// 那么就添加到缓存里面,方便下次使用
cache.set(rawVNode.type, rawVNode);
}
// 接下来又挂了一个 shouldKeepAlive 属性
rawVNode.shouldKeepAlive = true;
// 将 keepalive 组件实例也添加到 vnode 上面,后面在渲染器中有用
rawVNode.keepAliveInstance = instance;
return rawVNode;
};
},
};
keep-alive 和渲染器是结合得比较深的,keep-alive 组件本身并不会渲染额外的什么内容,它的渲染函数最终只返回需要被 keep-alive 的组件,这样的组件我们可以称之为“内部组件”。
keep-alive 组件会对这些组件添加一些标记属性,以便渲染器能够根据这些标记属性执行一些特定的逻辑:
- keptAlive:标识内部组件已经被缓存了,这样当内部组件需要重新渲染的时候,渲染器并不会重新挂载它,而是将其激活。
// 渲染器内部代码片段
function patch(n1, n2, container, anchor) {
if (n1 && n1.type !== n2.type) {
unmount(n1);
n1 = null;
}
const { type } = n2;
if (typeof type === "string") {
// 省略部分代码
} else if (type === Text) {
// 省略部分代码
} else if (type === Fragment) {
// 省略部分代码
} else if (typeof type === "object" || typeof type === "function") {
// component
if (!n1) {
// 如果该组件已经被 KeepAlive,则不会重新挂载它,而是会调用_activate 来激活它
if (n2.keptAlive) {
n2.keepAliveInstance._activate(n2, container, anchor);
} else {
mountComponent(n2, container, anchor);
}
} else {
patchComponent(n1, n2, anchor);
}
}
}
- shouldKeepAlive:该属性会被添加到 vnode 上面,这样当渲染器卸载内部组件的时候,不会真正的去卸载,而是将其移动到隐藏的容器里面
// 渲染器代码片段
function unmount(vnode) {
if (vnode.type === Fragment) {
vnode.children.forEach((c) => unmount(c));
return;
} else if (typeof vnode.type === "object") {
// vnode.shouldKeepAlive 是一个布尔值,用来标识该组件是否应该 KeepAlive
if (vnode.shouldKeepAlive) {
// 对于需要被 KeepAlive 的组件,我们不应该真的卸载它,而应调该组件的父组件,
// 即 KeepAlive 组件的 _deActivate 函数使其失活
vnode.keepAliveInstance._deActivate(vnode);
} else {
unmount(vnode.component.subTree);
}
return;
}
const parent = vnode.el.parentNode;
if (parent) {
parent.removeChild(vnode.el);
}
}
- keepAliveInstance:该属性让内部组件持有了 KeepAlive 的组件实例,回头在渲染器中的某些场景下可以通过该属性来访问 KeepAlive 组件实例上面的 _deActivate 以及 _activate。
include和exclude
默认情况下,keep-alive 会对所有的“内部组件”进行缓存。
不过有些时候用户只期望缓存特定的组件,此时可以使用 include 和 exclude.
<keep-alive include="TextInput,Counter">
<component :is="Component" />
</keep-alive>
因此 keep-alive 组件需要定义相关的 props:
const KeepAlive = {
__isKeepAlive: true,
props: {
include: RegExp,
exclude: RegExp
},
setup(props, { slots }) {
// ...
}
};
在进入缓存之前,我们需要对该组件是否匹配进行判断:
const KeepAlive = {
__isKeepAlive: true,
props: {
include: RegExp,
exclude: RegExp,
},
setup(props, { slots }) {
// 省略部分代码...
return () => {
let rawVNode = slots.default();
if (typeof rawVNode.type !== "object") {
return rawVNode;
}
const name = rawVNode.type.name;
if (
name &&
((props.include && !props.include.test(name)) ||
(props.exclude && props.exclude.test(name)))
) {
return rawVNode;
}
// 进入缓存的逻辑...
};
},
};
缓存管理
目前为止的缓存实现如下:
const cachedVNode = cache.get(rawVNode.type);
if (cachedVNode) {
rawVNode.component = cachedVNode.component;
rawVNode.keptAlive = true;
} else {
cache.set(rawVNode.type, rawVNode);
}
目前缓存的设计,只要缓存不存在,总是会设置新的缓存。这会导致缓存不断的增加,极端情况下会占用大量的内容。
为了解决这个问题,keep-alive 组件允许用户设置缓存的阀值,当组件缓存数量超过了指定阀值时会对缓存进行修剪
<keep-alive :max="3">
<component :is="Component" />
</keep-alive>
因此在设计 keep-alive 组件的时候,新增一个 max 的 props:
const KeepAlive = {
__isKeepAlive: true,
props: {
include: RegExp,
exclude: RegExp,
max: Number
},
setup(props, { slots }) {
// ...
}
};
接下来需要有一个能够修剪缓存的方法:
function pruneCacheEntry(key: CacheKey) {
const cached = cache.get(key) as VNode
// 中间逻辑略...
cache.delete(key)
keys.delete(key)
}
然后是更新缓存的队列:
const cachedVNode = cache.get(key)
if (cachedVNode) {
// 其他逻辑略...
// 进入此分支,说明缓存队列里面有,有的话就更新一下顺序
// 保证当前这个在缓存中是最新的
// 先删除,再添加即可
keys.delete(key)
keys.add(key)
} else {
// 说明缓存中没有,说明是全新的,先添加再修剪
keys.add(key)
if (max && keys.size > parseInt(max as string, 10)) {
// 进入此分支,说明当前添加进去的组件缓存已经超过了最大值,进行删除
pruneCacheEntry(keys.values().next().value)
}
}
- keep-alive 核心原理就是将内部组件搬运到隐藏容器,以及从隐藏容器搬运回来。因为没有涉及到真正的卸载,所以组件状态也得以保留。
- keep-alive 和渲染器是结合得比较深的,keep-alive 会给内部组件添加一些特殊的标识,这些标识就是给渲染器的用,回头渲染器在挂载和卸载组件的时候,会根据这些标识执行特定的操作。
- include 和 exclude 核心原理就是对内部组件进行一个匹配操作,匹配上了再进入后面的缓存逻辑
- max:添加之前看一下缓存里面有没有缓存过该组件
- 缓存过:更新到队列最后
- 没有缓存过:加入到缓存里面,但是要看一下有没有超过最大值,超过了就需要进行修剪。