在前面的文章中,我们学习了
Suspense如何处理异步组件加载。今天,我们将探索Vue3中另一个强大的特性:KeepAlive。它允许我们在组件切换时缓存组件实例,避免重复渲染,极大地提升了用户体验和性能。理解它的实现原理,将帮助我们更好地处理需要保持状态的组件。
前言:为什么需要组件缓存?
在构建大型单页应用时,我们经常会遇到这样的场景:
- 用户频繁切换标签页,每次切换回来表单数据却丢失了。
- 一个复杂的图表组件每次重新进入都要重新渲染,造成性能浪费。
Vue3 的 KeepAlive 组件正是为了解决这些问题而生。本文将深入剖析 KeepAlive 的工作原理、LRU缓存策略、生命周期变化,并手写一个简易实现。
KeepAlive 组件概述
什么是 KeepAlive
KeepAlive 是 Vue 的内置组件,它能够在组件切换时,自动将组件实例保存在内存(缓存)中,而不是直接将其销毁。当组件再次被切回时,直接从缓存中恢复实例和 DOM,从而避免重复渲染和状态丢失:
<template>
<keep-alive>
<component :is="currentTab" />
</keep-alive>
</template>
核心优势
- 状态保持:表单输入、滚动位置等状态在切换后依然保留
- 性能提升:避免重复创建和销毁组件实例,减少DOM操作
- 数据复用:避免重复请求相同的数据,减少网络开销
KeepAlive 的工作机制
核心原理:DOM的"搬家"
很多人误以为 KeepAlive 只是简单的 display: none,其实不然:它的本质是将组件的 DOM 节点从页面上摘下来,并将组件实例和 DOM 引用保存在内存中。当再次切回来时,直接从内存中取出这个 DOM 节点重新挂上去。
这个过程可以简化为:
- 组件失活时:
container.removeChild(dom),移除组件节点,但在内存中保留实例 - 组件激活时:
container.appendChild(dom),挂载组件节点,并恢复组件状态
缓存队列的设计
KeepAlive 内部使用两个核心数据结构来管理缓存:
const cache: Map<string, VNode> = new Map(); // 缓存存储
const keys: Set<string> = new Set(); // 缓存key顺序队列
cache:存储组件 VNode 的 Map 结构,key 通常是组件的 id 或 key 属性keys:维护缓存 key 的访问顺序,用于实现 LRU 淘汰策略
核心配置属性
<keep-alive
:include="['ComponentA', 'ComponentB']"
:exclude="/ComponentC/"
:max="10"
>
<component :is="currentComponent" />
</keep-alive>
include:只有名称匹配的组件才会被缓存,支持字符串、正则、数组exclude:名称匹配的组件不会被缓存max:最多缓存多少组件实例,超过时按 LRU 策略淘汰
激活与失活:特殊的生命周期
activated 和 deactivated
当组件被 KeepAlive 包裹时,它会多出两个生命周期钩子:
<script setup>
import { onActivated, onDeactivated } from 'vue'
onActivated(() => {
// 调用时机:
// 1. 组件首次挂载
// 2. 每次从缓存中被重新插入时
console.log('组件被激活了')
// 适合恢复轮询、恢复动画等
})
onDeactivated(() => {
// 调用时机:
// 1. 从 DOM 上移除、进入缓存时
// 2. 组件卸载时
console.log('组件被停用了')
// 适合清除定时器、暂停网络请求等
})
</script>
与普通生命周期的关系
被缓存的组件在切换时不会触发 unmounted 和 mounted,而是触发 deactivated 和 activated。这意味着组件实例一直活着,只是暂时休眠,其生命周期流程如下:
- 首次进入: beforeMount -> mounted -> activated
- 切换出去: -> deactivated
- 切换回来: -> activated
- 最终销毁: -> beforeUnmount -> unmounted -> deactivated
源码实现机制
Vue3 内部通过 registerLifecycleHook 来管理这些钩子:
function registerLifecycleHook(type, hook) {
const instance = getCurrentInstance()
if (instance) {
(instance[type] || (instance[type] = [])).push(hook)
}
}
// 激活时执行
function activateComponent(instance) {
if (instance.activated) {
instance.activated.forEach(hook => hook())
}
}
// 失活时执行
function deactivateComponent(instance) {
if (instance.deactivated) {
instance.deactivated.forEach(hook => hook())
}
}
LRU 淘汰策略深度解析
为什么需要 LRU
当设置了 max 属性后,缓存池容量有限。如果没有淘汰策略,无限缓存会导致内存溢出。LRU(Least Recently Used)算法正是解决这个问题的经典方案。
LRU 核心思想
LRU 基于"最近被访问的数据将来被访问的概率更高"这一假设:
- 新数据插入到链表尾部
- 每当缓存命中,将数据移到链表尾部
- 链表满时,丢弃链表头部的数据(最久未使用)
KeepAlive 中的 LRU 实现
KeepAlive 利用 Set 的迭代顺序特性来实现 LRU,即:每次访问时先删除再添加,就实现了"移到末尾"的效果:
// 核心LRU逻辑
if (cachedVNode) {
// 缓存命中:删除旧key,重新添加到末尾(表示最新使用)
keys.delete(key)
keys.add(key)
return cachedVNode
} else {
// 缓存未命中:添加新key
keys.add(key)
// 检查是否超过最大限制
if (max && keys.size > max) {
// 淘汰最久未使用的key(Set的第一个元素)
const oldestKey = keys.values().next().value
pruneCacheEntry(oldestKey)
}
cache.set(key, vnode)
return vnode
}
手写实现简易 KeepAlive 组件
核心实现思路
// MyKeepAlive.ts
import { defineComponent, h, onBeforeUnmount, getCurrentInstance } from 'vue'
export default defineComponent({
name: 'MyKeepAlive',
props: {
include: [String, RegExp, Array],
exclude: [String, RegExp, Array],
max: [String, Number]
},
setup(props, { slots }) {
// 缓存容器
const cache = new Map()
const keys = new Set()
// 当前渲染的 vnode
let current = null
// 工具函数:检查组件名是否匹配规则
const matches = (pattern, name) => {
if (Array.isArray(pattern)) {
return pattern.includes(name)
} else if (pattern instanceof RegExp) {
return pattern.test(name)
} else if (typeof pattern === 'string') {
return pattern.split(',').includes(name)
}
return false
}
// 工具函数:获取组件名称
const getComponentName = (vnode) => {
const type = vnode.type
return type.name || type.__name
}
// 淘汰缓存
const pruneCacheEntry = (key) => {
const cached = cache.get(key)
if (cached && cached.component) {
// 如果不是当前激活的组件,需要卸载
if (cached !== current) {
cached.component.unmount()
}
}
cache.delete(key)
keys.delete(key)
}
// 根据 include/exclude 清理缓存
const pruneCache = (filter) => {
cache.forEach((vnode, key) => {
const name = getComponentName(vnode)
if (name && filter(name)) {
pruneCacheEntry(key)
}
})
}
// 监听 include/exclude 变化
if (props.include || props.exclude) {
watch(
() => [props.include, props.exclude],
([include, exclude]) => {
include && pruneCache(name => !matches(include, name))
exclude && pruneCache(name => matches(exclude, name))
},
{ flush: 'post' }
)
}
// 组件卸载时清理所有缓存
onBeforeUnmount(() => {
cache.forEach((vnode) => {
if (vnode.component) {
vnode.component.unmount()
}
})
cache.clear()
keys.clear()
})
return () => {
// 获取默认插槽的第一个子节点
const vnode = slots.default?.()[0]
if (!vnode) return null
const name = getComponentName(vnode)
// 检查 include/exclude
if (
(props.include && name && !matches(props.include, name)) ||
(props.exclude && name && matches(props.exclude, name))
) {
// 不缓存,直接返回
return vnode
}
// 生成缓存key
const key = vnode.key ?? vnode.type.__id ?? name
// 命中缓存
if (cache.has(key)) {
const cachedVNode = cache.get(key)
// 复用组件实例和DOM
vnode.component = cachedVNode.component
vnode.el = cachedVNode.el
// 标记为 KeepAlive 组件
vnode.shapeFlag |= 1 << 11 // ShapeFlags.COMPONENT_KEPT_ALIVE
// LRU: 刷新key顺序
keys.delete(key)
keys.add(key)
current = vnode
return vnode
}
// 未命中缓存
cache.set(key, vnode)
keys.add(key)
// LRU: 检查是否超过max限制
if (props.max && keys.size > Number(props.max)) {
const oldestKey = keys.values().next().value
pruneCacheEntry(oldestKey)
}
// 标记为需要被 KeepAlive 的组件
vnode.shapeFlag |= 1 << 12 // ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
current = vnode
return vnode
}
}
})
原生 JS 模拟演示
为了更直观地理解 KeepAlive 的"DOM搬家"原理,这里提供一个原生 JS 的简单实现:
<div id="app"></div>
<button onclick="switchTab('home')">首页</button>
<button onclick="switchTab('profile')">个人</button>
<script>
const cache = {}
const container = document.getElementById('app')
let currentTab = null
function createHomePage() {
const div = document.createElement('div')
div.innerHTML = `
<h3>首页</h3>
<input placeholder="试试输入内容..." />
`
return div
}
function createProfilePage() {
const div = document.createElement('div')
div.innerHTML = `<h3>个人中心</h3><p>这是个人页</p>`
return div
}
function switchTab(tab) {
// 移除当前页面
if (currentTab && cache[currentTab]) {
container.removeChild(cache[currentTab])
console.log(`[缓存] ${currentTab} 已暂停 (DOM移除)`)
}
// 加载新页面
if (cache[tab]) {
// 命中缓存,直接复用DOM
container.appendChild(cache[tab])
console.log(`[缓存] ${tab} 命中缓存,恢复DOM`)
} else {
// 首次创建
const page = tab === 'home' ? createHomePage() : createProfilePage()
cache[tab] = page
container.appendChild(page)
console.log(`[缓存] ${tab} 首次创建并缓存`)
}
currentTab = tab
}
</script>
常见陷阱
陷阱1:组件名不匹配导致缓存失效
KeepAlive 的 include/exclude 是根据组件的 name 选项来匹配的,而不是文件名或路径,因此必须显示地声明组件的 name 。
陷阱2:滥用缓存导致内存溢出
对于频繁切换且数量众多的组件,务必设置合理的 max 值,避免无限缓存。
陷阱3:WebSocket 等全局资源重复创建
// ❌ 错误:每次激活都新建连接
onActivated(() => {
ws = new WebSocket('wss://...') // 重复创建
})
// ✅ 正确:全局单例 + 按需消费
const socketStore = useSocketStore() // Pinia 全局单例
onActivated(() => {
socketStore.subscribe('chat')
})
onDeactivated(() => {
socketStore.unsubscribe('chat')
})
清除缓存的几种方式
方法1:动态修改 include/exclude
const cachedComponents = ref(['ComponentA', 'ComponentB'])
const clearCache = () => {
cachedComponents.value = [] // 清空 include,所有组件不再缓存
}
方法2:改变 key 强制重新渲染
const componentKey = ref(0)
const forceRerender = () => {
componentKey.value++ // key 变化,组件重新创建
}
方法3:调用 unmount(不推荐)
const clearCache = (key) => {
// 通过 ref 访问组件实例,调用 unmount
}
结语
KeepAlive 是 Vue 中提升性能的重要工具,它通过缓存组件实例,避免重复渲染。理解它的实现原理,不仅帮助我们更好地使用它,也能在遇到性能问题时找到合适的优化方案。
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!