做后台管理系统这几年,被提得最多的需求之一,就是多页签状态下“切走再切回来,页面状态还在”。尤其是客户录到一半的复杂表单、筛了一堆条件的列表页,切出去看一眼别的菜单再回来,数据全没了,这种体验说严重点可以直接劝退用户。
若依这套框架本身对多页签(tags-view)的支持已经算完整了:打开一个菜单自动生成一个 tab,顶部可以来回切换。但很多同学实际用下来会发现,有些页面切走再回来,列表被重置、表单被清空、分页回落到第一页,和重新刷新几乎没有区别。所以这次就围绕“若依vue切换tab页签时页面保持不重新加载”这个需求,把背后的 keep-alive 缓存链路彻底拆开,再给出可以直接照抄的配置步骤和踩坑经验。
适用版本是 RuoYi-Vue2 和 RuoYi-Vue3 的前后端分离前端工程,两个版本核心逻辑是一模一样的,差异只在路由 meta 字段和部分写法,文中我会专门标注。
1. 先搞懂一个核心机制:组件为什么会被重新加载
1.1 没有缓存保护时,Vue 路由切换的真实行为
Vue 的路由切换,本质上是动态组件的卸载和挂载。当从 A 页面点到 B 页面,vue-router 会销毁 A 的组件实例,把页面 DOM 移除,随后挂载 B。当再次回到 A,又会创建一个全新的 A 实例。
用大白话打个比方:这就像你雇了几个临时工,A 临时工干完活就办了离职,你切回来只能重新招一个,之前他桌上的草稿、填了一半的表格、搜过半天已经选中的条件,全被他带走了。
这个过程里,A 页面的所有 data 状态、输入框内容、列表请求结果、滚动位置,默认都会随着组件销毁一起消失。所以若依的 tab 切换如果只是单纯的路由切换,那“页面保持不重新加载”就无从谈起。
1.2 keep-alive 改变了什么
Vue 自带一个<keep-alive>内置组件,专门解决这类问题。用 keep-alive 包住动态组件或路由视图后,组件实例在切换时不会销毁,而是进入“停用”状态:实例还在内存里、data 还在、DOM 被暂时移走,切回来时直接从缓存里恢复,不用重建。
关于生命周期变化这一点,建议你收藏这张表,后面排查“数据不刷新”类问题时十有八九要回到它上面找答案。
| 生命周期钩子 | 无 keep-alive | 有 keep-alive |
|---|---|---|
| created / setup | 每次进入都执行 | 只有首次进入执行 |
| mounted | 每次进入都执行 | 只有首次进入执行 |
| activated | 不支持 | 每次从缓存恢复时都执行 |
| deactivated | 不支持 | 每次切走时都执行 |
| unmounted / beforeDestroy | 每次离开都执行 | 只有缓存被显式清除时才执行 |
1.3 include 的作用和最容易踩的误区
keep-alive 的include属性用来指定“哪些组件需要缓存”,可以传字符串、正则或数组。这个属性有一个让无数人踩坑的机制:它匹配的是组件实例的 name,而不是路由的 name。
组件实例的 name 在 Vue3 中通过defineOptions声明,或通过<script setup name="xxx">这种插件语法声明;Vue2 中就是组件对象里的name字段。而 vue-router 的 name 是路由记录里的配置,两者根本不是同一个东西。
若依恰恰是把路由的 name 塞进 cachedViews 数组,再由 keep-alive 的include去匹配组件 name,所以若依官方文档里反复强调:组件 name 必须和路由 name 保持一致,就是这个原因。如果你只改了路由配置,组件 name 缺失或者写成了别的值,include 匹配不上,缓存自然不生效,而且整个过程没有任何报错。
2. 若依里,控制页面缓存的三层机构
2.1 先从文件藏身地看整体链路
若依前端工程里,跟 tab 缓存强相关的文件主要是下面这几个,建议你现在就打开工程对照着看:
src/layout/components/AppMain.vue:整个页面内容渲染的出口,keep-alive 就包在这里;src/store/modules/tagsView.js:负责维护两个数组,visitedViews(已打开的 tab 列表)和 cachedViews(需要缓存的组件 name 列表);src/permission.js:路由全局守卫,进入路由后调用store.dispatch('tagsView/addView', to),把当前页面加入 tab 和缓存数组;src/store/modules/permission.js:负责动态生成可访问路由,菜单表里配置的 meta 信息会通过它带进路由记录。
它们按下面这个顺序协作:
- 用户进入某个页面,路由守卫校验通过;
- 全局守卫向 tagsView store 派发 addView;
- addView 内部先 addVisitedView(把页面加入顶部 tag 列表),再 addCachedView(把路由 name 塞进 cachedViews);
- AppMain 的 keep-alive 通过
:include="cachedViews"读取数组,数组里包含的组件 name 才会走缓存。
这四步缺了任何一环,页面都做不到“不重新加载”。
2.2 tagsView store 里到底做了什么
直接看核心的 mutation 就够了。这是若依 Vue3 版的逻辑:
ADD_CACHED_VIEW: (state, view) => { if (state.cachedViews.includes(view.name)) return if (view.meta.cache !== false) { state.cachedViews.push(view.name) } }注意这里的判断:view.meta.cache !== false。也就是说,只要路由 meta 里没有显式写cache: false,这个页面默认就会加入缓存。反过来,如果你想排除某个页面,就在路由 meta 里写cache: false。
而在若依 Vue2 版本里,字段名刚好相反,用的是noCache:
ADD_CACHED_VIEW: (state, view) => { if (state.cachedViews.includes(view.name)) return if (!view.meta.noCache) { state.cachedViews.push(view.name) } }默认不写noCache时也是缓存,写了noCache: true代表排除。两个版本语义相反,迁移代码时最容易看走眼。
删除缓存的动作同样重要,关闭 tab 时触发:
DEL_CACHED_VIEW: (state, view) => { const index = state.cachedViews.indexOf(view.name) index > -1 && state.cachedViews.splice(index, 1) }关闭 tab 不仅仅是关掉标签,它同时会把该路由对应的组件 name 从 cachedViews 中移除。所以你在若依里把某个页面 tab 关掉再重新打开,这个页面一定是重新加载的,因为缓存已经被清掉了。
2.3 AppMain 中的 keep-alive 是最后一道门
AppMain.vue 的模板结构,Vue3 版大致是这样的:
<router-view v-slot="{ Component, route }"> <transition name="fade-transform" mode="out-in"> <keep-alive :include="cachedViews"> <component :is="Component" :key="route.path" /> </keep-alive> </transition> </router-view>cachedViews来自 store 的 state,route.path作为 key。这里的:key="route.path"很关键,后面讲页面串数据时会再提到它。
到这里三层链路已经清楚了:store 管“该缓存谁”,AppMain 管“缓存这几个”,路由 meta 里的cache/noCache管“这个页让不让缓存”。接对了,切换 tab 不重新加载就是顺理成章的事。
3. 实操:让一个列表页在切换 tab 后保持状态
3.1 第一步:给组件起好 name
随便拿一个列表页举例,比如若依的用户管理页面,路径是src/views/system/user/index.vue。
Vue3 里如果用了<script setup>,组件 name 默认不会写进组件定义,需要在 script 标签上补充:
<script setup name="SystemUser"> // 你的逻辑 </script>注意,这种<script setup name="xxx">的写法是若依前端工程里已经内置支持的语法扩展。如果你是自己搭建的工程,要么安装对应的 vite 插件,要么改用defineOptions声明。Vue2 的写法就是常规的导出对象:
export default { name: 'SystemUser', data() { ... } }然后去src/router/index.ts或动态路由生成处找到用户管理的路由定义,确认路由的 name 也是SystemUser。如果路由里写的是别的值,include 匹配不上,缓存会静默失败,不会有任何报错提示。
3.2 第二步:路由 meta 的配置与版本差异
若依 Vue3 版的路由定义形如:
{ path: '/system/user', component: Layout, redirect: '/system/user', name: 'SystemUser', meta: { title: '用户管理', icon: 'user', cache: true } }如果不写cache字段,默认行为也是缓存,所以这一步其实什么都不用做。想要禁用缓存就在 meta 里写cache: false,比如首页、报表大屏这类希望每次进入都拉新数据的页面。
Vue2 版则是:
meta: { title: '用户管理', icon: 'user', noCache: false }不写noCache默认缓存;写了noCache: true不缓存。
这里有一个非常实用的捷径:如果你用的是若依自带的菜单管理功能,在“系统管理 -> 菜单管理 -> 新增/编辑菜单”里有一个“是否缓存”的选项。选“是”后,数据库菜单表会写入对应字段,前端动态生成路由时自动带进 meta。也就是说,大多数页面你根本不用手改路由文件,在后台菜单配置里把“是否缓存”打开就行。
3.3 第三步:快速验证缓存到底有没有生效
配置完很多同学心里没底。我的验证方法是直接打日志,最直观。
在页面组件里写:
onMounted(() => { console.log('页面组件被创建') }) onActivated(() => { console.log('页面被激活(切回来了)') })操作流程:
- 首次进入页面,控制台输出“页面组件被创建”和“页面被激活”;
- 切换去另一个 tab,控制台输出“页面被停用”;
- 切回当前页面。
如果第 3 步只输出“页面被激活”,说明缓存已经生效——组件实例没被销毁,只是从缓存中恢复。如果第 3 步又看到“页面组件被创建”,说明缓存没生效,优先回头查组件 name 和路由 meta。
想更直观地验证状态保持,就在列表页放一个输入框,随便敲几个字符,切走再切回来;如果输入框内容还在,就说明页面实例没有被重建,之前再深层的状态也都保留着。
3.4 进阶:按需刷新数据,而不是永远固守旧状态
状态保持之后,业务上通常还会有新要求:希望表单内容不丢,但列表数据每次切回来都拉最新。这就要用到activated钩子。
最简单的做法是把请求逻辑从 mounted 挪到 activated 里:
onActivated(() => { getList() })因为 activated 在首次进入时也会执行,所以 mounted 里不用再重复调用。
但这样又带来一个体验问题:activated 在每次 tab 切回时都会无条件执行。哪怕用户刚在列表页点完搜索,切走再切回来,列表还会被重新拉一遍。如果后端接口慢,这个“多出来的请求”反而是负优化。
更贴合实际业务的写法是加一个刷新标志位,比如refreshFlag:
const refreshFlag = ref(false) // 在搜索、重置、分页变化等主动查询动作里置为 true function handleSearch() { refreshFlag.value = true getList() } onActivated(() => { if (refreshFlag.value) { refreshFlag.value = false getList() } })这样 tab 切换回来时,如果用户之前没有做任何主动查询操作,就不会白白发请求,既保住了页面状态,又控制了网络开销。
4. 常见问题速查表与排雷指南
4.1 一张表看懂问题、原因和解决
我把平时排查这类问题的高频原因整理成了表格,直接照表查。
| 现象 | 原因 | 解决方法 |
|---|---|---|
| 切走再回来,页面整体重新加载 | cachedViews 里没有对应组件 name | 确认组件 name 与路由 name 一致,确认 meta 没禁用缓存 |
| 第一次能缓存,刷新浏览器后失效 | 刷新会重建整个应用,store 里的缓存数组被清空 | 属正常现象,需要恢复的数据在 activated 里处理 |
| 列表页切回来数据不刷新 | 请求逻辑写在 mounted,activated 里没有处理 | 按 3.4 的方式把刷新策略切到 activated |
| 两个页面显示同样内容 | 两个路由复用了同一个组件,缓存实例冲突 | 检查 key 和路由 path,必要时拆成独立组件文件 |
| 关闭其他 tab 后,别的页面重新加载 | “关闭其他”会清空除了当前页外的全部缓存 | 了解设计,必要时修改 DEL_OTHERS_CACHED_VIEWS |
| 多级菜单下的页面缓存不生效 | 页面渲染在内部 router-view,外层 AppMain 缓存的是父组件 | 拍平路由层级,或子级页面内部自行处理 keep-alive |
4.2 多级菜单缓存失效的底层原因
这个点值得单独展开。若依的 AppMain 里只有一个<router-view>,它渲染的是 Layout 直接子路由。如果你的菜单结构是系统管理(目录)下挂用户管理(页面),由于若依动态路由会为叶子页面生成/system/user这类绝对路径并直接挂在 Layout 下,AppMain 里渲染的就是用户管理页面,缓存能正常生效。
但如果你把页面嵌在了更深的多级嵌套路由中,比如四级五级目录,AppMain 里渲染的可能不是最终叶子组件,而是一层中间组件,这层中间组件里又套了自己的 router-view。keep-alive 在 AppMain 层缓存的只是中间组件,真正想缓存的叶子页面在内部 router-view 里每次都会被重建。
处理办法有三种:
- 尽量把菜单层级控制在两级以内,让叶子页面直接挂在 Layout 下;
- 如果必须多级,把叶子页面用绝对 path 独立成一级路由,菜单结构用父级目录的显隐配置来模拟层级;
- 在中间组件的 router-view 外自己包一层 keep-alive,include 数组自行维护,但复杂度会明显上升。
我见过不少项目选择把三级菜单拍平成两级,一劳永逸,后期维护成本最低。
4.3 缓存不生效的四个“隐形杀手”
除了表格里列的,还有几个隐蔽原因,遇到缓存不起作用时逐个排查。
第一,组件没有 name。Vue3 的<script setup>如果不用插件或 defineOptions 声明 name,keep-alive 的 include 根本匹配不到。
第二,路由 meta 被后端菜单数据覆盖。若依支持菜单表里动态配置参数,如果你在系统管理的菜单配置里填了 cache=false 或者 noCache=true 之类的值,前端动态路由生成时就会带进 meta,把你代码里原本的配置盖掉。排查时翻一下菜单配置。
第三,大小写不一致。路由 name 是 systemUser,组件 name 写成 SystemUser,二者严格区分大小写,直接匹配失败。
第四,如果你用的是 Vue3 + TypeScript 改造版的若依,注意route.meta的类型默认是RouteMeta,里面没有 cache 字段,直接写to.meta.cache !== false会有 TS 报错。解决办法是给 vue-router 的 RouteMeta 做模块扩展声明,把 cache、noCache、title、icon 这些字段补全。
4.4 若依右键刷新功能的原理
顶部 tab 右键菜单里的“刷新”,很多人不理解为什么它能强制刷新一个已经被缓存的页面。原理其实很直接:
- 先把当前页面从 cachedViews 中删除;
- 通过一个
/redirect空页面路由跳转; /redirect页面的守卫里瞬间再跳回原页面;- 因为缓存已经被清除,跳回原页面时组件必然重建。
这个设计很巧妙,而且刷新完成后 store 的 addView 又会把该页面重新加进缓存。所以刷新之后你再切走切回来,页面依旧保持状态,符合预期。
5. 我的实操经验与几个独门技巧
5.1 列表页“首次加载 + 后续保持 + 手动刷新”三段式
这是我在真实项目里用得最多的模式,分享出来供参考。
在列表页中:
- 查询参数、分页页码、折叠面板状态、勾选中的行数据,全部保留在组件 data 里,这些天然会被 keep-alive 缓存;
- 首次 mounted 时只做一次初始化请求;
- 每次切回来时,用 activated 做“温和恢复”——不重新拉列表,只恢复滚动条位置或激活一个已展开的行;
- 真正的数据刷新(搜索、重置、增删改之后)仍然由用户操作触发,和 tab 切换解耦。
配合 3.4 的 refreshFlag,很多页面能做到“零重复请求”,后端同事也非常喜欢这种页面。
下面是我常用的一个模板,改动成本很低:
const queryParams = ref({ pageNum: 1, pageSize: 10 }) const total = ref(0) const rows = ref([]) const loading = ref(false) const needRefresh = ref(false) const firstLoad = ref(true) function fetchList() { loading.value = true listUser(queryParams.value).then(res => { rows.value = res.rows total.value = res.total }).finally(() => { loading.value = false }) } onMounted(() => { fetchList() }) onActivated(() => { if (firstLoad.value) { firstLoad.value = false return } if (needRefresh.value) { needRefresh.value = false fetchList() } }) function handleSearch() { queryParams.value.pageNum = 1 needRefresh.value = true fetchList() }5.2 开发阶段监控缓存数组的一招
排查缓存问题时,我习惯在 AppMain 里临时 watch cachedViews,看看每个路由到底有没有进数组:
watch(cachedViews, (newVal) => { console.log('当前缓存中的组件:', newVal) }, { deep: true })切一个页面看一眼,哪个路由 name 被加了,哪个没加,一目了然。这比在页面里到处打断点快得多。排查完记得删掉,避免在生产环境刷屏。
5.3 注意“关闭其他标签页”的副作用
若依右键菜单里的“关闭其他”会把非当前的所有 tab 全部关掉。细看代码,它连这些页面的缓存也一并清了:
DEL_OTHERS_CACHED_VIEWS: (state, view) => { const index = state.cachedViews.indexOf(view.name) if (index > -1) { state.cachedViews = state.cachedViews.slice(index, index + 1) } else { state.cachedViews = [] } }意思是,用户点了“关闭其他”后,再重新打开某个刚才被关掉的菜单,那个页面会从头加载。大多数业务场景下这算是合理的,但偶尔会有客户抱怨“点了一下,所有之前填的东西都没了”。如果产品上希望“关闭其他只关标签、不破坏缓存”,可以按“只移除被关闭页面的 name”的思路去改这个 mutation,而不是直接清空整个数组。
5.4 退出登录和切换账号时的缓存清理
提醒一个容易被忽略的问题:退出登录时如果只清掉了用户信息和 token,没有重置 tagsView 的 visitedViews 和 cachedViews,下一个人登录后可能看到上一个账号打开的 tab,甚至点进去还能读到上一个账号的缓存数据。
若依在 logout 的 action 里封装了重置逻辑,正常情况下会清干净。但如果你自己改了退出流程,或者做了多账号切换、租户切换,务必检查这两个数组是否被重置,否则很容易出串数据的生产事故。
做后台管理系统这几年,我个人体会是:keep-alive 不是炫技,它是中后台系统里最接地气的一套能力。多页签模式下,“切换 tab 页面不重新加载”能不能做好,直接影响用户每天的操作效率。若依把大部分机制都封装好了,我们要做的就是理清 name、meta、cachedViews 这条链路,再根据业务场景决定哪些页面该缓存、哪些页面该在激活时刷新。文里的代码和排查思路都是我在实际项目里反复验证过的,照着配基本不会跑偏。后续如果页面复杂度上来,需要做动态表单、数据大屏这类强交互页面,还可以在这套机制上继续叠加路由复用、组件缓存分级等玩法,原理还是同一套。