Vue3用了keep-alive为什么组件状态还是没保存?
前段时间帮朋友调Vue3项目的标签页切换时,明明套了keep-alive,每次切回标签还是重新触发onMounted、数据全清空,折腾了半天才找到3个不太起眼但必踩的坑,今天索性把所有我遇到过、或者群里小伙伴常问的keep-alive无效情况整理成一套完整的排查思路,希望能帮到同样踩坑的人。
排查第一步:先确认组件是不是真的被keep-alive包裹住了
很多同学一开始会犯这个低级错误,但排查的时候总容易忽略,先从最基础的看起。
Vue3官方文档里明确提过,keep-alive要包裹动态组件(
<!-- 错误写法:keep-alive每次v-if为true都会重新创建,包裹的动态组件当然也会重建 -->
<template>
<keep-alive>
<component v-if="isShowTabA" :is="TabA" />
</keep-alive>
<keep-alive>
<component v-if="isShowTabB" :is="TabB" />
</keep-alive>
</template>
正确的写法应该是把v-if放在动态组件里,或者把所有需要缓存的静态组件都放在同一个keep-alive标签内,v-if/v-else-if/v-else嵌套在里面:
<!-- 正确写法1:动态组件带v-if或者不带v-if都可以,取决于切换逻辑 -->
<template>
<keep-alive>
<component v-if="currentTab === 'A'" :is="TabA" />
<component v-else :is="TabB" />
</keep-alive>
</template>
<!-- 正确写法2:多个keep-alive包裹静态组件?不,多个静态组件要放同一个里 -->
<template>
<keep-alive>
<TabA v-if="currentTab === 'A'" />
<TabB v-else-if="currentTab === 'B'" />
<TabC v-else />
</keep-alive>
</template>
检查包裹层级的一个小技巧:打开Vue DevTools,切换一下组件,看Elements标签里组件对应的DOM是不是真的只是隐藏(如果是用动态组件但实际DOM消失了),或者看Components标签里keep-alive下面的组件列表,切换的时候组件会不会从列表里消失——真缓存的话,组件会一直留在列表里,只是状态从Active变成Inactive,反之如果消失了,就是没被正确包裹或者缓存规则没匹配到。
排查第二步:检查缓存规则include/exclude/max是不是写反了或者写错了
Vue3的keep-alive新增了vnode key匹配功能(虽然之前Vue2也支持,但很多人没用过或者没注意匹配逻辑的细节),不过不管是用组件name属性匹配还是用vnode key匹配,规则写错了都会导致组件不被缓存。
首先确认组件有没有写name属性(除非你只靠vnode key匹配)
很多同学用Vue3的<script setup>语法糖,组件默认是没有暴露name属性的!这是Vue3 keep-alive无效最最常见的坑,没有之一——如果include/exclude是靠组件名匹配的,<script setup>里的组件没有显式声明name,就会匹配失败,自然不被缓存。
那怎么给<script setup>里的组件加name?有三种方法:
第一种是用组合式API里的defineOptions宏(Vue 3.3+版本官方推荐,不需要引入任何库):
<script setup>
// Vue 3.3+新增,直接在这里声明name、inheritAttrs这些选项
defineOptions({
name: 'UserTab',
inheritAttrs: false
})
// 其他业务代码...
</script>
第二种是用一个单独的<script>标签(非setup模式)来声明name:
<script>
// 单独的script标签,默认导出name
export default {
name: 'ProductTab'
}
</script>
<script setup>
// 业务代码...
</script>
第三种是用unplugin-vue-define-options插件(Vue 3.3以下版本用这个,功能和官方的defineOptions一样),不过现在Vue 3.4都普及了,尽量用官方的吧,不用额外配置插件,太麻烦。
然后确认include/exclude的匹配规则
include/exclude可以是字符串、正则表达式或者数组,注意字符串匹配是完全匹配组件的name属性,不是文件名!比如你文件名是UserDetail.vue,但name写的是UserDetailTab,那include里写UserDetail就匹配不到,必须写UserDetailTab。
举个常见的错误匹配例子:
<!-- 错误写法:正则表达式如果用在template里的v-bind,不能直接写斜杠开头的字面量,需要用RegExp构造函数或者data/computed里的变量 -->
<template>
<keep-alive include="/^User/">
<component :is="currentTab" />
</keep-alive>
</template>
上面的写法在Vue3 template里会把/^User/当成普通字符串,完全匹配不到任何组件,正确的写法是把正则放在computed里:
<script setup>
import { computed } from 'vue'
// 把需要缓存的组件名的正则放在computed里
const cachedNamePattern = computed(() => /^User/)
</script>
<template>
<!-- v-bind绑定computed变量 -->
<keep-alive :include="cachedNamePattern">
<component :is="currentTab" />
</keep-alive>
</template>
如果用数组的话,数组里的每个元素都要是字符串(完全匹配),不要混其他类型:
<!-- 正确的数组匹配写法 -->
<template>
<keep-alive :include="['UserTab', 'ProductTab', 'OrderTab']">
<component :is="currentTab" />
</keep-alive>
</template>
exclude的规则和include完全相反,匹配到的组件不会被缓存,如果两个都写了,exclude的优先级更高,比如include写了UserTab,exclude也写了UserTab,那UserTab就不会被缓存,这点要注意。
最后检查max属性是不是限制太死了
max属性可以限制keep-alive缓存的组件数量,超过这个数量的话,最久没被使用的组件会被销毁(LRU策略),如果你的max写得太小,比如只有2,但你同时开了3个标签页,那切到第三个标签页的时候,第一个标签页的组件就会被销毁,再切回第一个就会重新触发onMounted,看起来就是“keep-alive偶尔失效”。 这种情况一般不是代码逻辑问题,就是max设置的问题,或者标签页数量没控制好,比如有些同学的标签页是无限新增的,又设置了max=5,那开第6个标签的时候最早的那个就会被清掉,这时候可以考虑调整max的大小,或者实现标签页关闭时主动销毁keep-alive缓存里的对应组件(后面会讲怎么主动销毁)。
排查第三步:检查路由是不是用了router-view的嵌套或者key导致缓存失效
如果你的项目用了Vue Router,那keep-alive一般是包裹在router-view外面的,这时候路由相关的配置也会导致缓存失效。
第一个路由相关的坑:嵌套router-view的keep-alive包裹层级
很多同学的项目有嵌套路由,比如/user对应UserLayout组件,UserLayout里又有/user/info和/user/order两个子路由,这时候如果只在App.vue里包裹了UserLayout的router-view,但子路由的router-view没包裹keep-alive,那子组件UserInfo和UserOrder当然不会被缓存。
举个嵌套路由的正确包裹例子:
<!-- App.vue:包裹父路由的router-view,缓存UserLayout -->
<template>
<div class="app-container">
<keep-alive>
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</keep-alive>
</div>
</template>
<!-- UserLayout.vue:包裹子路由的router-view,缓存UserInfo和UserOrder -->
<template>
<div class="user-layout">
<div class="user-sidebar">侧边栏</div>
<div class="user-content">
<keep-alive :include="['UserInfo', 'UserOrder']">
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</keep-alive>
</div>
</div>
</template>
注意嵌套路由里的每个需要缓存的router-view层级都要单独包裹keep-alive,不能指望父级的keep-alive缓存子级的组件——因为子级的组件是渲染在父级组件的router-view里的,父级的keep-alive只会缓存父级组件本身,子级组件的销毁重建由父级组件里的router-view控制。
第二个路由相关的坑:router-view绑定了错误的key
很多同学为了解决路由参数变化但组件不重新渲染的问题,会给router-view绑定一个key="$route.fullPath",这确实能解决参数变化的问题,但如果同时用了keep-alive,就会导致组件每次路由变化(包括切换到其他同层级路由)都重新创建——因为$route.fullPath每次变化都会改变key,Vue会认为是不同的组件,直接销毁旧的创建新的,不管keep-alive有没有包裹。
举个错误的例子:
<!-- 错误写法:绑定了$route.fullPath作为key,即使有keep-alive,每次路由变也会重建 -->
<template>
<keep-alive>
<router-view v-slot="{ Component }">
<component :key="$route.fullPath" :is="Component" />
</router-view>
</keep-alive>
</template>
那怎么解决“路由参数变化不重新渲染”和“keep-alive缓存”的矛盾?有两种方法:
第一种是区分需要参数变化重建和不需要的组件,如果是不需要重建的组件(比如列表页,有搜索筛选但可以用watch监听参数变化来刷新数据),就不要绑定key;如果是需要重建的组件(比如详情页,参数完全不同的话需要重新加载内容),可以在路由元信息里加个配置,比如meta.needRefreshOnParamChange,然后在router-view的key里根据这个配置来决定用什么:
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
// 根据路由元信息决定key
const routerViewKey = computed(() => {
if (route.meta.needRefreshOnParamChange) {
return route.fullPath
} else {
// 用路由名称或者空字符串?空字符串的话Vue会自动复用组件
return route.name
}
})
</script>
<template>
<keep-alive :include="['ListTab']">
<router-view v-slot="{ Component, route: currentRoute }">
<component :key="routerViewKey" :is="Component" />
</router-view>
</keep-alive>
</template>
第二种方法是对需要参数变化重建但又想缓存历史状态的组件,用组合式API里的onActivated和onDeactivated钩子,或者用provide/inject结合Vuex/Pinia来保存历史状态,比如详情页可以把之前打开的ID和数据存在Pinia里,下次打开同一个ID的时候直接从Pinia里取,不同的ID再重新请求,这样既解决了参数变化的问题,又能缓存历史状态。
排查第四步:检查组件内部的钩子函数或者逻辑有没有手动重置状态
有些时候keep-alive明明生效了,组件也留在了Vue DevTools的Components列表里,状态从Active变成Inactive再变回来,但数据还是清空了——这时候就要检查组件内部的代码了。
第一个内部逻辑的坑:在onMounted里重置了所有数据
很多同学习惯在onMounted里把所有数据初始化为默认值,比如表单页面在onMounted里把formData清空,搜索页面在onMounted里把searchParams重置——但keep-alive缓存的组件,第一次加载的时候会触发onMounted,之后切换回来只会触发onActivated,不会再触发onMounted,哦不对,等一下,是不是搞反了?不,刚才说的朋友的情况是v-show切换手动清空,但如果是keep-alive包裹的组件,要是在onActivated里重置了数据,那每次切回来都会清空,看起来就是“没缓存”!对,onActivated是每次组件从Inactive变成Active的时候都会触发的,千万不要在里面把所有数据都重置成默认值,除非你确实有这个需求(比如每次打开标签页都要重新填写表单)。 举个正确的例子:
<script setup>
import { ref, onMounted, onActivated } from 'vue'
const formData = ref({
username: '',
email: '',
phone: ''
})
// 第一次加载的时候从localStorage或者接口获取初始数据
onMounted(() => {
const savedData = localStorage.getItem('userFormData')
if (savedData) {
formData.value = JSON.parse(savedData)
}
})
// 每次切回来的时候只做一些必要的操作,比如聚焦输入框,不要重置数据
onActivated(() => {
document.querySelector('#username-input')?.focus()
})
// 每次切出去的时候保存数据到localStorage
onDeactivated(() => {
localStorage.setItem('userFormData', JSON.stringify(formData.value))
})
</script>
第二个内部逻辑的坑:用了shallowRef或者shallowReactive但没注意更新方式?不,这个和keep-alive无关,主要是有些同学用了v-if在组件内部切换子组件,子组件没有被包裹keep-alive,所以每次子组件切换都会重建,看起来像是父组件的keep-alive没生效——这种情况只要给内部的子组件也加上keep-alive就行。
额外技巧:如何主动销毁keep-alive缓存里的组件?
有时候我们需要主动销毁某个缓存的组件,比如关闭标签页的时候,不想让它继续占内存,或者下次打开这个标签页的时候要重新加载数据,那怎么主动销毁呢?
Vue3的keep-alive组件暴露了一个cache属性和一个keys属性,但这两个属性是内部的,官方不推荐直接操作(虽然直接操作也能用,但版本更新可能会失效),官方推荐的方法是用include/exclude的响应式数组,通过修改数组来添加或删除需要缓存的组件名。
举个标签页关闭时主动销毁的例子:
<script setup>
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
// 所有打开的标签页
const tabs = ref([
{ name: 'HomeTab', title: '首页', path: '/' }
])
// 当前激活的标签页的name
const activeTabName = computed(() => route.meta.tabName || route.name)
// 需要缓存的组件名的响应式数组,从tabs里提取
const cachedNames = computed(() => tabs.value.map(tab => tab.name))
// 关闭标签页的方法
const closeTab = (tabName) => {
// 找到要关闭的标签页的索引
const index = tabs.value.findIndex(tab => tab.name === tabName)
if (index === -1) return
// 从tabs里删除
tabs.value.splice(index, 1)
// 如果关闭的是当前激活的标签页,跳转到前一个或后一个标签页
if (tabName === activeTabName.value) {
const nextTab = tabs.value[index] || tabs.value[index - 1]
router.push(nextTab.path)
}
}
</script>
<template>
<div class="tab-container">
<!-- 标签页头部 -->
<div class="tab-header">
<div
v-for="tab in tabs"
:key="tab.name"
class="tab-item"
:class="{ active: activeTabName === tab.name }"
@click="router.push(tab.path)"
>
{{ tab.title }}
<span v-if="tab.name !== 'HomeTab'" class="close-btn" @click.stop="closeTab(tab.name)">×</span>
</div>
</div>
<!-- 标签页内容 -->
<div class="tab-content">
<keep-alive :include="cachedNames">
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</keep-alive>
</div>
</div>
</template>
上面的例子里,cachedNames是从tabs响应式数组里提取的computed属性,当关闭某个标签页时,tabs数组会删除对应的元素,cachedNames也会跟着删除对应的组件名,keep-alive检测到include数组里没有这个组件名了,就会自动销毁缓存里的这个组件,下次再打开这个标签页的时候就会重新触发onMounted,完美解决了主动销毁的需求。
Vue3 keep-alive无效的完整排查清单
最后给大家整理一个快速排查的清单,以后遇到keep-alive无效的问题,照着这个清单一条一条查,99%的问题都能解决:
- 检查组件是不是真的被keep-alive包裹了,v-if有没有写在外面,是不是用了v-show切换;
- 打开Vue DevTools,看切换组件时keep-alive下面的组件列表有没有消失,状态是不是Active/Inactive切换;
- 检查
<script setup>里的组件有没有显式声明name属性(Vue 3.3+用defineOptions,3.3以下用单独的script标签); - 检查include/exclude的匹配规则,是不是完全匹配组件name,正则是不是正确绑定,exclude的优先级是不是更高;
- 检查max属性是不是设置得太小,导致组件被LRU策略销毁;
- 检查嵌套router-view的包裹层级,每个需要缓存的router-view都要单独包裹;
- 检查router-view是不是绑定了错误的key(route.fullPath);
- 检查组件内部是不是在onActivated里重置了数据,或者用了内部v-if切换没包裹keep-alive的子组件。
好啦,以上就是我整理的所有Vue3 keep-alive无效的情况和解决方法,希望能帮到大家,如果还有其他没覆盖到的坑,欢迎在评论区留言一起讨论~
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网

