Skip to content

实现 Teleport 与 KeepAlive

本节对标 Vue 3 源码 @vue/runtime-core 中的 components/Teleport.tscomponents/KeepAlive.ts

Part 1: Teleport

Teleport 的作用

Teleport 将子节点渲染到 DOM 树的另一个位置,而不改变组件的逻辑层级关系。典型场景:

  • 模态框(Modal):逻辑上属于触发它的组件,但 DOM 上需要渲染到 <body> 下避免 z-indexoverflow 问题
  • 通知(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>

disabledtrue 时,子节点渲染在 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 注入 activatedeactivate

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 TeleportReact Portal
API<Teleport to="body">createPortal(children, domNode)
目标指定CSS 选择器字符串或 DOM 元素只接受 DOM 元素
disabled 支持原生支持 disabled 属性无原生支持,需条件渲染
事件冒泡在组件树中冒泡(非 DOM 树)在组件树中冒泡(非 DOM 树)
SSR 支持内置支持不支持 SSR

两者的核心理念完全一致:DOM 结构与组件逻辑树解耦

KeepAlive vs React

维度Vue 3 KeepAliveReact
原生支持有(<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 通过渲染器级别的支持(sharedContextShapeFlags),实现了真正的组件实例缓存,这是 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)
  })
})

本节小结

  1. Teleport —— 通过 process 方法将子节点挂载到 to 指定的目标容器,支持动态切换目标和 disabled 属性
  2. Teleport 的 tree-shaking —— 渲染逻辑定义在 TeleportImpl 对象上而非渲染器内部,不使用则不打包
  3. KeepAlive —— 通过 COMPONENT_KEPT_ALIVECOMPONENT_SHOULD_KEEP_ALIVE 标志位与渲染器通信
  4. activate / deactivate —— 替代 mount / unmount,将 DOM 节点移入/移出离屏容器,保留完整 DOM 状态
  5. LRU 缓存 —— Map + Set 实现,max 属性限制缓存上限,淘汰最久未使用的组件
  6. sharedContext —— 渲染器向 KeepAlive 组件注入内部操作方法,实现跨层级协作
  7. React 无原生对应 —— React 没有 KeepAlive 等价物,需借助 CSS hack 或第三方库,Vue 的渲染器级支持是架构优势

用心学习,用代码说话 💻