Vue3 源码解读-KeepAlive 组件实现原理
欢迎关注公众号:《前端 Talkking》
1、前言
在 Vuejs 中,内置了 KeepAlive
组件用于缓存组件,可以避免组件的销毁/重建,提高性能。假设页面有一组 Tab
组件,如下代码所示:
<template>
<Tab v-if="currentTab === 1">...</Tab>
<Tab v-if="currentTab === 2">...</Tab>
<Tab v-if="currentTab === 3">...</Tab>
</template>
可以看到,根据变量 currentTab
的不同,会渲染不同的 Tab
组件。当用户频繁的切换 Tab
时,会导致不停地卸载并重建对应的 Tab
组件。为了避免因此产生的性能开销,可以使用 KeepAlive
组件解决这个问题,如下代码所示:
<template>
<KeepAlive>
<Tab v-if="currentTab === 1">...</Tab>
<Tab v-if="currentTab === 2">...</Tab>
<Tab v-if="currentTab === 3">...</Tab>
</KeepAlive>
</template>
这样,无论用户怎么切换 Tab
组件,都不会发生频繁的创建和销毁,因此会极大地优化对用户操作的响应,尤其在大组件场景下,优势会更加明显。
2、KeepAlive 组件-源码实现
2.1 原理
KeepAlive
组件的本质是缓存管理以及特殊的挂载/卸载逻辑。被 KeepAlive
包裹的组件在卸载的时候并不是真正的卸载,而是将该组件搬运到一个隐藏的容器中,实现假卸载,从而使得组件可以维持当前状态。而当挂载的时候,会讲它从隐藏容器中搬运到原容器。
KeepAlive
组件提供了 activate
和 deactivate
两个生命周期函数来实现挂载和卸载的逻辑,如下图所示:
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) {
// 省略部分代码
// 返回一个函数,该函数将会直接作为组件的render函数
return () => {
// 省略部分代码
}
}
}
从以上代码中我们可以得知,KeepAlive
组件上有 name
、__isKeepAlive
、props
、setup
等属性,它们的作用分别是:
- name:
KeepAlive
组件名称; - _isKeepAlive:
KeepAlive
组件标识; - props:
KeepAlive
组件属性; - setup:
KeepAlive
组件渲染render
函数。
2.2 挂载-activate
在挂载组件的时候会调用 processComponent
函数,其源码实现如下:
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
) => {
n2.slotScopeIds = slotScopeIds
// 挂载组件
if (n1 == null) {
// 判断当前要挂载的组件是否是KeepAlive组件
if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
// 激活组件,即将隐藏容器中移动到原容器中
;(parentComponent!.ctx as KeepAliveContext).activate(
n2,
container,
anchor,
isSVG,
optimized
)
} else {
// 不是KeepAlive组件,调用mountComponent挂载组件
mountComponent(
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
optimized
)
}
} else {
// 更新组件
updateComponent(n1, n2, optimized)
}
}
在该函数中,挂载的时候首先判断了 shapeFlag
的值,如果挂载的组件是 KeepAlive
组件,则调用 activate
函数激活组件,否则调用 mountComponent
函数挂载组件。KeepAlive
组件中 activate
源码实现如下:
// 该函数用于激活组件
sharedContext.activate = (vnode, container, anchor, isSVG, optimized) => {
// 组件实例
const instance = vnode.component!
// 将组件从隐藏容器中移动到原容器中(即页面中)
move(vnode, container, anchor, MoveType.ENTER, parentSuspense)
// in case props have changed
// props可能会发生变化,因此需要执行patch过程
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)
if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
// Update components tree
devtoolsComponentAdded(instance)
}
}
根据上面的源码我们得知,KeepAlive
组件挂载时候是调用 move
方法,将组件从隐藏容器中移动到原页面中。由于重新挂载时,props
可能会发生变化,因此需要重新执行 patch
过程。
2.3 卸载-deactivate
卸载组件的时候会调用 unmount
方法,在该方法中判断了 shapeFlag
的值,如果卸载的组件是 KeepAlive
组件,则调用 deactivate
方法将组件搬运到隐藏容器中,然后直接返回,否则执行的是卸载组件的逻辑,将组件真正的卸载掉。
const unmount: UnmountFn = (
vnode,
parentComponent,
parentSuspense,
doRemove = false,
optimized = false
) => {
const {
type,
props,
ref,
children,
dynamicChildren,
shapeFlag,
patchFlag,
dirs
} = vnode
// unset ref
if (ref != null) {
setRef(ref, null, parentSuspense, vnode, true)
}
// 判断当前要挂载的组件是否是KeepAlive组件
if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
// 调用KeepAlive组件的deactivate方法使组件失活,即将组件搬运到一个隐藏的容器中
;(parentComponent!.ctx as KeepAliveContext).deactivate(vnode)
return
}
// 其他卸载组件的处理逻辑
}
接着我们来看deactive 函数源码实现:
// 将组件移动到隐藏容器中
sharedContext.deactivate = (vnode: VNode) => {
// 组件实例
const instance = vnode.component!
// 将组件移动到隐藏容器中
move(vnode, storageContainer, null, MoveType.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)
if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
// Update components tree
devtoolsComponentAdded(instance)
}
}
在该方法中,调用 move
方法,将组件搬运到一个隐藏容器中。
2.4 include 和 exclude
默认情况下,KeepAlive
组件会对所有的“内部组件"进行缓存,为了给用户提供自定义的缓存规则,KeepAlive
组件提供了 include
和 exclude
这两个 props
,用户可以自定义哪些组件需要被 KeepAlive
,哪些组件不需要被 KeepAlive
。
KeepAlive
组件的 props
定义如下:
const KeepAliveImpl: ComponentOptions = {
name: `KeepAlive`,
// KeepAlive独有标识
__isKeepAlive: true,
props: {
// 配置了该属性,那么只有名称匹配的组件会被缓存
include: [String, RegExp, Array],
// 配置了该属性,那么任何名称匹配的组件都不会被缓存
exclude: [String, RegExp, Array],
// 最多可以缓存多少组件实例
max: [String, Number]
},
}
在 KeepAlive
组件挂载时,它会根据“内部组件”的名称(即 name
选项)进行匹配,如下代码所示:
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) {
// 省略部分代码
// 返回一个函数,该函数将会直接作为组件的render函数
return () => {
// 省略部分代码
// 获取用户传递的include、exclude、max
const { include, exclude, max } = props
// 如果name没有被include匹配或者被exclude匹配
if (
(include && (!name || !matches(include, name))) ||
(exclude && name && matches(exclude, name))
) {
// 则直接渲染内部组件,不对其进行后续的缓存操作,将当前渲染的属性存储到current上
current = vnode
return rawVNode
}
// 省略部分代码
current = vnode
return isSuspense(rawVNode.type) ? rawVNode : vnode
}
}
}
function matches(pattern: MatchPattern, name: string): boolean {
if (isArray(pattern)) {
// 如果是数组,则遍历pattern,递归调用matches,判断是否包含当前组件
return pattern.some((p: string | RegExp) => matches(p, name))
} else if (isString(pattern)) {
// 如果是字符串,则分割字符串,判断pattern是否包含当前组件
return pattern.split(',').includes(name)
} else if (isRegExp(pattern)) {
// 如果是正则,则使用正则匹配判断是否包含当前组件
return pattern.test(name)
}
/* istanbul ignore next */
return false
}
根据上面的源码,会根据用户传入的 include
和 exclude
对“内部组件”名称匹配,如果 name
没有被匹配到则直接渲染“内部组件”,否则需要缓存组件。
2.5 缓存管理
假设有如下模版内容:
<keep-alive>
<h1 v-if="flag">h1</h1>
<h2 v-else>h2</h2>
</keep-alive>
借助Vue SFC Playground平台,编译后的代码如下:
import { unref as _unref, openBlock as _openBlock, createElementBlock as _createElementBlock, createCommentVNode as _createCommentVNode, KeepAlive as _KeepAlive, createBlock as _createBlock } from "vue"
const _hoisted_1 = { key: 0 }
const _hoisted_2 = { key: 1 }
import { ref } from 'vue'
const __sfc__ = {
__name: 'App',
setup(__props) {
const msg = ref('Hello World!')
let flag = true
return (_ctx, _cache) => {
return (_openBlock(), _createBlock(_KeepAlive, null, [
(_unref(flag))
? (_openBlock(), _createElementBlock("h1", _hoisted_1, "h1"))
: (_openBlock(), _createElementBlock("h2", _hoisted_2, "h2"))
], 1024 /* DYNAMIC_SLOTS */))
}
}
}
根据编译后的代码可知,KeepAlive
的子节点创建的时候都添加了一个 key
(_hoisted_1
、_hoisted_2
)。
然后渲染 KeepAlive
组件的时候会对缓存做一定的处理,如下所示:
// 返回一个函数,该函数将会直接作为组件的render函数
return () => {
// 省略部分代码
// 根据vnode的key去缓存中查找是否有缓存的组件
const cachedVNode = cache.get(key)
// clone vnode if it's reused because we are going to mutate it
if (vnode.el) {
// 复制vnode为了复用
vnode = cloneVNode(vnode)
if (rawVNode.shapeFlag & ShapeFlags.SUSPENSE) {
rawVNode.ssContent = vnode
}
}
pendingCacheKey = key
if (cachedVNode) {
// copy over mounted state
vnode.el = cachedVNode.el
// 如果有缓存内容,则说明不应该执行挂载,而应该执行激活集成组件实例
vnode.component = cachedVNode.component
if (vnode.transition) {
// recursively update transition hooks on subTree
setTransitionHooks(vnode, vnode.transition!)
}
// avoid vnode being mounted as fresh
// 将shapeFlag设置为COMPONENT_KEPT_ALIVE,vnode避免挂载为新的
vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
// make this key the freshest
keys.delete(key)
keys.add(key)
} else {
keys.add(key)
// prune oldest entry
// 当缓存梳理超过指定阈值时对缓存进行修剪
if (max && keys.size > parseInt(max as string, 10)) {
pruneCacheEntry(keys.values().next().value)
}
}
// 省略部分代码
}
}
以上代码是 Vue.js 中处理 KeepAlive
组件缓存逻辑的一部分,它允许组件保持状态或避免重新渲染。
-
如果
cachedVNode
存在,表示有缓存的虚拟节点:- 将缓存的 DOM 元素
el
赋值给当前虚拟节点vnode.el
,这样就可以复用 DOM 元素,而不是重新创建。 - 将缓存的组件实例
component
赋值给当前虚拟节点vnode.component
,这样组件状态可以保持不变。 - 如果
vnode
有过渡效果,递归地更新子树的过渡钩子函数。 - 通过设置
vnode.shapeFlag
为COMPONENT_KEPT_ALIVE
,避免将vnode
当作新组件挂载。 - 更新缓存键
key
的位置,将其从当前位置删除后重新添加到keys
集合的末尾,这样可以保持key
是最新的。
- 将缓存的 DOM 元素
-
如果
cachedVNode
不存在,表示没有缓存的虚拟节点:- 将新的
key
添加到keys
集合中。 - 如果缓存的大小超过了设定的最大值
max
,则从缓存中删除最旧的条目。这是通过pruneCacheEntry
函数实现的,它接收最旧条目的key
并执行删除操作(LRU
缓存策略)。
- 将新的
这段代码的目的是优化组件的渲染性能,通过复用组件实例和 DOM 元素来避免不必要的渲染开销。同时,它也管理着缓存的大小,确保不会因为缓存过多而消耗过多的内存。
当 KeepAlive
组件挂载后会执行 onMounted
生命周期函数,组件更新会执行 onUpdated
生命周期函数,设置对应 key
的组件缓存。
// cache sub tree after render
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)
3、总结
KeepAlive
的本质作用是缓存组件,总结如下:
- 卸载不是真正的卸载,是把组件移动到一个隐藏容器中,挂载是从隐藏容器中搬运到原页面中,以提高组件卸载和挂载的性能;
- 用户可以指定
include
和exclude
来指定哪些组件可以被缓存,哪些组件不可以被缓存; - 缓存策略采用的
LRU
策略。
4、参考资料
[1]vue官网
[2]vuejs设计与实现
[3]vue3源码
转载自:https://juejin.cn/post/7345105927358038070