彻底弄懂 Vue KeepAlive!深入源码看组件缓存与生命周期管理

6 阅读1分钟

一、这东西是什么

Vue KeepAlive 是 Vue.js 的内置组件,用于缓存不活动的组件实例,而不是销毁它们。当组件在 <KeepAlive> 内切换时,它的状态会被保留,避免重复渲染。

核心功能

  1. 组件缓存:保存组件实例,避免重复创建
  2. 状态保持:保持组件数据和 DOM 状态
  3. 生命周期扩展:新增 activateddeactivated 钩子
  4. 智能缓存:基于 LRU 算法管理缓存大小

二、这东西有什么用

适用场景

  • 标签页切换(如后台管理系统)
  • 列表页到详情页的返回
  • 表单填写中途离开
  • 需要保持滚动位置的页面
  • 性能敏感的应用(减少渲染开销)

能带来什么收益

  1. 性能提升:减少组件创建和销毁的开销
  2. 用户体验:保持页面状态,避免数据丢失
  3. 流畅切换:快速切换,无闪烁
  4. 内存管理:智能缓存,避免内存泄漏

三、官方链接

四、从源码看实现原理

1. KeepAlive 组件结构(Vue 3 源码)

// vue-next/packages/runtime-core/src/components/KeepAlive.ts
export const KeepAliveImpl: ComponentOptions = {
  name: `KeepAlive`,
  
  // 组件属性
  props: {
    include: [String, RegExp, Array],
    exclude: [String, RegExp, Array],
    max: [String, Number]
  },

  setup(props: KeepAliveProps, { slots }: SetupContext) {
    // 缓存映射:key -> 组件实例
    const cache: Map<CacheKey, VNode> = new Map()
    // 键的访问顺序(用于 LRU)
    const keys: Set<CacheKey> = new Set()
    
    // 当前渲染的组件
    let current: VNode | null = null
    
    // 父组件实例
    const parentSuspense = getCurrentInstance()!.suspense
    
    // 共享上下文
    const sharedContext = getCurrentInstance()!.ctx
    
    // 缓存组件
    function cacheSubtree() {
      if (current) {
        cache.set(current.key!, current)
        keys.add(current.key!)
      }
    }
    
    // 激活组件
    function activate(vnode: VNode, container: RendererElement, anchor: RendererNode | null) {
      // 调用组件的 activated 钩子
      if (vnode.shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
        vnode.component!.activated(vnode, container, anchor)
      }
    }
    
    // 失活组件
    function deactivate(vnode: VNode) {
      // 调用组件的 deactivated 钩子
      if (vnode.shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
        vnode.component!.deactivated(vnode)
      }
    }
    
    // 渲染函数
    return () => {
      // 获取默认插槽内容
      const children = slots.default?.()
      if (!children || !children.length) {
        return null
      }
      
      const rawVNode = children[0]
      // 只缓存有 key 的组件
      if (rawVNode.key == null) {
        return rawVNode
      }
      
      // 检查 include/exclude
      const name = getComponentName(rawVNode.type)
      if (
        name &&
        ((props.include && !matches(props.include, name)) ||
          (props.exclude && matches(props.exclude, name)))
      ) {
        return rawVNode
      }
      
      const key = rawVNode.key!
      const cachedVNode = cache.get(key)
      
      // 缓存命中
      if (cachedVNode) {
        // 更新访问顺序(LRU)
        keys.delete(key)
        keys.add(key)
        
        // 标记为已缓存
        rawVNode.el = cachedVNode.el
        rawVNode.component = cachedVNode.component
        
        // 标记需要保持激活状态
        rawVNode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE
      } else {
        // 缓存未命中,添加到缓存
        keys.add(key)
        
        // 检查缓存是否超过最大限制
        if (props.max && keys.size > parseInt(props.max as string)) {
          // LRU:移除最久未使用的
          const keyToDelete = keys.values().next().value
          keys.delete(keyToDelete)
          cache.delete(keyToDelete)
        }
        
        cache.set(key, rawVNode)
      }
      
      // 标记为需要保持激活
      rawVNode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE
      current = rawVNode
      
      return rawVNode
    }
  }
}

2. LRU 缓存算法实现

// LRU(Least Recently Used)缓存实现
class LRUCache<K, V> {
  private capacity: number
  private cache: Map<K, V>
  
  constructor(capacity: number) {
    this.capacity = capacity
    this.cache = new Map()
  }
  
