前置知识: JavaScript、HTML5、CSS

KeepAlive 缓存与生命周期

8 min高级

Vue 3 KeepAlive 组件缓存机制完整解析:include/exclude/max、activated/deactivated 生命周期、缓存刷新与内存管理。

前置知识

学习目标

  • 掌握「1. 历史动机与发展脉络」的核心机制、典型用法与常见陷阱
  • 掌握「2. 形式化定义」的核心机制、典型用法与常见陷阱
  • 掌握「3. 理论推导与原理解析」的核心机制、典型用法与常见陷阱
  • 掌握「4. 代码示例(带详尽注释)」的核心机制、典型用法与常见陷阱
  • 掌握「5. 对比分析」的核心机制、典型用法与常见陷阱

1. 历史动机与发展脉络

SPA 中组件随路由切换频繁创建与销毁。Vue 2 时期,开发者用 <keep-alive> 包裹动态组件保存状态,但只能缓存组件树中的组件;Vue Router 场景则需要 keep-alive 包裹 <router-view>,配合路由 meta 判断。Vue 3 保留 <KeepAlive>(PascalCase 命名),内部实现基于 MoveType 的移动缓存:被缓存组件卸载时以“失活”状态移入隐藏容器,而不是销毁。

Vue 3 的 KeepAlive 实现与 Suspense、异步组件深度集成:defineAsyncComponent 加载完成的组件可以被缓存;KeepAlive 内的组件卸载(unmount)时,若命中缓存则只执行 deactivated 而不执行 unmounted。Vue 3.4 后缓存渲染器的内部调度优化进一步减少了失活/激活的抖动。

timeline
    title KeepAlive 演进
    Vue 2.0 : <keep-alive> 支持动态组件缓存
    Vue 2.6 : 与 vue-router 配合的 keep-alive 模式流行
    Vue 3.0 : <KeepAlive> 内置组件,include/exclude/max
    Vue 3.2 : onActivated/onDeactivated 组合式 API
    Vue 3.4 : 缓存调度优化,过渡配合更稳定

2. 形式化定义

<KeepAlive> 是 Vue 内置组件,其行为形式化描述为:对直接子组件(通常只有一个动态子组件或 router-view)建立缓存表,键为子组件的类型标识(name 或定义对象);当子组件卸载时,若命中 include/exclude 规则且缓存表未满,将其 vnode 与实例移入缓存容器;当子组件重新渲染时,若命中缓存,复用实例并触发 activated。

props:

include:字符串、正则或数组,匹配组件 name。匹配成功的组件才会被缓存;

exclude:同上,匹配成功的组件不被缓存。exclude 优先级高于 include;

max:数字,最大缓存实例数。超过时按 LRU(最近最少使用)淘汰最久未激活的缓存。

生命周期契约:被缓存组件在离开视图时触发 onDeactivated,重新进入时触发 onActivated;onMounted/onUnmounted 只在首次创建与最终销毁时各执行一次。

flowchart TD
    A["组件渲染"] --> B{"KeepAlive 缓存命中?"}
    B -- "是" --> C["复用实例,触发 onActivated"]
    B -- "否" --> D["创建新实例,触发 onMounted"]
    C --> E["组件离开视图"]
    D --> E
    E --> F{"符合 include/exclude?"}
    F -- "是且未超 max" --> G["移入缓存,触发 onDeactivated"]
    F -- "否或超限" --> H["正常卸载,触发 onUnmounted"]
    G --> A

3. 理论推导与原理解析

3.1 缓存键与匹配规则

Vue 3 的 KeepAlive 使用 getComponentName 获取组件 name 作为匹配依据;未声明 name 的组件可以退化为组件定义对象自身。include 匹配采用字符串精确匹配、正则 test 或数组遍历。<script setup> 组件默认文件名即 name(Vue 3.2.34+ 支持通过 defineOptions({ name }) 显式声明)。

3.2 LRU 淘汰推导

缓存表是一个 Map(有序键值)。每次命中时把键移到末尾(最近使用);插入新缓存且数量超过 max 时,删除表头键(最久未使用)。推导可知:max=10 时,第 11 个组件进入会淘汰第 1 个,被淘汰组件真正卸载并触发 onUnmounted。

3.3 与路由的协作

