跳到主要内容

keep-alive 组件原理详解

在 Vue 项目中,你是否遇到过这样的场景:从列表页进入详情页后返回,列表页的滚动位置丢失了,搜索条件也被重置了?或者 Tab 切换时,每次切回来组件都要重新请求数据?这些问题的根源在于 组件被销毁又重新创建 了,而 <keep-alive> 正是 Vue 提供的解决方案。

1. 什么是 keep-alive?

<keep-alive> 是 Vue 内置的一个抽象组件,它的作用是:缓存不活动的组件实例,而不是销毁它们

核心价值

keep-alive 让组件切换时不销毁,而是放入缓存。再次显示时直接从缓存取出,跳过组件的创建和挂载过程,既保留了状态又提升了性能。

什么是"抽象组件"?

抽象组件有两个特点:

  1. 不渲染真实 DOM<keep-alive> 不会在页面中产生任何 DOM 元素,它只是逻辑层面的包装
  2. 不出现在组件链中:在 $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:

属性类型说明
includestring | RegExp | Array只缓存匹配的组件
excludestring | RegExp | Array不缓存匹配的组件
maxnumber最多缓存多少个组件实例
<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>
匹配规则

includeexclude 匹配的是组件的 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>

生命周期完整对比

关键区别
  • 首次渲染createdmountedactivated(三个钩子都触发)
  • 缓存后再次激活:只触发 activated(跳过 createdmounted
  • 被缓存时:只触发 deactivated(不触发 unmounted

4. 核心原理:LRU 缓存策略

<keep-alive> 内部使用了 LRU(Least Recently Used,最近最少使用) 缓存策略来管理缓存的组件实例。

4.1 什么是 LRU?

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)
}
}
}
}
pruneCacheEntry 的安全检查

在销毁缓存组件时,会检查 cached.tag !== current.tag,确保不会销毁当前正在显示的组件。这是一个重要的边界保护。

5.2 Vue 3 源码解析

Vue 3 对 <keep-alive> 进行了重写,使用 MapSet 替代了 ObjectArray,代码更加清晰。

核心结构

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 2Vue 3
缓存结构Object + ArrayMap + Set
组件模式Options API(render 函数)Composition API(setup 返回渲染函数)
抽象标记abstract: true__isKeepAlive: true + ShapeFlags
LRU 实现手动 remove + push 操作数组Setdelete + add(天然有序)
缓存淘汰keys[0] 取最旧的keys.values().next().value
监听过滤变化this.$watchwatch Composition API
组件销毁$destroy()unmount()
Vue 3 的改进

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

不设置 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 和组件状态);当组件被切回时,直接从缓存中恢复,跳过组件创建和挂载的过程。

它的核心作用有两点:

  1. 保留组件状态:如表单输入、滚动位置、搜索条件等
  2. 提升性能:避免重复创建和销毁组件的开销

Q2:keep-alive 的内部实现原理是什么?

参考回答:

keep-alive 的实现核心包含三个部分:

  1. 缓存管理:内部维护一个 cache(Map)存储 key → VNode,以及一个 keys(Set)维护访问顺序

  2. LRU 缓存策略:当缓存数量超过 max 时,淘汰最久未使用的组件。每次访问缓存时将对应 key 移到最新位置

  3. render 函数逻辑

    • 获取插槽中的子组件
    • 根据 include/exclude 判断是否需要缓存
    • 命中缓存则复用组件实例,未命中则新建并存入缓存
    • 给 VNode 打上 keepAlive 标记
  4. patch 配合:Vue 的 patch 过程会识别 keepAlive 标记,用 deactivate/activate 替代 unmount/mount,实现 DOM 的缓存和恢复


Q3:keep-alive 的 LRU 缓存策略是如何工作的?

参考回答:

LRU(Least Recently Used)是一种缓存淘汰策略,当缓存满了时淘汰最久未被访问的项。

keep-alive 中的具体工作方式:

  1. 使用 Set 维护 key 的访问顺序(Set 按插入顺序迭代)
  2. 缓存命中:从 Set 中删除该 key 再重新添加(移到末尾 = 最新位置)
  3. 缓存未命中:将新 key 添加到 Set 末尾
  4. 缓存溢出:取 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:组件被停用时调用(从页面放入缓存)

执行顺序:

  • 首次渲染createdmountedactivated
  • 切走(缓存)deactivated(不触发 beforeUnmount / unmounted
  • 切回(恢复)activated(不触发 created / mounted
  • 缓存被清理deactivatedunmounted

关键区别在于:首次进入会触发完整的生命周期加上 activated,后续切换只触发 activated/deactivated


Q5:include 和 exclude 是如何匹配的?

参考回答:

include/exclude 匹配的是组件的 name 选项,支持三种格式:

  1. 字符串:逗号分隔的组件名列表,如 "Home,UserList"
  2. 正则表达式:如 :include="/^(Home|User)/"
  3. 数组:如 :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-alivev-show
适用对象动态组件 / 路由组件任何元素或组件
隐藏方式DOM 移到隐藏容器(脱离文档流)display: none(仍在文档流中)
组件实例保留并可触发 activated/deactivated始终保持挂载状态
初始开销首次才创建,后续从缓存恢复初始就创建所有实例
缓存管理有 LRU 淘汰机制,可控无缓存管理
典型场景路由页面缓存、Tab 切换简单的显示/隐藏切换

简单来说:v-show 适合频繁切换的简单场景,keep-alive 适合需要缓存管理的组件级别场景


Q9:为什么 keep-alive 只能缓存有 name 的组件?

参考回答:

严格来说,keep-alive 可以缓存没有 name 的组件,includeexclude 需要通过 name 来匹配组件,如果组件没有 name,这两个属性就无法生效。

name 的作用:

  1. 作为 include/exclude 的匹配标识
  2. 与缓存 key 的生成有关(Vue 2 中,无 key 时会用 Ctor.cid + tag 生成)
  3. 在 DevTools 中显示组件名称,方便调试

在 Vue 3.2.34+ 的 <script setup> 中,组件名会根据文件名自动推断,无需手动定义。

11. 总结

核心要点回顾:

  1. <keep-alive> 是 Vue 内置的抽象组件,缓存组件实例而非销毁它们
  2. 内部采用 LRU 缓存策略,通过 max 控制缓存上限,自动淘汰最久未使用的组件
  3. 通过 include/exclude 精确控制缓存范围,匹配的是组件的 name
  4. 缓存组件有专属的 activated/deactivated 生命周期钩子
  5. Vue 的 patch 过程配合 keepAlive 标记,用 deactivate/activate 替代 unmount/mount
  6. 实际使用中务必设置 max 并在 deactivated清理定时器和事件监听