  get(key: K): V | undefined {
    if (!this.cache.has(key)) {
      return undefined
    }
    
    // 访问时移动到最新位置
    const value = this.cache.get(key)!
    this.cache.delete(key)
    this.cache.set(key, value)
    
    return value
  }
  
  put(key: K, value: V): void {
    if (this.cache.has(key)) {
      this.cache.delete(key)
    } else if (this.cache.size >= this.capacity) {
      // 删除最久未使用的(第一个)
      const firstKey = this.cache.keys().next().value
      this.cache.delete(firstKey)
    }
    
    this.cache.set(key, value)
  }
}

五、如何做一个 demo 出来

1. 环境要求

  • Node.js 14+
  • Vue 3.0+
  • Vite 或 Vue CLI

2. 安装命令

# 创建 Vue 3 项目
npm create vue@latest vue-keepalive-demo

# 选择配置
# ✔ Project name: vue-keepalive-demo
# ✔ Add TypeScript? Yes
# ✔ Add JSX Support? No
# ✔ Add Vue Router for Single Page Application development? Yes
# ✔ Add Pinia for state management? Yes
# ✔ Add Vitest for Unit Testing? No
# ✔ Add an End-to-End Testing Solution? No
# ✔ Add ESLint for code quality? Yes

cd vue-keepalive-demo
npm install

3. 目录结构说明

vue-keepalive-demo/
├── src/
│   ├── components/
│   │   ├── TabContent/          # 标签页内容组件
│   │   ├── UserList/           # 用户列表组件
│   │   └── UserDetail/         # 用户详情组件
│   ├── views/
│   │   ├── Home.vue           # 首页
│   │   ├── About.vue          # 关于页
│   │   └── KeepAliveDemo.vue  # KeepAlive 演示页
│   ├── router/
│   │   └── index.ts           # 路由配置
│   ├── App.vue
│   └── main.ts
├── package.json
└── vite.config.ts

4. 最小可运行示例

基础使用

<!-- src/views/KeepAliveDemo.vue -->
<template>
  <div class="keepalive-demo">
    <h2>KeepAlive 基础演示</h2>
    
    <!-- 切换按钮 -->
    <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>
    
    <!-- KeepAlive 包裹动态组件 -->
    <KeepAlive>
      <component :is="currentComponent" :key="currentTab" />
    </KeepAlive>
    
    <!-- ���示缓存状态 -->
    <div class="cache-info">
      <h3>缓存信息</h3>
      <p>当前标签: {{ currentTab }}</p>
      <p>缓存组件: {{ cachedComponents.join(', ') }}</p>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, shallowRef } from 'vue'
import TabContent from '@/components/TabContent.vue'

// 标签配置
const tabs = [
  { name: 'tab1', label: '标签页 1', component: TabContent },
  { name: 'tab2', label: '标签页 2', component: TabContent },
  { name: 'tab3', label: '标签页 3', component: TabContent }
]

// 当前选中的标签
const currentTab = ref('tab1')

// 当前组件
const currentComponent = computed(() => {
  const tab = tabs.find(t => t.name === currentTab.value)
  return tab?.component || TabContent
})

// 模拟缓存状态
const cachedComponents = ref<string[]>(['tab1'])
</script>

<style scoped>
.keepalive-demo {
  padding: 20px;
}

.tabs {
  margin-bottom: 20px;
}

.tabs button {
  padding: 10px 20px;
  margin-right: 10px;
  border: 1px solid #ddd;
  background: white;
  cursor: pointer;
}

.tabs button.active {
  background: #1890ff;
  color: white;
  border-color: #1890ff;
}

.cache-info {
  margin-top: 30px;
  padding: 15px;
  background: #f5f5f5;
  border-radius: 4px;
}
</style>

带生命周期的组件

<!-- src/components/TabContent.vue -->
<template>
  <div class="tab-content">
    <h3>{{ title }}</h3>
    
    <!-- 计数器演示状态保持 -->
    <div class="counter">
      <p>计数: {{ count }}</p>
      <button @click="count++">增加</button>
      <button @click="count = 0">重置</button>
    </div>
    
    <!-- 输入框演示状态保持 -->
    <div class="input-demo">
      <input v-model="message" placeholder="输入一些内容..." />
      <p>输入的内容: {{ message }}</p>
    </div>
    
    <!-- 时间显示 -->
    <div class="time">
      <p>组件创建时间: {{ createdTime }}</p>
      <p>最后激活时间: {{ activatedTime }}</p>
      <p>激活次数: {{ activatedCount }}</p>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, onActivated, onDeactivated } from 'vue'