<router-view v-slot="{ Component }"><KeepAlive><component :is="Component" /></KeepAlive></router-view> 是路由缓存的推荐形态。路由切换时,新路由组件实例进入视图,旧路由组件被缓存。activated 可用于判断“从缓存恢复”,从而决定是否刷新数据。

4. 代码示例(带详尽注释)

4.1 基础缓存

<script setup>
import { ref, onActivated, onDeactivated, onMounted, onUnmounted } from 'vue'

// 列表数据与滚动位置
const list = ref([])
const scrollTop = ref(0)

// 首次创建时加载数据
onMounted(async () => {
  console.log('首次挂载')
  list.value = await fetchList()
})

// 从缓存恢复时:恢复滚动位置,可按需刷新
onActivated(() => {
  console.log('从缓存激活')
  window.scrollTo(0, scrollTop.value)
})

// 离开视图进入缓存时:记录滚动位置
onDeactivated(() => {
  scrollTop.value = window.scrollY
})

// 缓存被淘汰或组件最终销毁时触发
onUnmounted(() => {
  console.log('真正卸载')
})
</script>

<template>
  <div>
    <div v-for="item in list" :key="item.id" class="item">{{ item.title }}</div>
  </div>
</template>

讲解:四个生命周期钩子的分工:onMounted 只执行一次(首次),onActivated 每次从缓存恢复都执行,onDeactivated 每次进入缓存执行,onUnmounted 只在淘汰时执行。这个对比是理解 KeepAlive 的关键。

4.2 include/exclude 控制

<script setup>
import { ref } from 'vue'
import ListPage from './ListPage.vue'
import DetailPage from './DetailPage.vue'

// 只缓存列表页,不缓存详情页
const cachedPages = ref(['ListPage'])
</script>

<template>
  <!-- include 使用逗号分隔字符串、正则或数组 -->
  <KeepAlive :include="cachedPages">
    <component :is="currentPage" />
  </KeepAlive>
</template>

讲解:include 动态变化时,被移出名单的缓存组件会立即被销毁(触发 unmounted),这是清理缓存的标准手段。

4.3 max 与 LRU

<template>
  <!-- 最多缓存 5 个页面,超出按最近最少使用淘汰 -->
  <KeepAlive :max="5">
    <router-view />
  </KeepAlive>
</template>

讲解:max 保护内存。用户在标签页系统中打开大量页面时,最久未访问的页面被自动销毁,避免内存无限增长。

4.4 与路由 meta 结合

<script setup>
import { useRoute } from 'vue-router'
const route = useRoute()
</script>

<template>
  <!-- 通过路由 meta.keepAlive 决定是否缓存 -->
  <KeepAlive :include="route.meta.keepAlive ? [route.name] : []">
    <router-view />
  </KeepAlive>
</template>

讲解:把缓存策略放进路由配置:meta: { keepAlive: true } 的页面缓存,其余不缓存。路由表成为缓存策略的单一事实来源。

4.5 缓存清理

<script setup>
import { ref, watch } from 'vue'

// 需要缓存的页面名列表
const keep = ref(['ListPage'])

// 用户点击“刷新”时,先清空缓存再重新进入
function refreshList() {
  keep.value = []
  // 下一帧恢复缓存名单,让组件重新创建
  requestAnimationFrame(() => {
    keep.value = ['ListPage']
  })
}
</script>

<template>
  <KeepAlive :include="keep">
    <router-view />
  </KeepAlive>
</template>

讲解:include 移除即销毁缓存实例,恢复名单后下次进入创建新实例,实现“强制刷新”。这是清理陈旧数据的官方推荐模式。

4.6 与 Teleport 协作

<template>
  <KeepAlive>
    <router-view v-slot="{ Component }">
      <!-- Teleport 内容也随缓存生命周期管理 -->
      <Teleport to="body">
        <component :is="Component" />
      </Teleport>
    </router-view>
  </KeepAlive>
</template>

讲解:KeepAlive 与 Teleport 可以组合:被缓存的页面即使 DOM 挂在 body 下,失活时也会整体移入缓存容器,不会残留浮层。

4.7 缓存与异步组件

<script setup>
import { defineAsyncComponent } from 'vue'

