Vue 基础体系 · 第 34/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue KeepAlive 深入:缓存键、include、生命周期和内存治理

<KeepAlive> 是 Vue 3 内置的抽象组件,用于缓存动态组件实例。它解决的不是“把 DOM 隐藏起来”,而是:当一个动态组件暂时离开当前渲染位置时,是否保留它的组件实例、响应式状态以及部分副作用,在它再次出现时恢复,而不是重新创建。

最常见的使用场景是标签页、路由页面、可切换的编辑器面板:

<KeepAlive>
  <component :is="currentComponent" />
</KeepAlive>

没有 KeepAlive 时,当前组件切换出去通常会被卸载,回来时重新挂载;加入 KeepAlive 后,旧组件会进入“停用”状态,实例仍然存在。

但是否命中缓存,不由组件在页面上的位置决定,而主要由以下因素共同决定:

  1. 动态组件的缓存键;
  2. includeexclude 对组件名称的筛选;
  3. max 对缓存数量的限制;
  4. 组件是否真的被卸载,而不是仅被停用;
  5. 组件内部的定时器、订阅、请求和第三方实例是否被正确治理。

1. 先区分三个概念:挂载、停用和卸载

理解 KeepAlive 前,需要区分 Vue 组件生命周期中的三个状态。

1.1 挂载

组件首次进入渲染树时,Vue 会:

  1. 创建组件实例;
  2. 执行 setup()
  3. 创建并挂载组件 DOM;
  4. 建立组件的渲染副作用;
  5. 触发 onMounted()

挂载意味着组件实例和对应 DOM 都被创建出来。

1.2 停用

组件被 <KeepAlive> 缓存后切换出去,会进入停用状态:

  • 组件实例仍然保留;
  • 组件的响应式状态仍然保留;
  • 组件 DOM 通常被移入 Vue 的隐藏存储容器;
  • 不再处于当前页面的活动渲染位置;
  • 触发 onDeactivated()
  • 不会触发 onUnmounted()

停用不是卸载。下面的状态会被保留:

const keyword = ref('')
const selectedRows = ref<string[]>([])
const scrollTop = ref(320)

如果组件只是被停用,这些值不会因为切换页面而恢复默认值。

1.3 卸载

组件被真正销毁时,Vue 会:

  • 移除组件 DOM;
  • 停止组件渲染副作用;
  • 执行卸载清理;
  • 触发 onUnmounted()

对于被 KeepAlive 缓存的组件,以下情况可能导致卸载:

  • 缓存被 max 淘汰;
  • includeexclude 变化后不再允许缓存;
  • KeepAlive 自身被卸载;
  • 动态组件对应的缓存条目被替换或删除;
  • 组件树发生了需要销毁该实例的结构变化。

因此,onDeactivated() 适合处理“暂时离开”,onUnmounted() 适合处理“实例最终销毁”。


2. KeepAlive 的基本渲染模型

KeepAlive 通常包裹一个动态组件:

<script setup lang="ts">
import { ref } from 'vue'
import UserList from './UserList.vue'
import SettingsPanel from './SettingsPanel.vue'

const current = ref<'users' | 'settings'>('users')

const components = {
  users: UserList,
  settings: SettingsPanel,
}
</script>

<template>
  <nav>
    <button @click="current = 'users'">用户</button>
    <button @click="current = 'settings'">设置</button>
  </nav>

  <KeepAlive>
    <component :is="components[current]" />
  </KeepAlive>
</template>

切换过程可以抽象为:

stateDiagram-v2
    [*] --> UserList: 首次显示
    UserList --> UserList: 命中同一缓存键
    UserList --> SettingsPanel: 停用 UserList
    SettingsPanel --> UserList: 激活缓存中的 UserList
    SettingsPanel --> [*]: KeepAlive 卸载或缓存淘汰
    UserList --> [*]: 缓存淘汰或不再缓存

首次显示 UserList 时,Vue 创建实例并挂载。切换到 SettingsPanel 时,UserList 通常只是被停用。再次切回时,如果缓存键仍然相同,Vue 会重新激活原来的 UserList 实例,而不是重新执行一次完整的创建流程。

这里的关键问题是:什么叫“缓存键相同”?


3. 缓存键:决定“这是同一个组件实例”还是“另一个缓存条目”

3.1 缓存键的基本规则

在 Vue 3 的 KeepAlive 实现中,缓存键可以概括为:

const cacheKey = vnode.key == null ? vnode.type : vnode.key