// 组件属性
const props = defineProps<{
  title?: string
}>()

// 响应式数据
const count = ref(0)
const message = ref('')
const createdTime = ref('')
const activatedTime = ref('')
const activatedCount = ref(0)

// 生命周期钩子
onMounted(() => {
  createdTime.value = new Date().toLocaleTimeString()
  console.log(`组件 ${props.title || '未知'} mounted`)
})

onUnmounted(() => {
  console.log(`组件 ${props.title || '未知'} unmounted`)
})

// KeepAlive 特有钩子
onActivated(() => {
  activatedTime.value = new Date().toLocaleTimeString()
  activatedCount.value++
  console.log(`组件 ${props.title || '未知'} activated`)
})

onDeactivated(() => {
  console.log(`组件 ${props.title || '未知'} deactivated`)
})
</script>

<style scoped>
.tab-content {
  padding: 20px;
  border: 1px solid #e8e8e8;
  border-radius: 4px;
  background: white;
}

.counter, .input-demo, .time {
  margin: 15px 0;
  padding: 10px;
  border: 1px dashed #ddd;
}

input {
  padding: 8px;
  width: 300px;
  margin-right: 10px;
}
</style>

路由级别的 KeepAlive

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'
import KeepAliveDemo from '@/views/KeepAliveDemo.vue'

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home,
    meta: {
      keepAlive: true  // 标记需要缓存
    }
  },
  {
    path: '/about',
    name: 'About',
    component: About,
    meta: {
      keepAlive: false  // 不缓存
    }
  },
  {
    path: '/keepalive',
    name: 'KeepAliveDemo',
    component: KeepAliveDemo
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes,
  
  // 滚动行为:保持滚动位置
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    } else {
      return { top: 0 }
    }
  }
})

export default router
<!-- src/App.vue -->
<template>
  <div id="app">
    <nav>
      <router-link to="/">首页</router-link>
      <router-link to="/about">关于</router-link>
      <router-link to="/keepalive">KeepAlive 演示</router-link>
    </nav>
    
    <!-- 路由视图使用 KeepAlive -->
    <router-view v-slot="{ Component, route }">
      <KeepAlive :include="cachedRoutes">
        <component 
          :is="Component" 
          :key="route.fullPath"
        />
      </KeepAlive>
    </router-view>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'

const router = useRouter()

// 计算需要缓存的路由
const cachedRoutes = computed(() => {
  return router.getRoutes()
    .filter(route => route.meta?.keepAlive)
    .map(route => route.name as string)
    .filter(Boolean)
})
</script>

5. 高级功能演示

include/exclude 配置

<template>
  <div>
    <h3>include/exclude 演示</h3>
    
    <!-- 只缓存 TabA 和 TabB -->
    <KeepAlive :include="['TabA', 'TabB']">
      <component :is="currentComponent" />
    </KeepAlive>
    
    <!-- 排除 TabC -->
    <KeepAlive :exclude="['TabC']">
      <component :is="currentComponent" />
    </KeepAlive>
    
    <!-- 使用正则表达式 -->
    <KeepAlive :include="/^Tab/">
      <component :is="currentComponent" />
    </KeepAlive>
  </div>
</template>

max 属性限制缓存数量

<template>
  <div>
    <h3>max 属性演示(LRU 缓存)</h3>
    
    <!-- 最多缓存 3 个组件 -->
    <KeepAlive :max="3">
      <component :is="currentComponent" :key="currentKey" />
    </KeepAlive>
    
    <div class="controls">
      <button 
        v-for="i in 5" 
        :key="i"
        @click="switchToComponent(`Component${i}`)"
        切换到组件 {{ i }}
      </button>
    </div>
    
    <div class="cache-status">
      <p>当前组件: {{ currentKey }}</p>
      <p>缓存队列: {{ cacheQueue.join(' → ') }}</p>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const currentKey = ref('Component1')
const cacheQueue = ref<string[]>(['Component1'])

function switchToComponent(key: string) {
  currentKey.value = key
  
  // 模拟 LRU 队列更新
  const index = cacheQueue.value.indexOf(key)
  if (index > -1) {
    // 已存在,移动到末尾
    cacheQueue.value.splice(index, 1)
  }
  cacheQueue.value.push(key)
  
  // 保持最多 3 个
  if (cacheQueue.value.length > 3) {
    cacheQueue.value.shift()
  }
}
</script>