// 懒加载的重型页面组件
const HeavyPage = defineAsyncComponent(() => import('./HeavyPage.vue'))
</script>

<template>
  <KeepAlive :max="3">
    <component :is="HeavyPage" />
  </KeepAlive>
</template>

讲解:异步组件加载完成后可以被 KeepAlive 缓存;再次进入不需要重新发起网络请求。适合图表、编辑器等昂贵页面。

5. 对比分析

5.1 缓存组件与普通组件生命周期对比

阶段普通组件KeepAlive 缓存组件
首次进入mountedmounted + activated
离开视图unmounteddeactivated
再次进入重新创建 + mountedactivated(复用)
最终销毁unmountedunmounted(淘汰时)

5.2 KeepAlive 与手动状态提升

把状态提升到 Pinia/父组件也能保留数据,但 DOM 状态(滚动位置、输入焦点、动画)需要手动恢复;KeepAlive 保留完整实例与 DOM,代价是内存。数据轻、DOM 重时用 KeepAlive;数据重、DOM 轻时用状态管理。

5.3 与 React 生态对比

React 没有内置 KeepAlive 等价物,社区方案(react-activation)模拟类似行为;Next.js 的 App Router 缓存的是 RSC 数据而非组件实例。Vue 的 KeepAlive 在“保留完整组件状态”这一点上仍是独有优势。

6. 常见陷阱与最佳实践

陷阱一:组件未声明 name,include 匹配失败。<script setup> 组件需 defineOptions({ name: 'Xxx' })。

陷阱二:把需要实时刷新的数据放进缓存组件,恢复后数据陈旧。最佳实践:onActivated 中按策略刷新。

陷阱三:缓存大量重型组件导致内存膨胀。最佳实践:设置 max,动态调整 include 名单。

陷阱四:在 onDeactivated 中执行销毁逻辑(如清除定时器),导致再次激活时功能缺失。定时器应继续运行或在 activated 重建。

陷阱五:KeepAlive 直接包裹多个子元素。KeepAlive 只缓存直接子组件,多子元素时应使用单根组件包裹或 v-if 切换。

陷阱六:与 Transition 组合时顺序错误。推荐 <Transition><KeepAlive>...</KeepAlive></Transition> 的顺序(KeepAlive 在内),并确认过渡模式。

7. 工程实践

7.1 标签页系统的缓存策略

// tabs.ts:标签页状态管理(Pinia)
import { defineStore } from 'pinia'

export const useTabsStore = defineStore('tabs', {
  state: () => ({
    // 已打开标签
    tabs: [] as Array<{ name: string; title: string }>,
    // 缓存名单:默认全部缓存,可单独关闭
    cacheable: new Set<string>()
  }),
  actions: {
    openTab(tab: { name: string; title: string }) {
      if (!this.tabs.some((t) => t.name === tab.name)) {
        this.tabs.push(tab)
        this.cacheable.add(tab.name)
      }
    },
    closeTab(name: string) {
      this.tabs = this.tabs.filter((t) => t.name !== name)
      // 关闭标签同时从缓存名单移除,销毁实例
      this.cacheable.delete(name)
    }
  }
})

讲解:cacheable 集合与 KeepAlive 的 include 绑定:打开标签加入缓存,关闭标签移除缓存(触发销毁)。标签页系统的内存与状态由此闭环管理。

7.2 表单草稿保留

表单页使用 KeepAlive 缓存后,用户误点返回再前进时草稿自动保留。配合 onDeactivated 记录离开时间,onActivated 判断是否提示“继续编辑或重置”。

8. 案例研究:带缓存的多标签文档站

需求:文档站支持多个文档标签页,切换不丢失阅读位置与搜索状态,最多同时缓存 5 个标签。

<template>
  <KeepAlive :include="tabNames" :max="5">
    <RouterView v-slot="{ Component }">
      <component :is="Component" />
    </RouterView>
  </KeepAlive>
</template>

<script setup>
import { computed } from 'vue'
import { useTabsStore } from '@/stores/tabs'

const tabs = useTabsStore()
// 缓存名单 = 当前打开的标签页名称
const tabNames = computed(() => [...tabs.cacheable])
</script>

讲解:路由视图被 KeepAlive 包裹,include 绑定标签状态。用户切换标签时,页面实例与滚动位置原样保留;关闭标签时实例销毁释放内存。max=5 兜底防止异常场景下的内存膨胀。

