用 keep-alive 的时候,你有没有想过:为什么设置了 max 之后,被淘汰的总是那个"最久没用"的组件?今天从 LRU 策略到源码实现,帮你彻底搞懂。
前言
keep-alive 这个东西,大部分 Vue 开发者都用过。包裹一下 <router-view>,页面切换时状态就保留了,用户体验确实好很多。
但说实话,我用了很久 keep-alive,一直有个疑问:设置 max 之后,Vue 是怎么决定淘汰哪个组件的?
后来翻了一下源码,发现里面用了一个经典的数据结构策略 ------ LRU(Least Recently Used,最近最少使用)。理解了这个,keep-alive 的所有行为就都说得通了。
一、LRU 策略:一句话讲清楚
当缓存满了,优先踢掉最久没被访问的那个。
举个例子,假设 max = 3:

核心思想:最近用过的数据,将来大概率还会被用到。所以淘汰"最久没用"的,保留"最近用过"的。
这个策略在操作系统页面置换、Redis 缓存、浏览器缓存里都有应用,Vue 的 keep-alive 也是同样的思路。
二、keep-alive 使用指南
2.1 三个核心属性
| 属性 | 类型 | 作用 |
|---|---|---|
include |
字符串/正则/数组 | 只有匹配的组件才缓存 |
exclude |
字符串/正则/数组 | 匹配的组件不缓存 |
max |
数字 | 最多缓存多少个组件实例 |
2.2 基本用法:缓存动态组件
html
<keep-alive>
<component :is="currentView"></component>
</keep-alive>
切换组件时,被切走的组件不会被销毁,状态完整保留。再切回来时,直接恢复之前的状态。
2.3 条件缓存:include 和 exclude
实际项目中,不是所有页面都需要缓存。比如搜索结果页、表单提交成功页,每次进去都应该是全新的状态。
html
<!-- 只缓存列表页和详情页,不缓存搜索页 -->
<keep-alive include="UserList,UserDetail" exclude="SearchResult">
<component :is="currentView"></component>
</keep-alive>
也可以用正则:
html
<keep-alive :include="/^User/">
<component :is="currentView"></component>
</keep-alive>
注意 :include 和 exclude 匹配的是组件的 name 选项 ,不是路由名称。所以组件一定要写 name:
javascript
export default {
name: 'UserList', // keep-alive 靠这个匹配
// ...
}
这个坑我踩过,组件没写 name,include 怎么配都不生效,排查了半天才发现
2.4 启用 LRU:设置 max
html
<!-- 最多缓存 10 个组件实例 -->
<keep-alive :max="10">
<router-view />
</keep-alive>
设置了 max 之后,LRU 策略就生效了。当缓存数量超过 max,最久没访问的组件会被销毁。
建议 :一定要设置 max!不设的话,所有被 keep-alive 包裹过的组件都会一直缓存,内存只会增不会减,时间长了页面会越来越卡。
2.5 与 Vue Router 配合
这是最常见的使用场景。Vue 2 和 Vue 3 写法稍有不同:
html
<!-- Vue 3 写法 -->
<router-view v-slot="{ Component }">
<keep-alive :include="cachedViews" :max="15">
<component :is="Component" />
</keep-alive>
</router-view>
html
<!-- Vue 2 写法 -->
<keep-alive :include="cachedViews" :max="15">
<router-view />
</keep-alive>
Vue 3 必须通过 v-slot 拿到 Component,再传给 <component :is>,因为 Vue 3 的 <router-view> 不再直接渲染组件。
如果需要动态控制缓存列表,可以结合 Pinia:
javascript
// store/cacheStore.js
export const useCacheStore = defineStore('cache', () => {
const cachedViews = ref([])
function addView(view) {
if (!cachedViews.value.includes(view)) {
cachedViews.value.push(view)
}
}
function removeView(view) {
const index = cachedViews.value.indexOf(view)
if (index > -1) {
cachedViews.value.splice(index, 1)
}
}
return { cachedViews, addView, removeView }
})
三、源码实现:LRU 是怎么工作的?
3.1 缓存的到底是什么?
首先搞清楚一点:keep-alive 缓存的不是 DOM,而是 VNode(虚拟节点)和组件实例。
VNode 持有组件实例的引用,所以缓存了 VNode 就等于缓存了整个组件的所有状态:
- 响应式数据(data)
- 计算属性(computed)
- 侦听器(watch)
- DOM 结构
- 子组件树
这就是为什么 keep-alive 能做到"切回来还是原来的样子"。
3.2 Vue 2 vs Vue 3 的实现差异
| 特性 | Vue 2 | Vue 3 |
|---|---|---|
| 缓存对象 | Object.create(null) 纯对象 |
new Map() |
| 访问记录 | 数组 [] |
new Set() |
| 代码组织 | 组件选项对象 | setup() 函数 |
| 源码位置 | core/components/keep-alive.js |
runtime-core/src/components/KeepAlive.ts |
Vue 3 用 Map + Set 替代了 Object + Array,查找和删除操作从 O(n) 优化到了 O(1),性能更好。
3.3 核心数据结构
typescript
// Vue 3 源码简化
const cache = new Map() // key → VNode,缓存组件
const keys = new Set() // 记录访问顺序,实现 LRU
cache:存组件的 VNode,key 通常是组件的 name 或组件标签keys:记录访问顺序,Set 的迭代顺序就是插入顺序
3.4 渲染流程
keep-alive 的渲染逻辑大致是这样的:

3.5 关键代码解读
缓存命中时
typescript
if (cache.has(key)) {
const cached = cache.get(key)
// 复用缓存的组件实例和 DOM
vnode.component = cached.component
vnode.el = cached.el
// LRU 核心:先删再加,把 key 移到 Set 末尾
keys.delete(key)
keys.add(key)
}
keys.delete(key) + keys.add(key) 就是 LRU 的精髓。Set 是按插入顺序排列的,删了再加就相当于移到了末尾,变成了"最近使用"。
缓存未命中时
typescript
// 存入缓存
cache.set(key, vnode)
keys.add(key)
// 检查是否需要淘汰
if (max && keys.size > parseInt(max)) {
// 获取 Set 中第一个元素(最久没用的)
const oldestKey = keys.values().next().value
// 从缓存和 keys 中移除
keys.delete(oldestKey)
cache.delete(oldestKey)
// 后续会执行组件的 unmount
}
keys.values().next().value 就是拿到 Set 中最早插入的那个 key,也就是最久没被访问的组件。
组件卸载的"劫持"
这是 keep-alive 最巧妙的地方。当组件被切走时,keep-alive 不会真正销毁它,而是"劫持"了卸载过程:
typescript
// 源码概念逻辑
if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
// 不执行 unmount,而是 deactivate
deactivate(vnode)
// 把 DOM 移到隐藏容器,而不是真正删除
}
deactivate 做的事情:
- 把组件的 DOM 从页面上移除
- 但不销毁组件实例,保留所有状态
- 触发
deactivated生命周期钩子
所以组件虽然看不见了,但其实还"活着"。
四、activated 和 deactivated
被 keep-alive 包裹的组件有两个独有的生命周期钩子:
activated ------ 组件被激活时调用
适合做这些事情:
- 刷新数据(从服务器拉最新数据)
- 开启定时器
- 恢复滚动位置
javascript
export default {
name: 'UserList',
activated() {
// 每次切回来都刷新数据
this.fetchUsers()
// 恢复滚动位置
this.$nextTick(() => {
window.scrollTo(0, this.scrollPosition)
})
},
deactivated() {
// 记住滚动位置
this.scrollPosition = window.scrollY
// 清理定时器
clearInterval(this.refreshTimer)
}
}
deactivated ------ 组件被停用时调用
适合做这些事情:
- 清理定时器
- 取消未完成的网络请求
- 保存当前状态
注意 :created 和 mounted 只在组件第一次创建时执行。之后每次激活/停用,只会触发 activated / deactivated。
五、常见问题
Q1: keep-alive 包裹的组件,created 和 mounted 执行几次?
A :只有第一次进入时执行。之后再进入只触发 activated。
Q2: 怎么让某个页面不缓存?
A:三种方式:
- 用
exclude排除组件名 - 在路由 meta 里标记,动态控制 include 列表
- 在 beforeRouteLeave 里手动清除缓存
Q3: 缓存的组件数据过期了怎么办?
A :在 activated 里重新请求数据。这是最推荐的做法。
Q4: keep-alive 能和 transition 一起用吗?
A:可以,但要注意顺序:
html
<!-- Vue 3 -->
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<keep-alive :include="cachedViews">
<component :is="Component" />
</keep-alive>
</transition>
</router-view>
transition 要包在外面,keep-alive 包在里面。反过来写的话,离开动画可能不会触发。
六、总结
| 概念 | 说明 |
|---|---|
| 缓存内容 | VNode + 组件实例(不是 DOM) |
| 淘汰策略 | LRU(最近最少使用) |
| 核心数据结构 | Vue 3: Map + Set |
| max 属性 | 一定要设,不然内存只增不减 |
| 组件 name | include/exclude 靠 name 匹配,别忘了写 |
| 生命周期 | activated(激活)/ deactivated(停用) |
一句话总结:keep-alive 通过 LRU 策略管理缓存,命中时复用组件实例,未命中时新建并存入缓存,满了就踢掉最久没用的。
有问题评论区见,觉得有帮助点个赞