滚动位置保持

<template>
  <div class="scroll-demo">
    <h3>滚动位置保持演示</h3>
    
    <KeepAlive>
      <ScrollContent :key="currentTab" />
    </KeepAlive>
    
    <div class="tabs">
      <button @click="currentTab = 'list1'">列表 1</button>
      <button @click="currentTab = 'list2'">列表 2</button>
      <button @click="currentTab = 'list3'">列表 3</button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import ScrollContent from './ScrollContent.vue'

const currentTab = ref('list1')
</script>

<style scoped>
.scroll-demo {
  height: 500px;
  display: flex;
  flex-direction: column;
}

.tabs {
  margin-top: 20px;
}
</style>
<!-- ScrollContent.vue -->
<template>
  <div class="scroll-content" ref="scrollContainer">
    <div v-for="item in 100" :key="item" class="item">
      项目 {{ item }}
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onActivated, onDeactivated } from 'vue'

const scrollContainer = ref<HTMLElement>()
let scrollTop = 0

// 保存滚动位置
onDeactivated(() => {
  if (scrollContainer.value) {
    scrollTop = scrollContainer.value.scrollTop
  }
})

// 恢复滚动位置
onActivated(() => {
  if (scrollContainer.value) {
    scrollContainer.value.scrollTop = scrollTop
  }
})
</script>

<style scoped>
.scroll-content {
  height: 400px;
  overflow-y: auto;
  border: 1px solid #ddd;
  padding: 10px;
}

.item {
  padding: 15px;
  border-bottom: 1px solid #eee;
}

.item:hover {
  background: #f5f5f5;
}
</style>

六、源码深度解析

1. 缓存数据结构

// Vue 3 KeepAlive 的缓存实现
interface KeepAliveCache {
  // 缓存映射
  cache: Map<string | number | symbol, VNode>
  // 键的访问顺序(LRU)
  keys: Set<string | number | symbol>
  
  // 添加缓存
  set(key: string | number | symbol, vnode: VNode): void
  
  // 获取缓存
  get(key: string | number | symbol): VNode | undefined
  
  // 删除缓存
  delete(key: string | number | symbol): boolean
  
  // 清空缓存
  clear(): void
}

2. 组件激活/失活流程

// 组件激活流程
function activateComponent(vnode: VNode) {
  // 1. 恢复组件实例
  const instance = vnode.component!
  
  // 2. 调用 activated 钩子
  if (instance.activated) {
    callHook(instance, 'activated')
  }
  
  // 3. 恢复子组件
  if (vnode.children) {
    for (const child of vnode.children) {
      if (isVNode(child) && child.component) {
        activateComponent(child)
      }
    }
  }
}

// 组件失活流程
function deactivateComponent(vnode: VNode) {
  // 1. 调用 deactivated 钩子
  const instance = vnode.component!
  if (instance.deactivated) {
    callHook(instance, 'deactivated')
  }
  
  // 2. 失活子组件
  if (vnode.children) {
    for (const child of vnode.children) {
      if (isVNode(child) && child.component) {
        deactivateComponent(child)
      }
    }
  }
}

3. include/exclude 匹配算法

function matches(pattern: string | RegExp | Array<string | RegExp>, name: string): boolean {
  if (Array.isArray(pattern)) {
    return pattern.some(p => matches(p, name))
  } else if (typeof pattern === 'string') {
    return pattern.split(',').map(s => s.trim()).includes(name)
  } else if (pattern instanceof RegExp) {
    return pattern.test(name)
  }
  return false
}

七、性能优化与最佳实践

1. 合理使用 include/exclude

<!-- 只缓存需要保持状态的组件 -->
<KeepAlive :include="['UserList', 'ProductList', 'OrderList']">
  <router-view />
</KeepAlive>

<!-- 排除不需要缓存的组件 -->
<KeepAlive :exclude="['Login', 'Register', 'ErrorPage']">
  <router-view />
</KeepAlive>

2. 设置合理的 max 值

<!-- 根据应用场景设置缓存上限 -->
<KeepAlive :max="5">  <!-- 标签页应用 -->
<KeepAlive :max="10"> <!-- 后台管理系统 -->
<KeepAlive :max="3">  <!-- 移动端应用 -->

3. 避免内存泄漏