配套:每个页面在 onActivated 中检查数据版本,若全局数据版本变化(如文档更新)则局部刷新,兼顾缓存体验与数据新鲜度。

9. 知识要点总结与深入讲解

KeepAlive 的本质是“实例级缓存”:缓存的是组件实例与 DOM,而不是序列化数据。因此它能保留滚动位置、输入焦点、动画状态等难以手动保存的运行时状态。

生命周期的关键词是“失活”与“激活”:deactivated 不是销毁,activated 不是重建。判断逻辑该放在哪个钩子,取决于“只执行一次”还是“每次进出都执行”。

缓存管理三件套:include 控制谁缓存,exclude 排除谁,max 限制总量。动态修改 include 是清理缓存的官方途径;理解 LRU 淘汰机制可以解释 max 的行为。

1. KeepAlive 基础

1.1 基本用法

<RouterView v-slot="{ Component }">
  <KeepAlive>
    <component :is="Component" />
  </KeepAlive>
</RouterView>

1.2 缓存策略

<!-- 缓存指定组件 -->
<KeepAlive include="UserList,Settings">
  <component :is="current" />
</KeepAlive>

<!-- 排除指定组件 -->
<KeepAlive exclude="Login">
  <component :is="current" />
</KeepAlive>

<!-- 最大缓存数 -->
<KeepAlive :max="10">
  <component :is="current" />
</KeepAlive>

1. 生命周期钩子

import { onActivated, onDeactivated } from 'vue';

export default {
  setup() {
    onActivated(() => {
      console.log('组件被激活');
    });

    onDeactivated(() => {
      console.log('组件被停用');
    });
  },
};
钩子触发时机
onActivated组件从缓存激活时
onDeactivated组件被缓存停用时

2. 缓存刷新

// 需要刷新缓存时,移除 include 中的组件名
const cachedViews = ref(['UserList', 'Settings']);

function refreshCache(name) {
  cachedViews.value = cachedViews.value.filter((v) => v !== name);
  nextTick(() => {
    cachedViews.value.push(name);
  });
}

KeepAlive 基础

KeepAlive 缓存组件

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

<script setup>
import { ref, computed } from 'vue';
import CompA from './CompA.vue';
import CompB from './CompB.vue';

const tab = ref('A');
const currentComponent = computed(() => tab.value === 'A' ? CompA : CompB);
</script>

KeepAlive 配合 router-view

<template>
  <KeepAlive>
    <router-view />
  </KeepAlive>
</template>

Props

include 包含 <KeepAlive include="<name1>, <name2>">

<!-- 缓存指定名称的组件 -->
<KeepAlive include="CompA,CompB">
  <component :is="current" />
</KeepAlive>

<!-- 数组形式 -->
<KeepAlive :include="['CompA', 'CompB']">
  <component :is="current" />
</KeepAlive>

<!-- 正则 -->
<KeepAlive :include="/^Comp/">
  <component :is="current" />
</KeepAlive>

exclude 排除 <KeepAlive exclude="<name1>, <name2>">

<KeepAlive exclude="CompC">
  <component :is="current" />
</KeepAlive>

<KeepAlive :exclude="['CompC', 'CompD']">
  <component :is="current" />
</KeepAlive>

<KeepAlive :exclude="/^Admin/">
  <component :is="current" />
</KeepAlive>

max 最大缓存数 <KeepAlive :max="<number>">

<KeepAlive :max="10">
  <component :is="current" />
</KeepAlive>
<!-- 超过 10 个时,LRU 淘汰最久未访问的 -->

组合使用

<KeepAlive :include="['CompA', 'CompB']" :max="5">
  <component :is="current" />
</KeepAlive>

缓存组件命名

defineOptions 指定 name

<script setup>
defineOptions({
  name: 'CompA'
});
</script>

defineComponent 指定 name

export default defineComponent({
  name: 'CompA',
  setup() { /* ... */ }
});

单文件组件文件名自动推断

<!-- CompA.vue -->
<!-- 默认 name 推断为 CompA -->
<script setup>
</script>

生命周期钩子

onActivated 缓存激活 onActivated(<callback>);