其中:

  • vnode.type 是组件类型,通常是组件对象;
  • vnode.key 是组件 VNode 的显式 key
  • 如果没有显式 key,使用组件类型作为缓存键;
  • 如果提供了显式 key,显式 key 会优先使用。

这条规则可以写成形式化表达:

K(v)={v.key,v.keynullv.type,v.key=nullK(v) = \begin{cases} v.key, & v.key \neq null \\ v.type, & v.key = null \end{cases}

其中 K(v)K(v) 表示 VNode vv 的缓存键。

当新 VNode 的缓存键满足:

K(vnew)=K(vcached)K(v_{\text{new}}) = K(v_{\text{cached}})

并且它仍然符合 includeexclude 等缓存条件时,Vue 才可能复用缓存实例。

3.2 没有 key:通常按组件类型缓存

<KeepAlive>
  <component :is="UserList" />
</KeepAlive>

如果这个位置始终渲染同一个 UserList 组件类型,缓存键通常就是 UserList 这个组件对象。

因此,下面的切换不会产生两个 UserList 实例:

<KeepAlive>
  <UserList v-if="mode === 'all'" />
  <UserList v-else />
</KeepAlive>

这段写法本身还涉及条件分支结构,实际使用时不应仅凭“代码写了两个标签”判断实例是否独立。组件类型和显式 key 才是判断缓存身份的核心。

3.3 使用 key:可以让同一组件类型拥有多个实例

假设一个通用编辑器组件同时打开两个文档:

<KeepAlive>
  <DocumentEditor
    v-if="documentId"
    :key="documentId"
    :document-id="documentId"
  />
</KeepAlive>

切换过程如下:

当前文档 缓存键 结果
doc-a doc-a 创建并缓存实例 A
doc-b doc-b 创建并缓存实例 B,停用 A
doc-a doc-a 激活实例 A
doc-c doc-c 创建实例 C,停用 B

这里虽然始终是同一个 DocumentEditor 组件类型,但不同 key 代表不同的缓存身份。

这也是 key 的重要语义:它不是只用于列表 DOM diff,也可以用于声明“这些组件状态必须彼此隔离”。

3.4 稳定键和随机键的区别

正确:

<DocumentEditor :key="document.id" :document-id="document.id" />

危险:

<DocumentEditor :key="Math.random()" :document-id="document.id" />

随机键会导致每次渲染都产生新的缓存键:

K1K2K3K_1 \neq K_2 \neq K_3

结果可能是:

  1. 旧实例无法按原键命中;
  2. 新实例不断创建;
  3. 旧缓存条目继续占用内存;
  4. 配合较大的 max 时,缓存增长速度异常;
  5. 组件的 onMounted()、初始化请求和第三方实例不断重复执行。

因此,key 应来自稳定业务身份,例如文档 ID、标签页 ID、路由唯一标识,而不应来自时间戳或随机数。

3.5 显式 key 的跨类型冲突

由于显式 key 会优先于组件类型,下面的写法存在风险:

<KeepAlive>
  <component :is="currentComponent" key="main" />
</KeepAlive>

如果 currentComponent 先是 UserList,之后变成 SettingsPanel,两者都使用字符串 "main"。从缓存键角度看,它们可能使用同一个键,而不是分别使用组件类型作为键。

更安全的写法是把类型或业务身份纳入键:

<KeepAlive>
  <component
    :is="currentComponent"
    :key="`${currentName}:${entityId}`"
  />
</KeepAlive>

例如:

const key = `${currentName}:${entityId}`
// UserList:42
// SettingsPanel:42

这样可以避免不同组件类型因为错误复用同一显式键而发生身份冲突。


4. includeexclude:按组件名称决定是否缓存

KeepAlive 的常用属性是:

<KeepAlive
  include="UserList,SettingsPanel"
  exclude="DebugPanel"
  :max="10"
>
  <component :is="currentComponent" />
</KeepAlive>

它们的含义是:

  • include:只有名称匹配的组件才允许进入缓存;
  • exclude:名称匹配的组件不进入缓存;
  • max:限制缓存条目数量。

includeexclude 作用于组件名称,不是组件变量名、路由名称或文件路径。

4.1 include 支持的类型

Vue 3 的 KeepAlive API 支持以下形式:

<KeepAlive include="UserList,SettingsPanel">
  <component :is="currentComponent" />
</KeepAlive>
<KeepAlive :include="/^(User|Settings)/">
  <component :is="currentComponent" />
</KeepAlive>
<KeepAlive :include="['UserList', 'SettingsPanel']">
  <component :is="currentComponent" />
</KeepAlive>

对应的匹配语义可以理解为:

cacheable(name)={false,nameexcludetrue,include 未设置match(name,include),include 已设置\operatorname{cacheable}(name) = \begin{cases} false, & name \in exclude \\ true, & include \text{ 未设置} \\ match(name, include), & include \text{ 已设置} \end{cases}

当同时配置 includeexclude 时,排除条件优先。也就是说,一个组件即使匹配 include,只要同时匹配 exclude,就不会被缓存。

4.2 组件名称从哪里来

组件名称可能来自:

  • Options API 中的 name
  • 单文件组件的组件名;
  • <script setup> 文件名推断出的名称;
  • 编译器或组件定义上的内部名称。

例如文件名为 UserList.vue

<script setup lang="ts">
import { ref } from 'vue'

const users = ref<string[]>([])
</script>

<template>
  <div>User list</div>
</template>

现代 Vue SFC 编译流程通常可以根据文件名推断组件名称 UserList。但在重命名文件、封装异步组件、使用高阶组件或跨构建工具链时,名称推断可能不够直观。

需要显式指定名称时,可以使用:

<script setup lang="ts">
defineOptions({
  name: 'UserList',
})
</script>

defineOptions() 是 Vue 3.3 引入的 <script setup> 编译器宏。项目如果低于 Vue 3.3,不能假设这个宏可用;可以改用普通 <script> 的组件选项:

<script lang="ts">
export default {
  name: 'UserList',
}
</script>

<script setup lang="ts">
// Composition API 逻辑
</script>

版本敏感点在于:include 匹配的是 Vue 最终识别到的组件名称,而不是 TypeScript 类型名,也不是 import UserList from './UserList.vue' 中的局部变量名。

4.3 include 不会阻止组件渲染

下面的配置:

<KeepAlive include="UserList">
  <component :is="currentComponent" />
</KeepAlive>

并不表示 SettingsPanel 不会显示。它表示:

  • UserList 可以被缓存;
  • SettingsPanel 可以正常渲染,但不进入缓存;
  • 离开 SettingsPanel 后,它通常会被卸载;
  • 再次进入 SettingsPanel 时,会重新创建实例。

因此,include 控制的是“是否缓存”,不是“是否允许渲染”。

4.4 动态修改 include 会清理缓存

<script setup lang="ts">
import { ref } from 'vue'

const enabled = ref(true)
</script>

<template>
  <button @click="enabled = !enabled">
    切换缓存策略
  </button>

  <KeepAlive :include="enabled ? ['UserList'] : []">
    <component :is="currentComponent" />
  </KeepAlive>
</template>

include 从包含 UserList 变为不包含 UserList 时,Vue 会清理不再匹配的缓存条目。被清理的非活动实例会被卸载;当前正在显示的实例不会因为属性刚刚变化就简单地从屏幕上消失,但后续切换时它不再按原策略保留。

这类动态清理是一个重要的内存治理手段,但不应把 include 当成任意缓存删除 API。它是声明式筛选条件,具体清理时机属于 Vue 内部实现行为,不应依赖未公开的缓存 Map 结构。


5. 生命周期:onActivatedonDeactivated

Composition API 提供两个与 KeepAlive 直接相关的生命周期钩子:

import {
  onActivated,
  onDeactivated,
} from 'vue'

5.1 onActivated

onActivated() 在组件被激活时调用。一个被 KeepAlive 缓存的组件:

  • 首次挂载时会经历挂载流程,并触发激活相关钩子;
  • 从缓存中重新显示时,会再次触发 onActivated()
  • 如果组件树中存在嵌套的可缓存组件,后代也可能收到激活通知。

因此,不能把 onActivated() 简单理解为“只执行一次的初始化函数”。

5.2 onDeactivated

onDeactivated() 在组件被停用时调用:

onDeactivated(() => {
  console.log('组件暂时离开当前视图')
})

它适合处理与“当前可见性”相关的资源,例如:

  • 停止轮询;
  • 暂停动画;
  • 取消当前页面专属订阅;
  • 停止监听某个全局事件;
  • 暂停高频计算。

但是,onDeactivated() 不代表组件已经死亡。组件状态和实例仍然可能在未来恢复。

5.3 onUnmounted 仍然必须保留

如果资源与组件实例本身绑定,就应在 onUnmounted() 中做最终兜底清理:

import {
  onActivated,
  onDeactivated,
  onUnmounted,
} from 'vue'

let timer: ReturnType<typeof setInterval> | undefined

function startTimer() {
  if (timer !== undefined) return

  timer = setInterval(() => {
    console.log('刷新数据')
  }, 5000)
}

function stopTimer() {
  if (timer === undefined) return

  clearInterval(timer)
  timer = undefined
}

onActivated(startTimer)
onDeactivated(stopTimer)
onUnmounted(stopTimer)

这里的因果关系是:

  1. 首次进入组件,onActivated() 启动定时器;
  2. 切换离开组件,onDeactivated() 停止定时器;
  3. 组件被真正销毁时,onUnmounted() 再次调用停止函数;
  4. stopTimer() 具有幂等性,重复调用不会造成错误。

如果只写:

onUnmounted(stopTimer)

那么组件被停用期间,定时器仍可能继续运行。缓存越多,后台继续运行的定时器越多。


6. 响应式副作用不会自动等同于“页面不可见”

一个常见误解是:

组件被 KeepAlive 停用后,组件里的所有逻辑都会自动暂停。

这并不可靠。

KeepAlive 主要控制组件实例和渲染分支的激活状态。组件内部自行创建的副作用,例如:

  • setInterval()
  • window.addEventListener()
  • WebSocket;
  • EventSource
  • 第三方图表实例;
  • watch() 中发起的持续任务;
  • 全局事件总线订阅;

并不会因为组件被停用就自动按照业务语义停止。必须由组件主动在激活和停用钩子中控制。

例如,全局事件监听:

import { onActivated, onDeactivated } from 'vue'

function handleVisibilityChange() {
  console.log(document.visibilityState)
}

onActivated(() => {
  document.addEventListener('visibilitychange', handleVisibilityChange)
})

onDeactivated(() => {
  document.removeEventListener('visibilitychange', handleVisibilityChange)
})

如果只注册不注销:

onMounted(() => {
  document.addEventListener('visibilitychange', handleVisibilityChange)
})

即使组件停用,监听器仍然可能存在。反复创建不同缓存实例后,还可能出现同一个业务事件被多个旧实例同时处理的问题。

对于 watch(),需要区分两个层次:

  • Vue 会管理组件作用域中的 watcher,在组件真正卸载时停止它;
  • 但“组件停用时是否应该继续观察并执行业务逻辑”,需要业务代码自行决定。

可以使用激活标志控制业务动作:

import { ref, watch, onActivated, onDeactivated } from 'vue'

const active = ref(false)
const keyword = ref('')

onActivated(() => {
  active.value = true
})

onDeactivated(() => {
  active.value = false
})

watch(keyword, value => {
  if (!active.value) return

  console.log('只在当前组件激活时搜索:', value)
})

如果 watcher 本身很昂贵,也可以在 onActivated() 中创建,在 onDeactivated() 中停止:

import {
  onActivated,
  onDeactivated,
  watch,
  type WatchStopHandle,
} from 'vue'

let stopWatch: WatchStopHandle | undefined

onActivated(() => {
  if (stopWatch) return

  stopWatch = watch(source, value => {
    console.log(value)
  })
})

onDeactivated(() => {
  stopWatch?.()
  stopWatch = undefined
})

7. 请求、并发和停用后的过期结果

缓存页面经常包含异步请求。停用组件时,请求可能仍在进行。如果请求完成后继续修改状态,结果不一定错误,但可能带来:

  • 用户已经切换到另一个页面,旧请求仍占用网络;
  • 返回结果覆盖了用户刚刚切换后的状态;
  • 多次激活产生多个并发请求;
  • 组件被缓存很久后,旧数据重新显示。

可以使用 AbortController 管理当前激活周期的请求:

<script setup lang="ts">
import { ref, onActivated, onDeactivated, onUnmounted } from 'vue'

const data = ref<string[]>([])
let controller: AbortController | undefined

async function loadUsers() {
  controller?.abort()

  controller = new AbortController()

  try {
    const response = await fetch('/api/users', {
      signal: controller.signal,
    })

    if (!response.ok) {
      throw new Error(`请求失败:HTTP ${response.status}`)
    }

    const result: string[] = await response.json()
    data.value = result
  } catch (error) {
    if (error instanceof DOMException && error.name === 'AbortError') {
      return
    }

    console.error('加载用户失败', error)
  }
}

onActivated(() => {
  void loadUsers()
})

onDeactivated(() => {
  controller?.abort()
  controller = undefined
})

onUnmounted(() => {
  controller?.abort()
  controller = undefined
})
</script>

<template>
  <ul>
    <li v-for="user in data" :key="user">
      {{ user }}
    </li>
  </ul>
</template>

这个示例的输入是组件激活事件,输出是用户列表。每次激活时:

  1. 取消上一次仍未完成的请求;
  2. 创建新的控制器;
  3. 发起请求;
  4. 请求成功后更新响应式数据;
  5. 请求被停用逻辑取消时,忽略 AbortError
  6. 组件最终卸载时再次兜底取消。

需要注意:是否在 onDeactivated() 取消请求取决于业务语义。如果页面希望回到后直接看到原请求结果,可以不取消,但应使用请求序列号、AbortController 或其他方式防止旧请求覆盖新请求。


8. max:缓存数量限制和近似 LRU 行为

max 用于限制缓存条目数:

<KeepAlive :max="3">
  <component :is="currentComponent" />
</KeepAlive>

它限制的是缓存条目,而不是组件类型数量的简单去重结果。一个组件类型配合多个 key,可以产生多个条目:

<DocumentEditor :key="documentId" />

如果依次打开 doc-adoc-bdoc-cdoc-d,即使组件类型始终是 DocumentEditor,也可能产生四个缓存条目。

Vue 的常见实现会维护缓存访问顺序,使 max 表现为近似 LRU(Least Recently Used,最近最少使用)策略:

  1. 新组件进入缓存;
  2. 缓存命中时更新访问顺序;
  3. 超过 max 时移除最早、最久未使用的条目;
  4. 被移除的组件会被真正卸载。

max="2" 为例:

操作 缓存条目 当前活动
打开 A A A
打开 B A、B B
回到 A B、A A
打开 C A、C C

打开 C 时,B 是最久未被访问的条目,因此会被淘汰。B 的实例销毁,并触发其卸载清理。

需要区分规范和实现:

  • max 的规范语义是限制缓存数量;
  • 具体内部数据结构和淘汰细节属于 Vue 实现;
  • 当前 Vue 3 实现通常表现为 LRU;
  • 业务代码不应直接访问 KeepAlive 内部缓存,也不应依赖内部字段名称。

max 不是越小越好。过小会导致用户频繁返回时重新初始化页面,过大则会保留更多状态和资源。应根据单个缓存实例保留的状态规模、用户访问路径和可接受的重建成本选择。


9. 完整示例:带 include、稳定 key 和生命周期治理

下面给出一个可以放入 Vite Vue 3 项目的示例。

9.1 UserList.vue

<script setup lang="ts">
import {
  onActivated,
  onDeactivated,
  onUnmounted,
  ref,
} from 'vue'

defineOptions({
  name: 'UserList',
})

const keyword = ref('')
const refreshCount = ref(0)

let timer: ReturnType<typeof setInterval> | undefined

function startRefresh() {
  if (timer !== undefined) return

  timer = setInterval(() => {
    refreshCount.value += 1
  }, 3000)
}

function stopRefresh() {
  if (timer === undefined) return

  clearInterval(timer)
  timer = undefined
}

onActivated(() => {
  console.log('UserList activated')
  startRefresh()
})

onDeactivated(() => {
  console.log('UserList deactivated')
  stopRefresh()
})

onUnmounted(() => {
  console.log('UserList unmounted')
  stopRefresh()
})
</script>

<template>
  <section>
    <h2>用户列表</h2>

    <label>
      搜索:
      <input v-model="keyword" />
    </label>

    <p>搜索词会在切换页面后保留:{{ keyword }}</p>
    <p>激活期间刷新次数:{{ refreshCount }}</p>
  </section>
</template>

9.2 SettingsPanel.vue

<script setup lang="ts">
import { ref } from 'vue'

defineOptions({
  name: 'SettingsPanel',
})

const darkMode = ref(false)
</script>

<template>
  <section>
    <h2>设置</h2>

    <label>
      <input v-model="darkMode" type="checkbox" />
      深色模式
    </label>

    <p>设置状态:{{ darkMode ? '开启' : '关闭' }}</p>
  </section>
</template>

9.3 App.vue

<script setup lang="ts">
import { computed, ref } from 'vue'
import UserList from './components/UserList.vue'
import SettingsPanel from './components/SettingsPanel.vue'

type TabName = 'users' | 'settings'

const currentTab = ref<TabName>('users')

const tabs = {
  users: {
    id: 'users',
    component: UserList,
  },
  settings: {
    id: 'settings',
    component: SettingsPanel,
  },
} as const

const currentComponent = computed(() => tabs[currentTab.value].component)
const currentKey = computed(() => tabs[currentTab.value].id)
</script>

<template>
  <nav>
    <button @click="currentTab = 'users'">
      用户
    </button>

    <button @click="currentTab = 'settings'">
      设置
    </button>
  </nav>

  <KeepAlive
    :include="['UserList', 'SettingsPanel']"
    :max="2"
  >
    <component
      :is="currentComponent"
      :key="currentKey"
    />
  </KeepAlive>
</template>

这个示例成立的原因如下:

  1. UserListSettingsPanel 都有明确组件名称;
  2. include 数组中的字符串与组件名称一致;
  3. key 稳定地区分当前标签;
  4. 切换标签时,旧组件进入停用状态;
  5. 返回旧标签时,搜索词、复选框状态和其他响应式状态得以恢复;
  6. UserList 的定时器只在激活期间运行;
  7. max="2" 防止缓存条目无限增长。

如果把 include 改成:

<KeepAlive :include="['UserList']">

SettingsPanel 仍然能显示,但切换离开后不会被保留;再次进入时,darkMode 会重新初始化为 false


10. 路由页面中的缓存键

在 Vue Router 中,KeepAlive 通常与路由出口结合:

<router-view v-slot="{ Component }">
  <KeepAlive :include="['UserPage', 'OrderPage']">
    <component :is="Component" />
  </KeepAlive>
</router-view>

这里需要注意两个独立问题:

10.1 路由组件名称必须匹配

include 匹配的是路由组件本身的名称,例如:

<script setup lang="ts">
defineOptions({
  name: 'UserPage',
})
</script>

它不匹配:

  • 路由记录的 name
  • URL 路径 /users
  • 菜单名称“用户管理”。

路由记录可以叫:

{
  name: 'users',
  path: '/users',
  component: UserPage,
}

KeepAliveinclude 仍应写:

<KeepAlive include="UserPage">

10.2 同一路由组件是否需要多个缓存实例

假设 /users/1/users/2 都使用 UserPage。如果希望它们各自保留独立状态,就需要使用稳定且能区分参数的键。

具体键的写法取决于项目使用的 Vue Router 版本、路由出口写法和页面语义。一个明确表达意图的方式是:

<router-view v-slot="{ Component, route }">
  <KeepAlive include="UserPage">
    <component
      :is="Component"
      :key="route.fullPath"
    />
  </KeepAlive>
</router-view>

这样 /users/1/users/2 使用不同的键。

但如果查询参数变化不应该创建新页面实例,fullPath 就可能过细。例如 /users/1?sort=name/users/1?sort=date 会产生不同键。此时可以根据业务选择:

<component
  :is="Component"
  :key="`${route.name}:${route.params.id}`"
/>

核心原则不是固定使用某一种路由键,而是先回答:

哪些路由状态应该共享一个组件实例,哪些状态必须隔离?

如果答案是“同一个用户 ID 共享,不同用户 ID 隔离”,就应把用户 ID 纳入键,而不是盲目使用完整 URL。


11. 常见失败表现和诊断方法

11.1 include 配置了,但组件仍然每次重新初始化

可能原因:

  1. include 名称与组件实际名称不一致;
  2. 组件被异步包装器包裹后,匹配到的名称不是预期名称;
  3. key 每次变化;
  4. KeepAlive 被放在了错误的组件层级;
  5. 实际发生的是组件卸载,而不是停用。

诊断时可以先显式设置名称:

defineOptions({
  name: 'UserList',
})

再通过生命周期日志确认:

onMounted(() => console.log('mounted'))
onActivated(() => console.log('activated'))
onDeactivated(() => console.log('deactivated'))
onUnmounted(() => console.log('unmounted'))

如果切换时日志是:

mounted
activated
deactivated
activated

说明实例被缓存并重新激活。

如果日志是:

mounted
unmounted
mounted
unmounted

说明实例没有被保留。此时应检查 includekey、组件树结构和 KeepAlive 是否确实包裹了动态组件。

11.2 include 写成路由名称

错误示例:

<KeepAlive include="users">
  <router-view />
</KeepAlive>

如果组件名称是 UserPage,路由记录名称是 users,这两个字符串不等价。应该写:

<KeepAlive include="UserPage">
  <router-view />
</KeepAlive>

或者在插槽形式下包裹实际组件:

<router-view v-slot="{ Component }">
  <KeepAlive include="UserPage">
    <component :is="Component" />
  </KeepAlive>
</router-view>

11.3 页面返回后状态保留,但请求仍然不断发出

这是停用和卸载被混淆的典型表现。缓存保留了实例,但组件中的轮询、订阅或 watcher 仍然工作。

处理方式是把资源分为两类:

资源 适合清理时机
当前页面可见时才需要的轮询 onDeactivated
当前页面可见时才需要的 DOM 监听 onDeactivated
组件实例最终持有的第三方对象 onUnmounted
激活期间的请求 根据业务决定取消或保留
WebSocket 页面订阅 通常在停用时取消,在激活时恢复

11.4 max 很小导致页面状态频繁丢失

max 淘汰会触发真正卸载。例如:

<KeepAlive :max="1">
  <component :is="currentComponent" />
</KeepAlive>

只能保留一个缓存条目。A 切换到 B 时,A 很可能马上被淘汰;再次回到 A,A 已经不是原实例。

这不是 Vue 丢失状态,而是配置明确要求缓存容量为 1。应根据状态恢复成本调整 max,或只把真正需要恢复的组件放入 include

11.5 用随机 key 强制刷新,结果缓存越来越多

有些代码会这样“刷新页面”:

<component :is="Component" :key="Date.now()" />

在普通渲染中,变化的 key 确实可以强制创建新实例;放在 KeepAlive 下时,变化的键还会制造新的缓存身份。短时间内多次改变会让旧条目进入缓存,直到被 max 淘汰。

如果目标只是重置当前组件,应明确选择一种策略:

  • 不使用 KeepAlive
  • 使用稳定但业务可控的版本号键;
  • 改变组件内部状态;
  • 通过 include 变化让目标缓存失效;
  • 调整页面结构,使组件真正卸载。

不要使用无界随机键作为常规刷新机制。


12. 内存治理:缓存的是组件实例及其引用图

KeepAlive 缓存的不只是几个字符串状态。一个组件实例可能引用整个对象图:

Mtotali=1n(Minstance,i+Mstate,i+Mdom,i+Mresource,i)M_{\text{total}} \approx \sum_{i=1}^{n} \left( M_{\text{instance},i} + M_{\text{state},i} + M_{\text{dom},i} + M_{\text{resource},i} \right)

其中:

  • nn 是缓存条目数;
  • MinstanceM_{\text{instance}} 是组件实例和响应式依赖占用;
  • MstateM_{\text{state}} 是表单、列表、编辑草稿等业务数据;
  • MdomM_{\text{dom}} 是保留的 DOM 结构;
  • MresourceM_{\text{resource}} 是定时器、订阅、图表、编辑器等外部资源。

这个公式不是 Vue 的精确内存计算公式,而是帮助判断风险的模型。即使单个组件看起来很小,以下页面也可能很重:

  • 大型表格,保留大量行数据;
  • 富文本编辑器,保留编辑器实例和撤销栈;
  • 图表页面,保留 Canvas、数据集和事件监听;
  • 多个路由参数对应多个缓存键;
  • 页面中存在未清理的 WebSocket 或全局订阅。

12.1 用 max 限制上界

如果每个缓存条目占用内存为 mim_i,缓存数量上限为 NN,则:

Mcachei=1NmiM_{\text{cache}} \leq \sum_{i=1}^{N} m_i

NN 有明确上限时,缓存规模至少在条目数量上可控。虽然单个页面大小仍然可能差异很大,但相比无限制缓存,风险边界更清晰。

12.2 用 include 缩小缓存范围

如果只有用户输入表单需要保留,就不应把所有路由页面都放进缓存:

<router-view v-slot="{ Component }">
  <KeepAlive :include="['SearchPage', 'EditorPage']" :max="5">
    <component :is="Component" />
  </KeepAlive>
</router-view>

这里的取舍是:

  • SearchPage 保留搜索条件和分页位置;
  • EditorPage 保留未提交草稿;
  • 报表、详情和临时提示页不缓存;
  • 未缓存页面返回时重新获取数据。

缓存策略应该由“恢复状态的价值”决定,而不是由“组件能否缓存”决定。

12.3 将大数据放到外部状态管理时要重新计算生命周期

如果组件被缓存,但列表数据放在 Pinia 或其他全局 store 中,组件本身虽然被淘汰,数据仍可能继续留在 store 中。此时清理 KeepAlive 并不等于清理业务数据。

需要分别治理:

  1. 组件实例和 DOM;
  2. 组件本地响应式状态;
  3. 全局 store;
  4. 请求缓存;
  5. 第三方实例和浏览器资源。

例如,路由页面卸载后是否清除 store,应由业务生命周期决定,不能假设 onUnmounted() 会自动清理全局状态。


13. KeepAlive 与异步组件、Suspense 的边界

异步组件可以被 KeepAlive 包裹:

import { defineAsyncComponent } from 'vue'

const ReportPage = defineAsyncComponent(() =>
  import('./ReportPage.vue')
)
<KeepAlive>
  <component :is="ReportPage" />
</KeepAlive>

这里缓存的是异步组件加载成功后形成的组件实例。KeepAlive 不负责:

  • 捕获异步加载失败;
  • 自动重试网络请求;
  • 替代加载状态;
  • 替代错误边界。

需要通过 defineAsyncComponent() 的加载失败配置、Suspense 或应用级错误处理机制解决这些问题。错误处理可通过组件的 onErrorCaptured()app.config.errorHandler 组织,但这些能力与 KeepAlive 的缓存机制是两条独立链路。

KeepAlive 也不等同于浏览器级页面缓存。它不会把状态持久化到:

  • localStorage;
  • IndexedDB;
  • URL;
  • 服务端;
  • 浏览器关闭后的恢复存储。

刷新页面后,内存中的组件实例会丢失。需要跨刷新恢复时,应使用明确的数据持久化方案。


14. 什么时候不应该使用 KeepAlive

下面几类页面通常不适合无条件缓存:

14.1 状态本身很重

例如包含几十万条数据、复杂编辑器或大型图形场景。缓存会减少重建成本,但会增加长期驻留成本。

14.2 页面必须在返回时重新校验权限和数据

缓存会保留旧视图。即使返回时重新请求数据,也要明确处理:

  • 权限变化;
  • 资源被删除;
  • 服务端状态变更;
  • 当前用户身份切换。

可以在 onActivated() 中执行轻量校验,而不是把缓存当作数据始终有效的证明。

14.3 页面包含不适合暂停的实时资源

例如全局唯一的实时连接、需要持续接收消息的页面。此时应把连接提升到合适的应用级管理位置,而不是让多个停用组件各自持有一条连接。

14.4 页面状态恢复反而会造成误导

某些确认页、支付页、临时操作页不应恢复旧表单或旧结果。此时重新挂载是更符合用户预期的行为。


15. 一套可验证的设计流程

设计一个使用 KeepAlive 的页面时,可以按以下顺序推导,而不是先随意添加组件标签。

第一步:定义状态身份

先确定哪些对象代表不同页面实例:

同一个用户 ID:共享实例
不同用户 ID:隔离实例

据此选择:

:key="userId"

如果不同组件类型可能共用业务 ID,则使用:

:key="`${componentName}:${userId}`"

第二步:定义允许缓存的组件集合

明确哪些页面值得保留:

<KeepAlive :include="['UserPage', 'EditorPage']">

确认这些字符串是组件名称,而不是路由名称。

第三步:定义容量上限

根据页面数量和状态重量设置:

<KeepAlive :max="5">

如果页面状态大小差异明显,应优先缩小 include 范围,而不是只提高 max

第四步:按停用和卸载分别管理副作用

onActivated(startPageResources)
onDeactivated(stopPageResources)
onUnmounted(stopAllResources)

每个停止函数都应尽量幂等,避免由于多次生命周期调用造成异常。

第五步:用生命周期日志和内存工具验证

开发阶段至少验证:

  • 返回页面时表单状态是否保留;
  • 是否触发 deactivated 而不是 unmounted
  • max 淘汰后是否触发 unmounted
  • 定时器、监听器和请求是否在停用后停止;
  • 切换大量不同 key 后内存是否持续增长。

浏览器 DevTools 的 Memory 面板可以辅助观察堆快照,Vue Devtools 可以观察组件树和组件状态。诊断重点不是“组件是否存在”,而是:

  1. 缓存键是否按预期复用;
  2. 不需要的条目是否被淘汰;
  3. 被淘汰后是否仍有外部引用阻止回收;
  4. 组件内部资源是否在停用和卸载时正确释放。

KeepAlive 的核心价值是保留有价值的交互状态;它的核心风险是把组件生命周期从“离开即销毁”改成“离开但继续驻留”。只要缓存键、名称筛选、生命周期和资源边界都被明确建模,KeepAlive 就不是一个模糊的“页面不刷新”开关,而是一套可预测的组件实例复用机制。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。