前言
知其然而知其所以然,优秀的工程师不仅要能熟练的使用框架,还要了解其底层是如何实现的。本文主要探究Vue3源码中内置KeepAlive
组件实现原理。
KeepAlive
是一个抽象组件,它并不会渲染成一个真实的 DOM,只会渲染内部包裹的子节点,并且让内部的子组件在切换的时候,不会走一整套递归卸载和挂载 DOM的流程,从而优化了性能。如果你要了解使用方法,官网已经介绍的很详细了,你可以点击查看 Vue3 KeepAlive。
实现原理
KeepAlive
组件在源码实现的是一个对象,其实现主要是组件的渲染、组件缓存的处理、props三个参数的处理和组件卸载过程。
// packages/runtime-core/src/components/KeepAlive.ts
const KeepAliveImpl: ComponentOptions = {
name: `KeepAlive`,
// keep-alive 组件接收的三个参数
props: {
include: [String, RegExp, Array],
exclude: [String, RegExp, Array],
max: [String, Number]
},
...
setup(props: KeepAliveProps, { slots }: SetupContext) {
// 组件卸载逻辑
...
// 返回渲染函数
return () => {
...
// 缓存逻辑处理
// props 参数处理逻辑
// 组件初始化逻辑
}
}
}
上面示例中,当 setup 函数返回一个函数,这个函数就是组件的渲染函数。
组件渲染
直接看下面源码:
// packages/runtime-core/src/components/KeepAlive.ts
const KeepAliveImpl: ComponentOptions = {
name: `KeepAlive`,
...
setup(props: KeepAliveProps, { slots }: SetupContext) {
...
// 返回渲染函数
return () => {
pendingCacheKey = null
if (!slots.default) {
return null
}
// 获取keep-alive 包裹的 children 元素
const children = slots.default()
const rawVNode = children[0]
if (children.length > 1) {
// keep-alive 只渲染单个子节点,大于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))
) {
// 不是vnode节点 或者自定义组件和SUSPENSE 则返回
current = null
return rawVNode
}
...
current = vnode
return rawVNode
}
}
}
从上面可以看出,KeepAlive
渲染的 vnode 就是子节点 children 的第一个元素,它是函数的返回值。因此我们说 KeepAlive
是抽象组件,它本身不渲染成实体节点,而是渲染它的第一个子节点。
缓存处理
KeepAlive
组件缓存的东西是 DOM,因为渲染 DOM 是 patch 递归的过程,也是最损耗性能的。
KeepAlive
组件注入了两个钩子函数,onMounted 和 onUpdated,在这两个钩子函数内部都执行了 cacheSubtree 函数来做缓存:
...
// packages/runtime-core/src/components/KeepAlive.ts
const cacheSubtree = () => {
// fix #1621, the pendingCacheKey could be 0
if (pendingCacheKey != null) {
cache.set(pendingCacheKey, getInnerChild(instance.subTree))
}
}
onMounted(cacheSubtree)
onUpdated(cacheSubtree)
pendingCacheKey 是在 KeepAlive 的 render 函数中才会被赋值,所以 KeepAlive 首次进入 onMounted 钩子函数的时候是不会缓存的。然后 KeepAlive 执行 render 的时候,pendingCacheKey 会被赋值为 vnode.key。
所以当组件切换会触发组件的重新渲染,进而也触发了 KeepAlvie
组件的重新渲染,在组件重新渲染前,会执行 onUpdate 对应的钩子函数,也就再次执行到 cacheSubtree 函数中。
这个时候 pendingCacheKey 对应的是 A 组件 vnode 的 key,instance.subTree 对应的也是 A 组件的渲染子树,所以 KeepAlive 每次在更新前,会缓存前一个组件的渲染子树。
当我们再次切换回原来组件,会再次触发KeepAlvie 组件的重新渲染,当然此时执行 onUpdate 钩子函数缓存的就是 B 组件的渲染子树了。
接着再次执行 KeepAlive 组件的 render 函数,此时就可以从缓存中根据 A 组件的 key 拿到对应的渲染子树 cachedVNode 的了,然后执行如下逻辑:
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!)
}
// 避免 vnode 节点作为新节点被挂载
vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
// 让这个 key 始终新鲜
keys.delete(key)
keys.add(key)
} else {
keys.add(key)
// 删除最久不用的 key
if (max && keys.size > parseInt(max as string, 10)) {
pruneCacheEntry(keys.values().next().value)
}
}
有了缓存的渲染子树后,我们就可以直接拿到它对应的 DOM 以及组件实例 component,赋值给 KeepAlive 的 vnode,并更新 vnode.shapeFlag,以便后续 patch 阶段使用。patch 主要由 processComponent 方法实现:源码如下:
// packages/runtime-core/src/renderer.ts
const processComponent = (n1: VNode | null,n2: VNode, ...) => {
n2.slotScopeIds = slotScopeIds
if (n1 == null) {
// 处理 keep-alive
if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
;(parentComponent!.ctx as KeepAliveContext).activate(
n2,
container,
anchor,
isSVG,
optimized
)
} else {
// 挂载组件
mountComponent(n2,...)
}
} else {
updateComponent(n1, n2, optimized)
}
}
KeepAlive 首次渲染某一个子节点时,和正常的组件节点渲染没有区别,但是有缓存后,由于标记了 shapeFlag,所以在执行processComponent函数时会走到处理 KeepAlive 组件的逻辑中,执行 KeepAlive 组件实例上下文中的 activate 函数,我们来看它的实现:
// 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)
// in case props have changed
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)
}
}
可以看到,由于此时已经能从 vnode.el 中拿到缓存的 DOM 了,所以可以直接调用 move 方法挂载节点,然后执行 patch 方法更新组件,以防止 props 发生变化的情况。接下来,就是通过 queuePostRenderEffect 的方式,在组件渲染完毕后,执行子节点组件定义的 activated 钩子函数。
props 参数处理
KeepAlive 一共支持了三个 Props,分别是 include、exclude 和 max。
// packages/runtime-core/src/components/KeepAlive.ts
props: {
include: [String, RegExp, Array],
exclude: [String, RegExp, Array],
max: [String, Number]
}
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
}
如果子组件名称不匹配 include 的 vnode ,以及子组件名称匹配 exclude 的 vnode 都不应该被缓存,而应该直接返回。
由于 props 是响应式的,在 include 和 exclude props 发生变化的时候也应该有相关的处理逻辑,如下:
// packages/runtime-core/src/components/KeepAlive.ts
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 }
)
监听的逻辑也很简单,当 include 发生变化的时候,从缓存中删除那些 name 不匹配 include 的 vnode 节点;当 exclude 发生变化的时候,从缓存中删除那些 name 匹配 exclude 的 vnode 节点。
除了 include 和 exclude 之外,KeepAlive 组件还支持了 max prop 来控制缓存的最大个数。
// packages/runtime-core/src/components/KeepAlive.ts
if (cachedVNode) {
...
} else {
keys.add(key)
// prune oldest entry
if (max && keys.size > parseInt(max as string, 10)) {
pruneCacheEntry(keys.values().next().value)
}
}
由于新的缓存 key 都是在 keys 的结尾添加的,所以当缓存的个数超过 max 的时候,就从最前面开始删除。
组件卸载
组件的卸载会执行 unmount 方法,其中有一个关于 KeepAlive 组件的逻辑,如下:
// packages/runtime-core/src/renderer.ts
const unmount: UnmountFn = (vnode,parentComponent,parentSuspense,doRemove = false,optimized = false) => {
...
if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
;(parentComponent!.ctx as KeepAliveContext).deactivate(vnode)
return
}
...
}
如果 shapeFlag 满足 KeepAlive 的条件,则执行相应的 deactivate 函数,它的定义如下:
// packages/runtime-core/src/components/KeepAlive.ts
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 方法从 DOM 树中移除该节点,接着通过 queuePostRenderEffect 的方式执行定义的 deactivated 钩子函数。
当 KeepAlive 所在的组件卸载时,由于卸载的递归特性,也会触发 KeepAlive 组件的卸载,在卸载的过程中会执行 onBeforeUnmount 钩子函数,如下:
// packages/runtime-core/src/components/KeepAlive.ts
onBeforeUnmount(() => {
cache.forEach(cached => {
const { subTree, suspense } = instance
const vnode = getInnerChild(subTree)
if (cached.type === vnode.type) {
// 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)
})
})
它会遍历所有缓存的 vnode,并且比对缓存的 vnode 是不是当前 KeepAlive 组件渲染的 vnode。
如果是的话,则执行 resetShapeFlag 方法,它的作用是修改 vnode 的 shapeFlag,不让它再被当作一个 KeepAlive 的 vnode 了,这样就可以走正常的卸载逻辑。接着通过 queuePostRenderEffect 的方式执行子组件的 deactivated 钩子函数。
如果不是,则执行 unmount 方法重置 shapeFlag 以及执行缓存 vnode 的整套卸载流程。
写在最后
最后总结一下KeepAlive
组件的实现原理:
KeepAlive
组件渲染的 vnode 就是子节点 children 的第一个元素,它是函数的返回值。KeepAlive
组件注入了两个钩子函数,onMounted 和 onUpdated,在这两个钩子函数内部都执行了 cacheSubtree 函数来做缓存。而钩子函数早于render 函数执行,所以切换的时候会缓存上一个组件,之后 patch 过程调用 activate 函数直接使用缓存vnode挂载。- KeepAlive 一共支持了三个 Props,分别是 include、exclude 和 max。如果子组件名称不匹配 include 的 vnode ,以及子组件名称匹配 exclude 的 vnode 都不应该被缓存,而应该直接返回。缓存 key 都是在 keys 的结尾添加的,所以当缓存的个数超过 max 的时候,就从最前面开始删除。
- 组件的卸载会执行 unmount 方法,该方法直接调用 KeepAlive 的 deactivate 移除 DOM,当 KeepAlive 所在的组件卸载时,由于卸载的递归特性,也会触发 KeepAlive 组件的卸载,在卸载的过程中会执行 onBeforeUnmount 钩子函数循环卸载节点。
PS:【想要快速搭建自己的前端静态博客,欢迎查阅Vuepress 快速搭建博客--一款你值得拥有的博客主题。】