import { onActivated } from 'vue';

onActivated(() => {
  console.log('组件从缓存激活');
  refreshData();
  resumeTimer();
});

onDeactivated 缓存停用 onDeactivated(<callback>);

import { onDeactivated } from 'vue';

onDeactivated(() => {
  console.log('组件被缓存(停用)');
  pauseTimer();
});

钩子执行顺序

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

// 首次渲染:
//   onMounted -> onActivated
// 切换到其他组件:
//   onDeactivated
// 切换回来:
//   onActivated
// 完全销毁:
//   onDeactivated -> onUnmounted

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

KeepAlive 实战模式

列表页 + 详情页缓存

<template>
  <KeepAlive :include="['ListPage']">
    <router-view />
  </KeepAlive>
</template>
vue
<!-- ListPage.vue -->
<script setup>
import { ref, onActivated, onDeactivated } from 'vue';

const scrollPos = ref(0);
const list = ref([]);

onActivated(() => {
  // 恢复滚动位置
  window.scrollTo(0, scrollPos.value);
});

onDeactivated(() => {
  // 保存滚动位置
  scrollPos.value = window.scrollY);
});
</script>

条件缓存(动态 include)

<template>
  <KeepAlive :include="cachedNames">
    <component :is="currentComp" />
  </KeepAlive>
</template>

<script setup>
import { ref, computed } from 'vue';

const keepAliveList = ref(['Home', 'List']);

const cachedNames = computed(() => {
  return keepAliveList.value;
});

function clearCache(name) {
  keepAliveList.value = keepAliveList.value.filter(n => n !== name);
}
</script>

缓存控制 API

通过组件实例访问 cache

import { getCurrentInstance } from 'vue';

const instance = getCurrentInstance();
// instance.cache 是内部缓存 Map,不推荐直接操作

max + LRU 淘汰策略

<!-- 最多缓存 3 个,最久未访问的被淘汰 -->
<KeepAlive :max="3">
  <component :is="current" />
</KeepAlive>

注意事项

必须配合动态组件或 router-view

<!-- 正确 -->
<KeepAlive>
  <component :is="current" />
</KeepAlive>

<!-- 正确 -->
<KeepAlive>
  <router-view />
</KeepAlive>

<!-- 错误:单个静态组件 -->
<KeepAlive>
  <StaticComp />
</KeepAlive>
<!-- 不会报错但毫无意义 -->

v-if 与 KeepAlive 配合

<KeepAlive>
  <CompA v-if="showA" />
  <CompB v-else />
</KeepAlive>

注意 props include/exclude 匹配

<!-- 必须确保组件 name 与 include 字符串完全匹配 -->
<script setup>
defineOptions({ name: 'UserProfile' });
</script>

<!-- 父组件 -->
<KeepAlive include="UserProfile">
  <UserProfile />
</KeepAlive>

综合应用

Tab 切换缓存

<template>
  <div class="tabs">
    <button
      v-for="tab in tabs"
      :key="tab.name"
      @click="current = tab.name"
      :class="{ active: current === tab.name }"
    >
      {{ tab.label }}
    </button>
  </div>

  <KeepAlive :max="5">
    <component :is="currentComp" />
  </KeepAlive>
</template>

<script setup>
import { ref, computed, markRaw } from 'vue';
import Home from './Home.vue';
import List from './List.vue';
import Detail from './Detail.vue';

const tabs = [
  { name: 'home', label: '首页', comp: markRaw(Home) },
  { name: 'list', label: '列表', comp: markRaw(List) },
  { name: 'detail', label: '详情', comp: markRaw(Detail) }
];

const current = ref('home');
const currentComp = computed(() =>
  tabs.find(t => t.name === current.value)?.comp
);
</script>

onActivated 数据刷新

<script setup>
import { ref, onActivated } from 'vue';

const lastActiveTime = ref<Date | null>(null);
const data = ref([]);

async function loadData() {
  data.value = await fetch('/api/data').then(r => r.json());
}

onActivated(async () => {
  const now = new Date();
  // 距离上次激活超过 30 秒,刷新数据
  if (!lastActiveTime.value ||
      now.getTime() - lastActiveTime.value.getTime() > 30000) {
    await loadData();
  }
  lastActiveTime.value = now;
});
</script>