主题
实现 Teleport 与 KeepAlive
本节对标 Vue 3 源码
@vue/runtime-core中的components/Teleport.ts和components/KeepAlive.ts
Part 1: Teleport
Teleport 的作用
Teleport 将子节点渲染到 DOM 树的另一个位置,而不改变组件的逻辑层级关系。典型场景:
- 模态框(Modal):逻辑上属于触发它的组件,但 DOM 上需要渲染到
<body>下避免z-index和overflow问题 - 通知(Notification):渲染到固定的通知容器
- 全屏遮罩(Overlay):渲染到
<body>最顶层
vue
<template>
<button @click="showModal = true">Open Modal</button>
<Teleport to="body">
<div v-if="showModal" class="modal">
<p>I'm rendered at body!</p>
</div>
</Teleport>
</template>对标源码位置:packages/runtime-core/src/components/Teleport.ts
Teleport VNode 结构
Teleport 是一个特殊的 VNode 类型,有自己独立的 ShapeFlag:
ts
// packages/shared/src/shapeFlags.ts
// 使用位运算定义 VNode 的类型标志,每个标志占一个比特位
// 位运算的好处:可以用 | 组合多个标志,用 & 高效检测标志
export const enum ShapeFlags {
ELEMENT = 1, // 普通 HTML 元素
FUNCTIONAL_COMPONENT = 1 << 1, // 函数式组件
STATEFUL_COMPONENT = 1 << 2, // 有状态组件
TEXT_CHILDREN = 1 << 3, // 子节点为文本
ARRAY_CHILDREN = 1 << 4, // 子节点为数组
SLOTS_CHILDREN = 1 << 5, // 子节点为插槽
TELEPORT = 1 << 6, // Teleport 类型的 VNode
SUSPENSE = 1 << 7, // Suspense 类型的 VNode
// ...
}ts
// Teleport 的 VNode
{
type: TeleportImpl,
props: { to: 'body', disabled: false },
children: [/* 子 VNode */],
shapeFlag: ShapeFlags.TELEPORT,
}TeleportImpl 定义
ts
// packages/runtime-core/src/components/Teleport.ts
// TeleportImpl 是 Teleport 的核心实现对象
// 它不是一个标准组件,而是一个具有 process/remove 方法的特殊类型
export const TeleportImpl = {
__isTeleport: true, // 标记,用于在 patch 时识别 Teleport 类型
// process 是 Teleport 的核心方法,处理挂载和更新逻辑
process(
n1: VNode | null, // 旧 VNode(首次挂载时为 null)
n2: VNode, // 新 VNode
container: RendererElement, // 当前容器(Teleport 所在的 DOM 位置)
anchor: RendererNode | null, // 插入锚点
parentComponent: ComponentInternalInstance | null,
internals: RendererInternals, // 渲染器内部方法集合
) {
// 从 internals 解构出渲染器提供的内部操作方法
const {
mc: mountChildren, // 挂载子节点
pc: patchChildren, // 更新子节点
pbc: patchBlockChildren,
o: { insert, querySelector, createText, createComment }, // DOM 操作方法
} = internals
const disabled = isTeleportDisabled(n2.props) // 检查是否被禁用
const { shapeFlag, children } = n2
if (n1 == null) {
// ============ MOUNT(首次挂载)============
// 创建两个注释节点作为占位符,标记 Teleport 在原位置的范围
const placeholder = (n2.el = createComment('teleport start'))
const mainAnchor = (n2.anchor = createComment('teleport end'))
// 在原位置插入占位注释节点,即使内容被传送走,原位置仍有标记
insert(placeholder, container, anchor)
insert(mainAnchor, container, anchor)
// 解析目标容器(通过 CSS 选择器或直接传入 DOM 元素)
const target = (n2.target = resolveTarget(n2.props, querySelector))
// 创建文本节点作为目标容器中的锚点
const targetAnchor = (n2.targetAnchor = createText(''))
if (target) {
insert(targetAnchor, target) // 在目标容器中插入锚点
if (!disabled) {
// 正常模式:将子节点挂载到目标容器(传送)
mountChildren(children, target, targetAnchor, parentComponent)
} else {
// disabled 模式:将子节点挂载到原位置(不传送)
mountChildren(children, container, mainAnchor, parentComponent)
}
}
} else {
// ============ UPDATE(更新)============
// 复用旧 VNode 的 DOM 引用和锚点
n2.el = n1.el
const mainAnchor = (n2.anchor = n1.anchor)!
const target = (n2.target = n1.target)!
const targetAnchor = (n2.targetAnchor = n1.targetAnchor)!
// 判断旧状态是否为 disabled,决定子节点当前在哪个容器中
const wasDisabled = isTeleportDisabled(n1.props)
const currentContainer = wasDisabled ? container : target
const currentAnchor = wasDisabled ? mainAnchor : targetAnchor
// patch 子节点(在子节点当前所在的容器中执行 diff 更新)
patchChildren(n1, n2, currentContainer, currentAnchor, parentComponent)
if (disabled) {
if (!wasDisabled) {
// enabled → disabled:将子节点从目标容器移回原位置
moveTeleport(n2, container, mainAnchor, internals, TeleportMoveTypes.TOGGLE)
}
} else {
if (wasDisabled) {
// disabled → enabled:将子节点从原位置移到目标容器
moveTeleport(n2, target, targetAnchor, internals, TeleportMoveTypes.TOGGLE)
} else if ((n2.props?.to) !== (n1.props?.to)) {
// 目标容器变化:将子节点移到新的目标容器
const nextTarget = (n2.target = resolveTarget(n2.props, querySelector))
if (nextTarget) {
moveTeleport(n2, nextTarget, null, internals, TeleportMoveTypes.TARGET_CHANGE)
}
}
}
}
},
// remove 方法:卸载 Teleport 时清理所有 DOM 节点
remove(vnode, parentComponent, internals) {
const { shapeFlag, children, anchor, targetAnchor, target } = vnode
const { um: unmount, o: { remove: hostRemove } } = internals
// 移除原位置的占位注释节点
hostRemove(vnode.el!)
hostRemove(anchor!)
// 移除目标容器中的锚点节点
if (target) {
hostRemove(targetAnchor!)
}
// 递归卸载所有子节点
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
for (let i = 0; i < children.length; i++) {
unmount(children[i], parentComponent)
}
}
},
}
// 导出 Teleport 类型,供用户在模板中使用
export const Teleport = TeleportImpl as unknown as {
__isTeleport: true
new (): { $props: TeleportProps }
}resolveTarget —— 目标容器解析
ts
// 解析 Teleport 的目标容器
function resolveTarget(props: any, select: typeof document.querySelector) {
const targetSelector = props?.to // 获取 to 属性(CSS 选择器字符串或 DOM 元素)
if (typeof targetSelector === 'string') {
// to 是字符串,使用 querySelector 查找目标 DOM 元素
if (!select) {
// 非浏览器环境(如 SSR)可能没有 querySelector
__DEV__ && console.warn(
`Current renderer does not support string target for Teleports.`,
)
return null
}
const target = select(targetSelector)
if (!target) {
// 找不到目标元素,开发环境下给出警告
__DEV__ && console.warn(
`Failed to locate Teleport target with selector "${targetSelector}".`,
)
}
return target
}
// to 也可以是 DOM 元素,直接返回
return targetSelector
}moveTeleport —— 移动逻辑
ts
// Teleport 移动类型枚举
const enum TeleportMoveTypes {
TARGET_CHANGE, // to 属性变化,需要将子节点移到新目标
TOGGLE, // disabled 属性切换,需要在原位置和目标之间移动
REORDER, // VNode 在序列中的位置变化(如列表重排)
}
// 将 Teleport 的子节点移动到新的容器中
function moveTeleport(
vnode: VNode,
container: RendererElement, // 目标容器
parentAnchor: RendererNode | null, // 插入锚点
internals: RendererInternals,
moveType: TeleportMoveTypes,
) {
const { mc: mountChildren, m: move, o: { insert } } = internals
if (moveType === TeleportMoveTypes.TARGET_CHANGE) {
// 目标容器变化时,先在新容器中插入锚点节点
insert(vnode.targetAnchor!, container, parentAnchor)
}
const { shapeFlag, children } = vnode
// 将所有子节点逐个移动到新容器
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
for (let i = 0; i < (children as VNode[]).length; i++) {
move((children as VNode[])[i], container, parentAnchor)
}
}
}在 renderer 中处理 Teleport
ts
// packages/runtime-core/src/renderer.ts
// patch 函数中对 Teleport 类型的特殊处理
function patch(n1, n2, container, anchor, parentComponent) {
const { type, shapeFlag } = n2
// 通过位运算检测是否为 Teleport 类型
if (shapeFlag & ShapeFlags.TELEPORT) {
// 委托给 TeleportImpl 的 process 方法处理
// 渲染器本身不包含 Teleport 的具体逻辑,保持代码简洁
;(type as typeof TeleportImpl).process(
n1,
n2,
container,
anchor,
parentComponent,
internals, // 传入渲染器内部方法,供 Teleport 使用
)
return
}
// ... 其他类型的处理
}设计分析:Teleport 不是通过渲染器内部的 if/else 分支实现的,而是将 process 方法定义在 TeleportImpl 对象上。渲染器只负责识别 Teleport 类型并委托调用。这种设计保持了渲染器的简洁,也便于 tree-shaking——不使用 Teleport 的应用不会打包相关代码。
Teleport 的 disabled 属性
vue
<Teleport to="body" :disabled="isMobile">
<Modal />
</Teleport>当 disabled 为 true 时,子节点渲染在 Teleport 的原位置(就像没有 Teleport 一样)。动态切换 disabled 时,会触发子节点在原位置和目标容器之间的移动。
Part 2: KeepAlive
KeepAlive 的作用
KeepAlive 缓存包裹的动态组件实例。当组件被切换出去时,不会被销毁(unmount),而是被"停用"(deactivate)。切换回来时,不会重新创建,而是"激活"(activate)。
vue
<KeepAlive>
<component :is="currentTab" />
</KeepAlive>对标源码位置:packages/runtime-core/src/components/KeepAlive.ts
KeepAlive 的组件定义
KeepAlive 本身是一个有状态的组件,但它的渲染逻辑非常特殊:
ts
// packages/runtime-core/src/components/KeepAlive.ts
// KeepAlive 是一个有状态的内置组件,但渲染逻辑非常特殊
const KeepAliveImpl = {
name: 'KeepAlive',
__isKeepAlive: true, // 标记,渲染器通过此标记识别 KeepAlive
props: {
include: [String, RegExp, Array], // 匹配的组件名会被缓存
exclude: [String, RegExp, Array], // 匹配的组件名不会被缓存
max: [String, Number], // 最大缓存数量
},
setup(props: KeepAliveProps, { slots }: SetupContext) {
const instance = getCurrentInstance()! // 获取当前 KeepAlive 组件实例
const sharedContext = instance.ctx as KeepAliveContext // 获取共享上下文,用于注入 activate/deactivate
const cache: Cache = new Map() // key → VNode 的缓存映射
const keys: Keys = new Set() // 用 Set 维护 key 的插入顺序,实现 LRU 策略
let current: VNode | null = null // 当前活跃的缓存 VNode
// 当前组件 pending 的缓存 key
let pendingCacheKey: CacheKey | null = null
// 缓存子树:在组件挂载/更新后将当前子组件的 VNode 存入缓存
const cacheSubtree = () => {
if (pendingCacheKey != null) {
cache.set(pendingCacheKey, getInnerChild(instance.subTree))
}
}
// 在 mounted 和 updated 时执行缓存,确保组件实例已经创建完成
onMounted(cacheSubtree)
onUpdated(cacheSubtree)
// 在 KeepAlive 自身卸载前,清理所有缓存的组件
onBeforeUnmount(() => {
cache.forEach(cached => {
const { subTree, suspense } = instance
const vnode = getInnerChild(subTree)
if (cached.type === vnode.type) {
// 当前活跃的组件走正常的 unmount 流程
resetShapeFlag(vnode) // 重置标志位,不再走 deactivate 逻辑
const da = vnode.component!.da // 获取 deactivated hooks
da && queuePostRenderEffect(da)
return
}
// 非活跃的缓存组件直接卸载
unmount(cached)
})
})
// 返回渲染函数
return () => {
pendingCacheKey = null
// 没有默认插槽,直接返回 null
if (!slots.default) {
return null
}
const children = slots.default() // 获取默认插槽内容
const rawVNode = children[0] // KeepAlive 只处理第一个子节点
if (children.length > 1) {
// KeepAlive 只能包裹单个子组件,多个子组件时给出警告
if (__DEV__) {
console.warn('KeepAlive should contain exactly one component child.')
}
current = null
return children
}
// 子节点必须是有状态组件,否则不进行缓存处理
if (
!isVNode(rawVNode) ||
(!(rawVNode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT))
) {
current = null
return rawVNode
}
let vnode = rawVNode
const comp = vnode.type // 组件定义对象
const name = getComponentName(comp) // 获取组件名(用于 include/exclude 匹配)
const { include, exclude, max } = props
// include / exclude 过滤:不匹配的组件不进行缓存
if (
(include && (!name || !matches(include, name))) ||
(exclude && name && matches(exclude, name))
) {
current = vnode
return rawVNode // 直接返回,不做缓存处理
}
// 生成缓存的 key:优先使用 vnode.key,否则使用组件类型本身
const key = vnode.key == null ? comp : vnode.key
const cachedVNode = cache.get(key) // 查找缓存
pendingCacheKey = key
if (cachedVNode) {
// 命中缓存:复用之前缓存的组件实例和 DOM 元素
vnode.el = cachedVNode.el
vnode.component = cachedVNode.component
// 标记为 COMPONENT_KEPT_ALIVE,渲染器会调用 activate 而非 mount
vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
// 将 key 移到 Set 末尾(LRU 策略:标记为最近使用)
keys.delete(key)
keys.add(key)
} else {
keys.add(key) // 新组件,将 key 加入 Set
// 超过最大缓存数:删除最久未使用的(Set 头部 = 最早插入 = 最久未使用)
if (max && keys.size > parseInt(max as string, 10)) {
pruneCacheEntry(keys.values().next().value!)
}
}
// 标记为 COMPONENT_SHOULD_KEEP_ALIVE,卸载时走 deactivate 而非 unmount
vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
current = vnode
return rawVNode
}
},
}核心 ShapeFlags
ts
export const enum ShapeFlags {
// ...
COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8, // 卸载时应缓存(deactivate)
COMPONENT_KEPT_ALIVE = 1 << 9, // 挂载时从缓存恢复(activate)
}这两个标志位是 KeepAlive 与渲染器之间的通信协议:
COMPONENT_SHOULD_KEEP_ALIVE:KeepAlive 在返回 VNode 时设置,告诉渲染器"卸载这个组件时不要真的销毁它"COMPONENT_KEPT_ALIVE:命中缓存时设置,告诉渲染器"挂载这个组件时不需要重新创建,直接激活"
缓存策略:Map + Set 实现 LRU
ts
const cache: Map<CacheKey, VNode> = new Map()
const keys: Set<CacheKey> = new Set()利用 Set 的插入顺序特性实现 LRU(Least Recently Used)淘汰策略:
初始状态: keys = {}
访问 A: keys = {A}
访问 B: keys = {A, B}
访问 C: keys = {A, B, C} max = 3
访问 A: keys = {B, C, A} A 移到末尾(最近使用)
访问 D: keys = {C, A, D} B 被淘汰(Set 头部 = 最久未使用)ts
// 清除指定 key 的缓存条目
function pruneCacheEntry(key: CacheKey) {
const cached = cache.get(key)
if (cached) {
// 不是当前活跃组件,才真正卸载(销毁组件实例和 DOM)
if (!current || cached.type !== current.type) {
unmount(cached)
} else {
// 是当前活跃组件,只重置 flag,让它下次被切走时正常卸载而非缓存
resetShapeFlag(cached)
}
}
cache.delete(key) // 从缓存映射中移除
keys.delete(key) // 从 LRU 顺序集合中移除
}sharedContext:渲染器注入 activate/deactivate
KeepAlive 需要在渲染器中执行真正的 DOM 操作(移动节点),但它本身作为组件无法直接访问渲染器内部方法。Vue 3 通过 sharedContext 机制解决:
ts
// packages/runtime-core/src/renderer.ts
// 处理组件类型的 VNode:区分 KeepAlive 缓存恢复和普通挂载
function processComponent(n1, n2, container, anchor, parentComponent) {
if (n1 == null) {
// 首次出现(挂载)
if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
// 该组件之前被 KeepAlive 缓存过,走 activate 流程(直接移回 DOM,不重新创建)
;(parentComponent!.ctx as KeepAliveContext).activate(
n2, container, anchor,
)
} else {
// 普通组件,走正常的挂载流程
mountComponent(n2, container, anchor, parentComponent)
}
} else {
// 已存在的组件,走更新流程
updateComponent(n1, n2)
}
}
// 卸载 VNode 时对 KeepAlive 的特殊处理
function unmount(vnode, parentComponent) {
// ...
if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
// 该组件被 KeepAlive 包裹,不真正卸载,而是走 deactivate 流程(移到离屏容器)
;(parentComponent!.ctx as KeepAliveContext).deactivate(vnode)
return
}
// 正常卸载流程...
}在 KeepAlive 组件挂载时,渲染器向其 context 注入 activate 和 deactivate:
ts
// packages/runtime-core/src/components/KeepAlive.ts
// 在 KeepAlive 的 setup 中,渲染器向 sharedContext 注入 activate 和 deactivate 方法
const sharedContext = instance.ctx as KeepAliveContext
// activate:将缓存的组件重新激活(从离屏容器移回文档 DOM)
sharedContext.activate = (vnode, container, anchor) => {
const instance = vnode.component!
// 将缓存的 DOM 节点从离屏容器移入目标容器
move(vnode, container, anchor)
// patch 更新 props(在缓存期间父组件传入的 props 可能已变化)
patch(instance.vnode, vnode, container, anchor, instance)
// 触发 activated 生命周期(异步执行,确保 DOM 操作已完成)
queuePostRenderEffect(() => {
instance.isDeactivated = false // 标记组件不再处于停用状态
if (instance.a) {
invokeArrayFns(instance.a) // 执行所有 activated hooks
}
})
}
// deactivate:将组件停用(从文档 DOM 移到离屏容器,保留 DOM 状态)
sharedContext.deactivate = (vnode) => {
const instance = vnode.component!
// 将 DOM 节点移到隐藏的离屏容器(不在文档中,用户不可见)
// 这样组件的 DOM 状态(滚动位置、表单输入等)得以保留
move(vnode, storageContainer)
// 触发 deactivated 生命周期
queuePostRenderEffect(() => {
if (instance.da) {
invokeArrayFns(instance.da) // 执行所有 deactivated hooks
}
instance.isDeactivated = true // 标记组件处于停用状态
})
}storageContainer —— 隐藏的离屏容器
ts
const storageContainer = createElement('div')被 deactivate 的组件的 DOM 节点会被移入这个离屏容器,而不是从 DOM 树中移除。这样组件的 DOM 状态(如滚动位置、表单输入)得以保留。
activate / deactivate 生命周期
KeepAlive 缓存的组件有两个额外的生命周期 Hooks:
ts
// packages/runtime-core/src/apiLifecycle.ts
// 注册 activated 生命周期 Hook(组件从缓存恢复时触发)
export const onActivated = (hook: Function) => {
registerKeepAliveHook(hook, 'a') // 'a' 对应实例上的 activated hooks 数组
}
// 注册 deactivated 生命周期 Hook(组件被缓存停用时触发)
export const onDeactivated = (hook: Function) => {
registerKeepAliveHook(hook, 'da') // 'da' 对应实例上的 deactivated hooks 数组
}
// KeepAlive 专用的 Hook 注册函数
function registerKeepAliveHook(hook: Function, type: 'a' | 'da') {
// 对 hook 进行包装,增加卸载状态检查
const wrappedHook = (...args: unknown[]) => {
// 如果组件已被真正卸载(非 deactivate),则不执行 hook
if (instance.isUnmounted) return
hook(...args)
}
// 将包装后的 hook 注入当前组件实例
injectHook(type, wrappedHook)
// 也注册到所有祖先 KeepAlive 实例
// 这样嵌套的 KeepAlive 中的组件也能正确触发 activated/deactivated
let current = instance.parent
while (current) {
if (current.type.__isKeepAlive) {
// 向祖先 KeepAlive 实例注入同一个 hook
injectHook(type, wrappedHook, current)
}
current = current.parent // 沿组件树向上遍历
}
}include / exclude 过滤
ts
// 检查组件名是否匹配 include/exclude 的模式
// 支持三种模式:字符串(逗号分隔)、正则表达式、数组
function matches(pattern: string | RegExp | (string | RegExp)[], name: string): boolean {
if (Array.isArray(pattern)) {
// 数组模式:递归检查数组中的每个元素,任一匹配即返回 true
return pattern.some(p => matches(p, name))
} else if (typeof pattern === 'string') {
// 字符串模式:按逗号分隔为数组,检查是否包含目标名
return pattern.split(',').includes(name)
} else if (pattern instanceof RegExp) {
// 正则模式:直接使用正则测试
return pattern.test(name)
}
return false
}vue
<!-- 只缓存名为 A 和 B 的组件 -->
<KeepAlive include="A,B">
<component :is="currentTab" />
</KeepAlive>
<!-- 用正则表达式 -->
<KeepAlive :include="/^Tab/">
<component :is="currentTab" />
</KeepAlive>
<!-- 用数组 -->
<KeepAlive :include="['TabA', 'TabB']">
<component :is="currentTab" />
</KeepAlive>当 include / exclude 动态变化时,需要剪枝不再匹配的缓存:
ts
// 监听 include/exclude 的变化,动态剪枝不再匹配的缓存
watch(
() => [props.include, props.exclude],
([include, exclude]) => {
// include 变化时:只保留仍然匹配 include 的缓存
include && pruneCache(name => matches(include, name))
// exclude 变化时:移除新匹配 exclude 的缓存
exclude && pruneCache(name => !matches(exclude, name))
},
{ flush: 'post', deep: true }, // post flush:在 DOM 更新后执行;deep:深度监听数组/正则变化
)
// 遍历缓存,移除不满足 filter 条件的条目
function pruneCache(filter: (name: string) => boolean) {
cache.forEach((vnode, key) => {
const name = getComponentName(vnode.type) // 获取缓存组件的名称
if (name && !filter(name)) {
// 组件名不满足过滤条件,清除该缓存
pruneCacheEntry(key)
}
})
}对比 React
Teleport vs React Portal
| 维度 | Vue 3 Teleport | React Portal |
|---|---|---|
| API | <Teleport to="body"> | createPortal(children, domNode) |
| 目标指定 | CSS 选择器字符串或 DOM 元素 | 只接受 DOM 元素 |
| disabled 支持 | 原生支持 disabled 属性 | 无原生支持,需条件渲染 |
| 事件冒泡 | 在组件树中冒泡(非 DOM 树) | 在组件树中冒泡(非 DOM 树) |
| SSR 支持 | 内置支持 | 不支持 SSR |
两者的核心理念完全一致:DOM 结构与组件逻辑树解耦。
KeepAlive vs React
| 维度 | Vue 3 KeepAlive | React |
|---|---|---|
| 原生支持 | 有(<KeepAlive>) | 无 |
| 实现机制 | 组件级别缓存(activate/deactivate) | 需要自行实现(display:none / 状态管理) |
| 缓存策略 | LRU + max + include/exclude | 无 |
| DOM 状态保留 | 完整保留(滚动位置、表单输入等) | 需要手动保存恢复 |
| 生命周期 | onActivated / onDeactivated | 无对应概念 |
React 生态中实现类似功能的常见方案:
tsx
// 方案 1: CSS display:none(DOM 不会被销毁,但始终存在于 DOM 树中)
// 缺点:所有"缓存"的组件一直在 DOM 中,性能较差
function KeepAlive({ active, children }) {
return <div style={{ display: active ? 'block' : 'none' }}>{children}</div>
}
// 方案 2: 状态提升(不缓存组件实例,而是将状态提到外部)
// 缺点:需要手动管理状态,无法保留 DOM 状态(如滚动位置)
const [tabState, setTabState] = useState({ tab1: {}, tab2: {} })
// 方案 3: react-activation 等第三方库
// 缺点:需要侵入式的 Provider 包裹,与 React 原生生命周期不完全兼容
import { KeepAlive } from 'react-activation'但这些方案都有缺陷:
- display:none:所有被"缓存"的组件一直存在于 DOM 中,性能较差
- 状态提升:需要手动管理状态,不能保留 DOM 状态(如滚动位置)
- 第三方库:需要侵入式的 Provider 包裹,与 React 原生生命周期不完全兼容
Vue 3 的 KeepAlive 通过渲染器级别的支持(sharedContext、ShapeFlags),实现了真正的组件实例缓存,这是 Vue 相对 React 的一个显著架构优势。
简化实现
Teleport 简化版
ts
// components/Teleport.ts
// Teleport 的简化实现
export const TeleportImpl = {
__isTeleport: true, // 类型标记
// process 处理挂载和更新两种情况
process(n1, n2, container, anchor, parentComponent, internals) {
// 解构出挂载子节点、更新子节点和 DOM 操作方法
const { mc: mountChildren, pc: patchChildren, o: { insert, querySelector, createText } } = internals
if (n1 == null) {
// 首次挂载:解析目标容器并将子节点挂载到目标位置
const target = (n2.target = querySelector(n2.props.to))
if (target) {
mountChildren(n2.children, target, null, parentComponent)
}
} else {
// 更新:复用旧的目标容器引用
n2.target = n1.target
// 在目标容器中 patch 更新子节点
patchChildren(n1, n2, n2.target, null, parentComponent)
// 检查 to 属性是否变化,需要将子节点移到新目标
if (n2.props.to !== n1.props.to) {
const newTarget = (n2.target = querySelector(n2.props.to))
if (newTarget) {
// 将所有子节点的 DOM 元素移到新目标容器
n2.children.forEach(child => {
insert(child.el, newTarget)
})
}
}
}
},
// 卸载时递归卸载所有子节点
remove(vnode, parentComponent, internals) {
const { um: unmount, o: { remove: hostRemove } } = internals
if (vnode.children) {
vnode.children.forEach(child => unmount(child, parentComponent))
}
},
}KeepAlive 简化版
ts
// components/KeepAlive.ts
// KeepAlive 的简化实现
export const KeepAliveImpl = {
name: 'KeepAlive',
__isKeepAlive: true, // 标记,渲染器通过此标记识别
props: {
max: Number, // 最大缓存组件数量
},
setup(props, { slots }) {
const instance = getCurrentInstance()!
const { move, createElement } = instance.ctx.renderer // 从渲染器获取 DOM 操作方法
const cache = new Map() // key → VNode 缓存
const keys = new Set() // 维护 key 的访问顺序(LRU)
const storageContainer = createElement('div') // 创建离屏容器,用于存放停用组件的 DOM
// 注入 activate 方法:将缓存组件的 DOM 移回文档并触发 activated hook
instance.ctx.activate = (vnode, container, anchor) => {
move(vnode, container, anchor) // 将 DOM 从离屏容器移入目标位置
const child = vnode.component
child.isDeactivated = false // 标记为活跃状态
if (child.a) invokeArrayFns(child.a) // 触发 activated hooks
}
// 注入 deactivate 方法:将组件 DOM 移到离屏容器并触发 deactivated hook
instance.ctx.deactivate = (vnode) => {
move(vnode, storageContainer, null) // 将 DOM 移到离屏容器
const child = vnode.component
if (child.da) invokeArrayFns(child.da) // 触发 deactivated hooks
child.isDeactivated = true // 标记为停用状态
}
// 返回渲染函数
return () => {
const vnode = slots.default()[0] // 获取唯一的子组件 VNode
// 生成缓存 key:优先使用 vnode.key,否则用组件类型
const key = vnode.key == null ? vnode.type : vnode.key
const cachedVNode = cache.get(key)
if (cachedVNode) {
// 命中缓存:复用组件实例和 DOM
vnode.el = cachedVNode.el
vnode.component = cachedVNode.component
// 标记为 KEPT_ALIVE,渲染器会调用 activate 而非重新 mount
vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
// LRU:将 key 移到末尾(标记为最近使用)
keys.delete(key)
keys.add(key)
} else {
// 新组件:加入缓存
cache.set(key, vnode)
keys.add(key)
// 超过最大缓存数:淘汰最久未使用的(Set 头部)
if (props.max && keys.size > props.max) {
const oldest = keys.values().next().value
pruneCacheEntry(oldest)
}
}
// 标记为 SHOULD_KEEP_ALIVE,卸载时走 deactivate 而非真正销毁
vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
return vnode
}
// 清除指定缓存条目:卸载组件并从 cache/keys 中移除
function pruneCacheEntry(key) {
const cached = cache.get(key)
if (cached) {
unmount(cached) // 真正卸载组件
}
cache.delete(key)
keys.delete(key)
}
},
}测试用例
Teleport 测试
ts
import { describe, it, expect } from 'vitest'
import { h, render, ref, nextTick, Teleport } from '../src'
describe('Teleport', () => {
// 测试:子节点应该被传送到目标容器中
it('should teleport children to target', () => {
// 创建目标容器并添加到 body
const target = document.createElement('div')
document.body.appendChild(target)
target.id = 'portal'
const root = document.createElement('div') // Teleport 的原位置容器
// 渲染 Teleport,将子节点传送到 #portal
render(
h(Teleport, { to: '#portal' }, [
h('div', { class: 'content' }, 'teleported'),
]),
root,
)
// 断言:原位置容器应该为空(子节点被传送走了)
expect(root.innerHTML).toBe('')
// 断言:子节点应该出现在目标容器中
expect(target.innerHTML).toBe('<div class="content">teleported</div>')
document.body.removeChild(target) // 清理
})
// 测试:传送的内容应该能响应式更新
it('should update teleported content', async () => {
const target = document.createElement('div')
document.body.appendChild(target)
target.id = 'portal'
const root = document.createElement('div')
const msg = ref('hello') // 响应式数据
const Comp = {
setup() {
return () =>
h(Teleport, { to: '#portal' }, [
h('div', null, msg.value), // 依赖 msg 的内容
])
},
}
render(h(Comp), root)
// 断言:初始内容正确传送
expect(target.innerHTML).toBe('<div>hello</div>')
msg.value = 'world' // 修改响应式数据
await nextTick() // 等待异步更新
// 断言:传送的内容也随之更新
expect(target.innerHTML).toBe('<div>world</div>')
document.body.removeChild(target)
})
// 测试:当 to 属性变化时,子节点应该移动到新目标
it('should move children when target changes', async () => {
const target1 = document.createElement('div')
const target2 = document.createElement('div')
document.body.appendChild(target1)
document.body.appendChild(target2)
target1.id = 'target1'
target2.id = 'target2'
const root = document.createElement('div')
const targetId = ref('#target1') // 动态切换目标
const Comp = {
setup() {
return () =>
h(Teleport, { to: targetId.value }, [
h('div', null, 'content'),
])
},
}
render(h(Comp), root)
// 断言:初始时子节点在 target1 中
expect(target1.innerHTML).toBe('<div>content</div>')
expect(target2.innerHTML).toBe('')
targetId.value = '#target2' // 切换目标容器
await nextTick()
// 断言:子节点已从 target1 移到 target2
expect(target1.innerHTML).toBe('')
expect(target2.innerHTML).toBe('<div>content</div>')
document.body.removeChild(target1)
document.body.removeChild(target2)
})
// 测试:disabled 时子节点应该渲染在原位置
it('should render in place when disabled', () => {
const target = document.createElement('div')
document.body.appendChild(target)
target.id = 'portal'
const root = document.createElement('div')
// disabled=true 时,子节点不传送,渲染在原位置
render(
h('div', null, [
h(Teleport, { to: '#portal', disabled: true }, [
h('span', null, 'local'),
]),
]),
root,
)
// 断言:目标容器应该为空
expect(target.innerHTML).toBe('')
// 断言:子节点应该在原位置(root 中)
expect(root.querySelector('span')?.textContent).toBe('local')
document.body.removeChild(target)
})
})KeepAlive 测试
ts
import { describe, it, expect, vi } from 'vitest'
import {
h,
render,
ref,
nextTick,
KeepAlive,
onMounted,
onUnmounted,
onActivated,
onDeactivated,
} from '../src'
describe('KeepAlive', () => {
// 测试:KeepAlive 应该缓存组件,切换回来时不重新创建
it('should cache component', async () => {
const mountedSpy = vi.fn() // 追踪 mounted 调用次数
const unmountedSpy = vi.fn() // 追踪 unmounted 调用次数
const Child = {
name: 'Child',
setup() {
onMounted(mountedSpy)
onUnmounted(unmountedSpy)
return () => h('div', null, 'child')
},
}
const toggle = ref(true) // 控制子组件的显示/隐藏
const Parent = {
setup() {
return () =>
h(KeepAlive, null, {
// 通过默认插槽传入子组件
default: () => toggle.value ? h(Child) : null,
})
},
}
const root = document.createElement('div')
render(h(Parent), root)
// 断言:首次渲染,mounted 被调用一次
expect(mountedSpy).toHaveBeenCalledTimes(1)
toggle.value = false // 切走子组件
await nextTick()
// 断言:切走时不应触发 unmounted(被 KeepAlive 缓存了)
expect(unmountedSpy).not.toHaveBeenCalled()
toggle.value = true // 切回子组件
await nextTick()
// 断言:切回时 mounted 不应再次调用(复用缓存实例)
expect(mountedSpy).toHaveBeenCalledTimes(1)
})
// 测试:activated 和 deactivated 生命周期应正确触发
it('should fire activated/deactivated hooks', async () => {
const activated = vi.fn()
const deactivated = vi.fn()
const Child = {
name: 'Child',
setup() {
onActivated(activated) // 注册 activated hook
onDeactivated(deactivated) // 注册 deactivated hook
return () => h('div', null, 'child')
},
}
const toggle = ref(true)
const Parent = {
setup() {
return () =>
h(KeepAlive, null, {
default: () => toggle.value ? h(Child) : null,
})
},
}
const root = document.createElement('div')
render(h(Parent), root)
// 断言:首次渲染时 activated 被调用一次
expect(activated).toHaveBeenCalledTimes(1)
toggle.value = false // 切走:触发 deactivated
await nextTick()
expect(deactivated).toHaveBeenCalledTimes(1)
toggle.value = true // 切回:再次触发 activated
await nextTick()
// 断言:activated 已被调用 2 次(首次 + 切回)
expect(activated).toHaveBeenCalledTimes(2)
})
// 测试:max 属性应实现 LRU 淘汰策略
it('should respect max prop (LRU)', async () => {
const unmountedSpies: Record<string, ReturnType<typeof vi.fn>> = {}
// 工厂函数:创建带有 unmounted 追踪的子组件
function createChild(name: string) {
const spy = vi.fn()
unmountedSpies[name] = spy
return {
name,
setup() {
onUnmounted(spy) // 注册 unmounted hook 用于追踪
return () => h('div', null, name)
},
}
}
const A = createChild('A')
const B = createChild('B')
const C = createChild('C')
const current = ref('A')
const components: Record<string, any> = { A, B, C }
const Parent = {
setup() {
return () =>
h(KeepAlive, { max: 2 }, { // 最多缓存 2 个组件
default: () => h(components[current.value]),
})
},
}
const root = document.createElement('div')
render(h(Parent), root)
current.value = 'B' // 切到 B,缓存 [A, B]
await nextTick()
// 断言:A 被 deactivate 缓存,不应触发 unmounted
expect(unmountedSpies.A).not.toHaveBeenCalled()
current.value = 'C' // 切到 C,缓存数超过 max=2
await nextTick()
// 断言:A 是最久未使用的,应被淘汰(触发 unmounted)
expect(unmountedSpies.A).toHaveBeenCalledTimes(1)
// 断言:B 仍在缓存中,不应触发 unmounted
expect(unmountedSpies.B).not.toHaveBeenCalled()
})
// 测试:include 属性应只缓存匹配的组件
it('should work with include/exclude', async () => {
const mountedA = vi.fn()
const mountedB = vi.fn()
const A = {
name: 'CompA', // 组件名匹配 include
setup() {
onMounted(mountedA)
return () => h('div', null, 'A')
},
}
const B = {
name: 'CompB', // 组件名不匹配 include,不会被缓存
setup() {
onMounted(mountedB)
return () => h('div', null, 'B')
},
}
const current = ref('A')
const components: Record<string, any> = { A, B }
const Parent = {
setup() {
return () =>
h(KeepAlive, { include: 'CompA' }, { // 只缓存名为 CompA 的组件
default: () => h(components[current.value]),
})
},
}
const root = document.createElement('div')
render(h(Parent), root)
current.value = 'B' // 切到 B
await nextTick()
current.value = 'A' // 切回 A:A 被缓存,不会重新 mount
await nextTick()
expect(mountedA).toHaveBeenCalledTimes(1) // A 只 mount 了一次(被缓存复用)
current.value = 'B' // 再切到 B
await nextTick()
current.value = 'A' // 再切回 A
await nextTick()
// 断言:B 每次都重新 mount(未被缓存),共 mount 了 3 次
expect(mountedB).toHaveBeenCalledTimes(3)
// 断言:A 始终被缓存复用,只 mount 了 1 次
expect(mountedA).toHaveBeenCalledTimes(1)
})
})本节小结
- Teleport —— 通过
process方法将子节点挂载到to指定的目标容器,支持动态切换目标和disabled属性 - Teleport 的 tree-shaking —— 渲染逻辑定义在
TeleportImpl对象上而非渲染器内部,不使用则不打包 - KeepAlive —— 通过
COMPONENT_KEPT_ALIVE和COMPONENT_SHOULD_KEEP_ALIVE标志位与渲染器通信 - activate / deactivate —— 替代 mount / unmount,将 DOM 节点移入/移出离屏容器,保留完整 DOM 状态
- LRU 缓存 ——
Map+Set实现,max属性限制缓存上限,淘汰最久未使用的组件 - sharedContext —— 渲染器向 KeepAlive 组件注入内部操作方法,实现跨层级协作
- React 无原生对应 —— React 没有 KeepAlive 等价物,需借助 CSS hack 或第三方库,Vue 的渲染器级支持是架构优势