跑购腿配
76.13M · 2026-03-05
在构建大型单页应用时,我们经常会遇到这样的场景:
Vue3 的 KeepAlive 组件正是为了解决这些问题而生。本文将深入剖析 KeepAlive 的工作原理、LRU缓存策略、生命周期变化,并手写一个简易实现。
KeepAlive 是 Vue 的内置组件,它能够在组件切换时,自动将组件实例保存在内存(缓存)中,而不是直接将其销毁。当组件再次被切回时,直接从缓存中恢复实例和 DOM,从而避免重复渲染和状态丢失:
<template>
<keep-alive>
<component :is="currentTab" />
</keep-alive>
</template>
很多人误以为 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 策略淘汰当组件被 KeepAlive 包裹时,它会多出两个生命周期钩子:
<script setup>
import { onActivated, onDeactivated } from 'vue'
onActivated(() => {
// 调用时机:
// 1. 组件首次挂载
// 2. 每次从缓存中被重新插入时
console.log('组件被激活了')
// 适合恢复轮询、恢复动画等
})
onDeactivated(() => {
// 调用时机:
// 1. 从 DOM 上移除、进入缓存时
// 2. 组件卸载时
console.log('组件被停用了')
// 适合清除定时器、暂停网络请求等
})
</script>
被缓存的组件在切换时不会触发 unmounted 和 mounted,而是触发 deactivated 和 activated。这意味着组件实例一直活着,只是暂时休眠,其生命周期流程如下:
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())
}
}
当设置了 max 属性后,缓存池容量有限。如果没有淘汰策略,无限缓存会导致内存溢出。LRU(Least Recently Used)算法正是解决这个问题的经典方案。
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
}
// 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
}
}
})
为了更直观地理解 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>
KeepAlive 的 include/exclude 是根据组件的 name 选项来匹配的,而不是文件名或路径,因此必须显示地声明组件的 name 。
对于频繁切换且数量众多的组件,务必设置合理的 max 值,避免无限缓存。
// 错误:每次激活都新建连接
onActivated(() => {
ws = new WebSocket('wss://...') // 重复创建
})
// 正确:全局单例 + 按需消费
const socketStore = useSocketStore() // Pinia 全局单例
onActivated(() => {
socketStore.subscribe('ch@t')
})
onDeactivated(() => {
socketStore.unsubscribe('ch@t')
})
const cachedComponents = ref(['ComponentA', 'ComponentB'])
const clearCache = () => {
cachedComponents.value = [] // 清空 include,所有组件不再缓存
}
const componentKey = ref(0)
const forceRerender = () => {
componentKey.value++ // key 变化,组件重新创建
}
const clearCache = (key) => {
// 通过 ref 访问组件实例,调用 unmount
}
KeepAlive 是 Vue 中提升性能的重要工具,它通过缓存组件实例,避免重复渲染。理解它的实现原理,不仅帮助我们更好地使用它,也能在遇到性能问题时找到合适的优化方案。
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!