keep-alive 组件原理详解
在 Vue 项目中,你是否遇到过这样的场景:从列表页进入详情页后返回,列表页的滚动位置丢失了,搜索条件也被重置了?或者 Tab 切换时,每次切回来组件都要重新请求数据?这些问题的根源在于 组件被销毁又重新创建 了,而 <keep-alive> 正是 Vue 提供的解决方案。
1. 什么是 keep-alive?
<keep-alive> 是 Vue 内置的一个抽象组件,它的作用是:缓存不活动的组件实例,而不是销毁它们。
keep-alive 让组件切换时不销毁,而是放入缓存。再次显示时直接从缓存取出,跳过组件的创建和挂载过程,既保留了状态又提升了性能。
什么是"抽象组件"?
抽象组件有两个特点:
- 不渲染真实 DOM:
<keep-alive>不会在页面中产生任何 DOM 元素,它只是逻辑层面的包装 - 不出现在组件链中:在
$parent/$children关系中会被跳过
// Vue 源码中的定义
export default {
name: 'keep-alive',
abstract: true, // 标记为抽象组件
// ...
}
2. 基本用法
2.1 最简单的使用
<template>
<!-- 动态组件切换时缓存 -->
<keep-alive>
<component :is="currentComponent" />
</keep-alive>
</template>
2.2 配合路由使用
<template>
<!-- Vue Router 中缓存页面组件 -->
<router-view v-slot="{ Component }">
<keep-alive>
<component :is="Component" />
</keep-alive>
</router-view>
</template>
2.3 三个核心属性
<keep-alive> 接受三个 Props:
| 属性 | 类型 | 说明 |
|---|---|---|
include | string | RegExp | Array | 只缓存匹配的组件 |
exclude | string | RegExp | Array | 不缓存匹配的组件 |
max | number | 最多缓存多少个组件实例 |
<template>
<!-- 字符串形式,用逗号分隔 -->
<keep-alive include="Home,UserList">
<component :is="currentView" />
</keep-alive>
<!-- 正则表达式(需用 v-bind) -->
<keep-alive :include="/^(Home|User)/">
<component :is="currentView" />
</keep-alive>
<!-- 数组形式 -->
<keep-alive :include="['Home', 'UserList']">
<component :is="currentView" />
</keep-alive>
<!-- 排除某些组件 -->
<keep-alive exclude="Login,Register">
<component :is="currentView" />
</keep-alive>
<!-- 限制缓存数量 -->
<keep-alive :max="10">
<component :is="currentView" />
</keep-alive>
</template>
include 和 exclude 匹配的是组件的 name 选项,而不是路由名称。请确保你的组件定义了 name 属性:
// Options API
export default {
name: 'UserList', // 用这个名称来匹配
// ...
}
<!-- Vue 3.2.34+ 的 <script setup> 会自动推断组件名 -->
<!-- 文件名 UserList.vue → 组件名 UserList -->
3. 专属生命周期钩子
被 <keep-alive> 缓存的组件会多出两个生命周期钩子:
activated — 组件被激活时
// Options API
export default {
activated() {
console.log('组件从缓存中被激活')
// 适合做:刷新数据、恢复定时器、重新监听事件
}
}
<!-- Composition API -->
<script setup>
import { onActivated } from 'vue'
onActivated(() => {
console.log('组件从缓存中被激活')
})
</script>
deactivated — 组件被停用时
// Options API
export default {
deactivated() {
console.log('组件被缓存(停用)')
// 适合做:清除定时器、取消网络请求、移除事件监听
}
}
<!-- Composition API -->
<script setup>
import { onDeactivated } from 'vue'
onDeactivated(() => {
console.log('组件被缓存(停用)')
})
</script>
生命周期完整对比
- 首次渲染:
created→mounted→activated(三个钩子都触发) - 缓存后再次激活:只触发
activated(跳过created和mounted) - 被缓存时:只触发
deactivated(不触发unmounted)
4. 核心原理:LRU 缓存策略
<keep-alive> 内部使用了 LRU(Least Recently Used,最近最少使用) 缓存策略来管理缓存的组件实例。
4.1 什么是 LRU?
LRU 的核心思想是:当缓存满了需要淘汰时,优先淘汰最长时间没有被访问的数据。
LRU 策略基于一个假设:最近被访问过的数据,将来被再次访问的概率更高。在组件缓存场景下,用户最近浏览过的页面更有可能被再次访问,这个假设非常合理。
4.2 缓存数据结构
在 Vue 的实现中,<keep-alive> 使用两个关键的数据结构:
cache:存储组件的 key 和对应的 VNode(虚拟节点)keys:维护缓存的访问顺序(最近访问的放在最后)
4.3 LRU 操作流程
5. 源码解析
5.1 Vue 2 源码解析
Vue 2 中 <keep-alive> 的源码位于 src/core/components/keep-alive.js,让我们逐段分析:
组件定义
export default {
name: 'keep-alive',
abstract: true, // 抽象组件,不渲染真实 DOM
props: {
include: [String, RegExp, Array],
exclude: [String, RegExp, Array],
max: [String, Number]
},
created() {
// 缓存容器:key → VNode
this.cache = Object.create(null)
// 缓存 key 的有序列表(用于 LRU)
this.keys = []
},
destroyed() {
// 组件销毁时,清除所有缓存
for (const key in this.cache) {
pruneCacheEntry(this.cache, key, this.keys)
}
},
mounted() {
// 监听 include/exclude 变化,动态清理不匹配的缓存
this.$watch('include', val => {
pruneCache(this, name => matches(val, name))
})
this.$watch('exclude', val => {
pruneCache(this, name => !matches(val, name))
})
},
render() {
// 核心渲染逻辑(见下文详解)
}
}
核心 render 函数
render() {
// 1. 获取默认插槽中的第一个子组件
const slot = this.$slots.default
const vnode = getFirstComponentChild(slot)
const componentOptions = vnode && vnode.componentOptions
if (componentOptions) {
// 2. 获取组件名称
const name = getComponentName(componentOptions)
// 3. 检查 include/exclude,不匹配则直接返回(不缓存)
const { include, exclude } = this
if (
(include && (!name || !matches(include, name))) ||
(exclude && name && matches(exclude, name))
) {
return vnode
}
// 4. 生成缓存 key
const { cache, keys } = this
const key = vnode.key == null
? componentOptions.Ctor.cid + (componentOptions.tag
? `::${componentOptions.tag}`
: '')
: vnode.key
// 5. 命中缓存
if (cache[key]) {
// 复用缓存的组件实例
vnode.componentInstance = cache[key].componentInstance
// LRU 策略:移到末尾(最近使用)
remove(keys, key)
keys.push(key)
}
// 6. 未命中缓存
else {
cache[key] = vnode
keys.push(key)
// 超出 max 限制,淘汰最久未使用的
if (this.max && keys.length > parseInt(this.max)) {
pruneCacheEntry(cache, keys[0], keys, this._vnode)
}
}
// 7. 标记为 keep-alive 组件(patch 时会用到)
vnode.data.keepAlive = true
}
return vnode || (slot && slot[0])
}
缓存清理函数
// 删除单个缓存
function pruneCacheEntry(cache, key, keys, current) {
const cached = cache[key]
// 如果缓存的组件不是当前正在渲染的组件,则销毁它
if (cached && (!current || cached.tag !== current.tag)) {
cached.componentInstance.$destroy()
}
cache[key] = null
remove(keys, key)
}
// 根据过滤条件批量清理缓存
function pruneCache(keepAliveInstance, filter) {
const { cache, keys, _vnode } = keepAliveInstance
for (const key in cache) {
const cachedNode = cache[key]
if (cachedNode) {
const name = getComponentName(cachedNode.componentOptions)
if (name && !filter(name)) {
pruneCacheEntry(cache, key, keys, _vnode)
}
}
}
}
在销毁缓存组件时,会检查 cached.tag !== current.tag,确保不会销毁当前正在显示的组件。这是一个重要的边界保护。
5.2 Vue 3 源码解析
Vue 3 对 <keep-alive> 进行了重写,使用 Map 和 Set 替代了 Object 和 Array,代码更加清晰。
核心结构
const KeepAliveImpl = {
name: 'KeepAlive',
__isKeepAlive: true, // 标记(替代 Vue 2 的 abstract)
props: {
include: [String, RegExp, Array],
exclude: [String, RegExp, Array],
max: [String, Number]
},
setup(props, { slots }) {
const instance = getCurrentInstance()
// 缓存容器
const cache = new Map() // key → VNode
const keys = new Set() // 有序 key 集合
let current = null
// 卸载缓存的组件
function pruneCacheEntry(key) {
const cached = cache.get(key)
if (cached && (!current || cached.type !== current.type)) {
// 卸载组件实例
unmount(cached)
}
cache.delete(key)
keys.delete(key)
}
// 监听 include/exclude 动态变化
watch(
() => [props.include, props.exclude],
([include, exclude]) => {
include && pruneCache(name => matches(include, name))
exclude && pruneCache(name => !matches(exclude, name))
},
{ flush: 'post', deep: true }
)
// 组件卸载时清理所有缓存
onBeforeUnmount(() => {
cache.forEach((_, key) => {
pruneCacheEntry(key)
})
})
return () => {
// 渲染逻辑(见下文)
}
}
}
渲染逻辑(setup 返回的渲染函数)
return () => {
const children = slots.default()
const rawVNode = children[0]
// 不是有状态组件则直接返回
if (!isVNode(rawVNode) || !(rawVNode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT)) {
current = null
return rawVNode
}
const comp = rawVNode.type
const name = getComponentName(comp)
const { include, exclude, max } = props
// 不匹配则不缓存
if (
(include && (!name || !matches(include, name))) ||
(exclude && name && matches(exclude, name))
) {
current = rawVNode
return rawVNode
}
const key = rawVNode.key == null ? comp : rawVNode.key
const cachedVNode = cache.get(key)
if (cachedVNode) {
// 命中缓存:复用组件实例
rawVNode.el = cachedVNode.el
rawVNode.component = cachedVNode.component
// LRU:移到最新位置
keys.delete(key)
keys.add(key)
} else {
// 未命中:加入缓存
keys.add(key)
cache.set(key, rawVNode)
// 超出 max,淘汰最老的
if (max && keys.size > parseInt(max)) {
// Set 迭代器的第一个值就是最旧的
pruneCacheEntry(keys.values().next().value)
}
}
// 标记为 keep-alive(patch 过程中会检查此标记)
rawVNode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
current = rawVNode
return rawVNode
}
5.3 Vue 2 vs Vue 3 实现对比
| 对比项 | Vue 2 | Vue 3 |
|---|---|---|
| 缓存结构 | Object + Array | Map + Set |
| 组件模式 | Options API(render 函数) | Composition API(setup 返回渲染函数) |
| 抽象标记 | abstract: true | __isKeepAlive: true + ShapeFlags |
| LRU 实现 | 手动 remove + push 操作数组 | Set 的 delete + add(天然有序) |
| 缓存淘汰 | keys[0] 取最旧的 | keys.values().next().value |
| 监听过滤变化 | this.$watch | watch Composition API |
| 组件销毁 | $destroy() | unmount() |
Vue 3 使用 Set 替代数组来维护 keys,Set 天然保持插入顺序,且 delete + add 操作比数组的 splice + push 性能更好(O(1) vs O(n))。
6. Patch 过程中的 keep-alive 处理
<keep-alive> 的魔法不仅在于它自身的渲染逻辑,更在于 Vue 的 patch 过程 如何配合处理被缓存的组件。
6.1 组件切走时:deactivate 而非 unmount
6.2 组件切回时:activate 而非 mount
Vue 3 内部会创建一个不可见的 DOM 容器(document.createElement('div')),用来临时存放被缓存组件的真实 DOM。这样做的好处是 DOM 节点不会被垃圾回收,切回时可以直接搬回去,无需重新创建。
7. 手写简易 keep-alive
理解了原理后,我们来实现一个简化版的 keep-alive:
// 简化版 keep-alive 核心逻辑
class SimpleKeepAlive {
constructor(max = Infinity) {
this.max = max
this.cache = new Map() // key → componentInstance
this.keys = new Set() // 维护 LRU 顺序
}
/**
* 访问组件(缓存命中或新增)
*/
access(key, createInstance) {
if (this.cache.has(key)) {
// 命中缓存:LRU 更新顺序
this.keys.delete(key)
this.keys.add(key)
return this.cache.get(key) // 返回缓存实例
}
// 未命中:创建新实例
const instance = createInstance()
this.cache.set(key, instance)
this.keys.add(key)
// 超出限制:淘汰最久未使用的
if (this.keys.size > this.max) {
const oldestKey = this.keys.values().next().value
this.remove(oldestKey)
}
return instance
}
/**
* 删除缓存
*/
remove(key) {
const instance = this.cache.get(key)
if (instance && instance.$destroy) {
instance.$destroy() // 销毁组件实例
}
this.cache.delete(key)
this.keys.delete(key)
}
/**
* 清空所有缓存
*/
clear() {
this.cache.forEach((_, key) => this.remove(key))
}
}
验证 LRU 行为:
const ka = new SimpleKeepAlive(3)
ka.access('A', () => ({ name: 'CompA' }))
ka.access('B', () => ({ name: 'CompB' }))
ka.access('C', () => ({ name: 'CompC' }))
// 缓存: A → B → C
ka.access('D', () => ({ name: 'CompD' }))
// 缓存已满,淘汰 A(最久未使用)
// 缓存: B → C → D
ka.access('B', () => ({ name: 'CompB' }))
// B 命中缓存,移到最新位置
// 缓存: C → D → B
ka.access('E', () => ({ name: 'CompE' }))
// 缓存已满,淘汰 C(最久未使用)
// 缓存: D → B → E
8. 实际应用场景
8.1 列表页 ↔ 详情页缓存
最经典的场景:从列表页进入详情页后返回,保持列表的滚动位置和筛选条件。
<!-- App.vue -->
<template>
<router-view v-slot="{ Component, route }">
<keep-alive :include="cachedViews">
<component :is="Component" :key="route.fullPath" />
</keep-alive>
</router-view>
</template>
<script setup>
import { ref } from 'vue'
const cachedViews = ref(['ProductList', 'OrderList'])
</script>
<!-- ProductList.vue -->
<script setup>
import { ref, onActivated } from 'vue'
const searchKeyword = ref('')
const scrollPosition = ref(0)
// 每次从详情页返回时触发
onActivated(() => {
// 可以选择性地刷新列表数据
// 滚动位置会自动恢复(DOM 被缓存了)
window.scrollTo(0, scrollPosition.value)
})
</script>
8.2 Tab 页签切换
多个 Tab 之间切换时保留各 Tab 的状态:
<template>
<div class="tabs">
<button
v-for="tab in tabs"
:key="tab.name"
:class="{ active: currentTab === tab.name }"
@click="currentTab = tab.name"
>
{{ tab.label }}
</button>
</div>
<keep-alive>
<component :is="currentTabComponent" />
</keep-alive>
</template>
<script setup>
import { ref, computed } from 'vue'
import TabHome from './TabHome.vue'
import TabProfile from './TabProfile.vue'
import TabSettings from './TabSettings.vue'
const tabs = [
{ name: 'home', label: '首页', component: TabHome },
{ name: 'profile', label: '个人资料', component: TabProfile },
{ name: 'settings', label: '设置', component: TabSettings }
]
const currentTab = ref('home')
const currentTabComponent = computed(() => {
return tabs.find(t => t.name === currentTab.value).component
})
</script>
8.3 动态控制缓存
根据路由 meta 动态决定哪些页面需要缓存:
// router/index.js
const routes = [
{
path: '/list',
component: () => import('./views/List.vue'),
meta: { keepAlive: true } // 需要缓存
},
{
path: '/detail/:id',
component: () => import('./views/Detail.vue'),
meta: { keepAlive: false } // 不需要缓存
}
]
<!-- App.vue -->
<template>
<router-view v-slot="{ Component, route }">
<keep-alive>
<component :is="Component" v-if="route.meta.keepAlive" />
</keep-alive>
<component :is="Component" v-if="!route.meta.keepAlive" />
</router-view>
</template>
8.4 使用 Vuex/Pinia 管理缓存列表
// stores/cached-views.js (Pinia)
import { defineStore } from 'pinia'
export const useCachedViewsStore = defineStore('cachedViews', {
state: () => ({
cachedViews: []
}),
actions: {
addCachedView(viewName) {
if (!this.cachedViews.includes(viewName)) {
this.cachedViews.push(viewName)
}
},
removeCachedView(viewName) {
const index = this.cachedViews.indexOf(viewName)
if (index > -1) {
this.cachedViews.splice(index, 1)
}
},
clearCachedViews() {
this.cachedViews = []
}
}
})
<!-- App.vue -->
<template>
<router-view v-slot="{ Component }">
<keep-alive :include="cachedViews">
<component :is="Component" />
</keep-alive>
</router-view>
</template>
<script setup>
import { storeToRefs } from 'pinia'
import { useCachedViewsStore } from '@/stores/cached-views'
const { cachedViews } = storeToRefs(useCachedViewsStore())
</script>
9. 常见问题与注意事项
9.1 内存泄漏风险
不设置 max 的 <keep-alive> 会无限缓存组件,在路由页面较多的 SPA 应用中,可能导致严重的内存问题。建议根据业务场景合理设置 max 值(通常 5~20)。
9.2 缓存后数据不更新
缓存的组件不会重新执行 created / mounted,如果依赖这些钩子加载数据,切回时不会刷新。
// ❌ 错误:数据只在首次加载
export default {
name: 'UserList',
created() {
this.fetchList() // 缓存后不再执行
}
}
// ✅ 正确:在 activated 中判断是否需要刷新
export default {
name: 'UserList',
activated() {
// 每次激活时检查是否需要刷新
if (this.needRefresh) {
this.fetchList()
this.needRefresh = false
}
}
}
9.3 定时器和事件监听泄漏
// ✅ 正确的做法:在 activated/deactivated 中管理
export default {
data() {
return { timer: null }
},
activated() {
// 组件激活时开启定时器
this.timer = setInterval(this.pollData, 5000)
},
deactivated() {
// 组件被缓存时清除定时器
clearInterval(this.timer)
}
}
10. 面试常见问题
Q1:keep-alive 是什么?它的作用是什么?
参考回答:
<keep-alive> 是 Vue 内置的一个抽象组件,用于缓存不活动的组件实例。当组件被切走时,keep-alive 不会销毁它,而是将其保存在内存中(包括 DOM 和组件状态);当组件被切回时,直接从缓存中恢复,跳过组件创建和挂载的过程。
它的核心作用有两点:
- 保留组件状态:如表单输入、滚动位置、搜索条件等
- 提升性能:避免重复创建和销毁组件的开销
Q2:keep-alive 的内部实现原理是什么?
参考回答:
keep-alive 的实现核心包含三个部分:
-
缓存管理:内部维护一个
cache(Map)存储 key → VNode,以及一个keys(Set)维护访问顺序 -
LRU 缓存策略:当缓存数量超过
max时,淘汰最久未使用的组件。每次访问缓存时将对应 key 移到最新位置 -
render 函数逻辑:
- 获取插槽中的子组件
- 根据
include/exclude判断是否需要缓存 - 命中缓存则复用组件实例,未命中则新建并存入缓存
- 给 VNode 打上
keepAlive标记
-
patch 配合:Vue 的 patch 过程会识别
keepAlive标记,用deactivate/activate替代unmount/mount,实现 DOM 的缓存和恢复
Q3:keep-alive 的 LRU 缓存策略是如何工作的?
参考回答:
LRU(Least Recently Used)是一种缓存淘汰策略,当缓存满了时淘汰最久未被访问的项。
在 keep-alive 中的具体工作方式:
- 使用
Set维护 key 的访问顺序(Set按插入顺序迭代) - 缓存命中:从 Set 中删除该 key 再重新添加(移到末尾 = 最新位置)
- 缓存未命中:将新 key 添加到 Set 末尾
- 缓存溢出:取 Set 迭代器的第一个值(最旧的),删除对应的缓存并销毁组件
操作示例(max=3):
访问 A → keys: [A]
访问 B → keys: [A, B]
访问 C → keys: [A, B, C]
访问 D → 淘汰 A → keys: [B, C, D]
访问 B → B 移到末尾 → keys: [C, D, B]
Q4:keep-alive 的生命周期钩子是什么?执行顺序是怎样的?
参考回答:
被 keep-alive 缓存的组件有两个专属钩子:
activated:组件被激活时调用(从缓存恢复到页面)deactivated:组件被停用时调用(从页面放入缓存)
执行顺序:
- 首次渲染:
created→mounted→activated - 切走(缓存):
deactivated(不触发beforeUnmount/unmounted) - 切回(恢复):
activated(不触发created/mounted) - 缓存被清理:
deactivated→unmounted
关键区别在于:首次进入会触发完整的生命周期加上 activated,后续切换只触发 activated/deactivated。
Q5:include 和 exclude 是如何匹配的?
参考回答:
include/exclude 匹配的是组件的 name 选项,支持三种格式:
- 字符串:逗号分隔的组件名列表,如
"Home,UserList" - 正则表达式:如
:include="/^(Home|User)/" - 数组:如
:include="['Home', 'UserList']"
匹配逻辑:
- 先获取子组件的
name - 如果设置了
include,组件名必须匹配include才缓存 - 如果设置了
exclude,组件名匹配exclude则不缓存 exclude的优先级高于include
此外,include/exclude 是响应式的,当其值变化时,Vue 会自动清理不再匹配的缓存组件。
Q6:keep-alive 中的 max 属性有什么作用?不设置会怎样?
参考回答:
max 用于限制缓存的最大组件实例数量。当缓存数量超过 max 时,会使用 LRU 策略 淘汰最久未访问的组件。
不设置 max 的风险:
缓存会无限增长,每个被访问过的组件实例都会保留在内存中(包括 DOM 节点、组件数据、事件监听器等)。在路由页面较多的 SPA 应用中,用户浏览了大量页面后,内存占用会持续增加,可能导致:
- 页面卡顿
- 浏览器崩溃(特别是移动端)
最佳实践: 根据业务场景设置合理的 max 值,一般 5~20 即可。
Q7:如何强制刷新被 keep-alive 缓存的组件?
参考回答:
有几种方案:
方案 1:在 activated 中重新请求数据
activated() {
this.fetchData()
}
方案 2:动态控制 include 列表
// 先从 include 中移除组件名(触发销毁),再加回来
store.removeCachedView('UserList')
await nextTick()
store.addCachedView('UserList')
方案 3:使用 key 强制重建
<component :is="Component" :key="refreshKey" />
修改 refreshKey 可以强制组件重建(但会失去缓存效果)。
推荐使用方案 1,最符合 keep-alive 的设计理念。
Q8:keep-alive 和 v-show 有什么区别?
参考回答:
| 对比项 | keep-alive | v-show |
|---|---|---|
| 适用对象 | 动态组件 / 路由组件 | 任何元素或组件 |
| 隐藏方式 | DOM 移到隐藏容器(脱离文档流) | display: none(仍在文档流中) |
| 组件实例 | 保留并可触发 activated/deactivated | 始终保持挂载状态 |
| 初始开销 | 首次才创建,后续从缓存恢复 | 初始就创建所有实例 |
| 缓存管理 | 有 LRU 淘汰机制,可控 | 无缓存管理 |
| 典型场景 | 路由页面缓存、Tab 切换 | 简单的显示/隐藏切换 |
简单来说:v-show 适合频繁切换的简单场景,keep-alive 适合需要缓存管理的组件级别场景。
Q9:为什么 keep-alive 只能缓存有 name 的组件?
参考回答:
严格来说,keep-alive 可以缓存没有 name 的组件,但 include 和 exclude 需要通过 name 来匹配组件,如果组件没有 name,这两个属性就无法生效。
name 的作用:
- 作为
include/exclude的匹配标识 - 与缓存 key 的生成有关(Vue 2 中,无 key 时会用
Ctor.cid + tag生成) - 在 DevTools 中显示组件名称,方便调试
在 Vue 3.2.34+ 的 <script setup> 中,组件名会根据文件名自动推断,无需手动定义。
11. 总结
核心要点回顾:
<keep-alive>是 Vue 内置的抽象组件,缓存组件实例而非销毁它们- 内部采用 LRU 缓存策略,通过
max控制缓存上限,自动淘汰最久未使用的组件 - 通过
include/exclude精确控制缓存范围,匹配的是组件的name - 缓存组件有专属的
activated/deactivated生命周期钩子 - Vue 的 patch 过程配合 keepAlive 标记,用 deactivate/activate 替代 unmount/mount
- 实际使用中务必设置
max并在deactivated中清理定时器和事件监听