// 在组件卸载时清理资源
onDeactivated(() => {
  // 清理定时器
  if (this.timer) {
    clearInterval(this.timer)
    this.timer = null
  }
  
  // 清理事件监听
  window.removeEventListener('resize', this.handleResize)
  
  // 清理 WebSocket 连接
  if (this.ws) {
    this.ws.close()
  }
})

onActivated(() => {
  // 重新初始化资源
  this.timer = setInterval(() => {
    this.updateData()
  }, 5000)
  
  window.addEventListener('resize', this.handleResize)
  this.connectWebSocket()
})

4. 结合路由使用

// 路由配置
const routes = [
  {
    path: '/list',
    component: ListPage,
    meta: {
      keepAlive: true,
      scrollTop: 0  // 记录滚动位置
    }
  },
  {
    path: '/detail/:id',
    component: DetailPage,
    meta: {
      keepAlive: false
    }
  }
]

// 路由守卫中处理缓存
router.beforeEach((to, from, next) => {
  // 从详情页返回列表页时,保持列表页缓存
  if (from.name === 'DetailPage' && to.name === 'ListPage') {
    // 保持缓存
  } else {
    // 其他情况可以清除缓存
  }
  next()
})

八、常见问题与解决方案

1. 组件不缓存的问题

<!-- 问题:组件没有 key,无法缓存 -->
<KeepAlive>
  <Component />  <!-- 缺少 key -->
</KeepAlive>

<!-- 解决方案:添加唯一的 key -->
<KeepAlive>
  <Component :key="componentKey" />
</KeepAlive>

<!-- 或者使用路由的 fullPath -->
<router-view v-slot="{ Component }">
  <KeepAlive>
    <component :is="Component" :key="$route.fullPath" />
  </KeepAlive>
</router-view>

2. 数据不更新的问题

<script setup>
// 问题:缓存组件的数据不会自动更新
const { data } = await fetchData()  // 只在 mounted 时执行

// 解决方案:在 activated 钩子中更新数据
onActivated(async () => {
  const { data } = await fetchData()
  // 更新数据
})
</script>

3. 滚动位置问题

// 问题:KeepAlive 不自动保存滚动位置
// 解决方案:手动保存和恢复
const scrollTop = ref(0)

onDeactivated(() => {
  scrollTop.value = document.documentElement.scrollTop
})

onActivated(() => {
  window.scrollTo(0, scrollTop.value)
})

4. 内存泄漏问题

// 问题:缓存组件中的定时器、事件监听器等未清理
// 解决方案:在 deactivated 中清理
let timer: number | null = null

onMounted(() => {
  timer = setInterval(() => {
    console.log('定时器执行')
  }, 1000)
})

onDeactivated(() => {
  if (timer) {
    clearInterval(timer)
    timer = null
  }
})

onActivated(() => {
  if (!timer) {
    timer = setInterval(() => {
      console.log('定时器重新启动')
    }, 1000)
  }
})

九、与 React 的对比

特性Vue KeepAliveReact 类似方案区别
内置支持否(需要第三方库)Vue 原生支持
生命周期activated/deactivated无对应钩子Vue 更完善
缓存策略LRU 算法手动管理Vue 自动管理
配置方式include/exclude/max手动实现Vue 更简单
路由集成容易需要配置Vue 更友好

React 实现类似功能

// 使用 react-activation
import { AliveScope, KeepAlive } from 'react-activation'

function App() {
  return (
    <AliveScope>
      <KeepAlive id="unique-id" cacheKey="cache-key">
        <Component />
      </KeepAlive>
    </AliveScope>
  )
}

十、总结

Vue KeepAlive 是一个强大的组件缓存解决方案:

  1. 核心价值:提升性能,改善用户体验
  2. 实现原理:基于 LRU 算法的智能缓存管理
  3. 生命周期:完整的 activated/deactivated 钩子
  4. 灵活配置:include/exclude/max 满足不同需求

最佳实践

  1. 为需要缓存的组件添加唯一的 key
  2. 合理���置 include/exclude 避免不必要的缓存
  3. 根据应用场景设置合适的 max 值
  4. 在 activated/deactivated 中管理资源
  5. 结合路由实现完整的页面缓存方案

最后提醒:KeepAlive 不是银弹,过度使用会导致内存占用过高。需要根据实际场景权衡缓存带来的性能提升和内存消耗。

如果对你有用,欢迎点赞、收藏、关注! 下一篇我们将介绍 10 个能大幅提升开发效率的 npm 工具包。

参考资料