Vue 3 多页签 keep-alive:缓存失效与重复请求排查
- 江河
- 2026年7月16日
- 2 mins
- 前端开发 实战
- keep alive pinia vue router vue3
后台管理系统常用“左侧菜单 + 顶部多页签”的布局。页签切换时需要保留筛选条件、分页和滚动位置,关闭后再次打开又要得到全新页面。页面内部如果还有一层 Tab 缓存,问题会更复杂。
本文记录两个相互关联的问题:
- 嵌套
<keep-alive>配合动态 wrapper 和include时,关闭页签触发deactivate is not a function。 - 被缓存但未激活的子组件继续监听
route.query,导致多个详情接口同时请求。
页面结构与目标
layouts/index.vue 外层 keep-alive:缓存顶部页签
└─ views/data-source/index.vue 内层 keep-alive:缓存页面内 Tab
├─ database/index.vue
└─ file-system/index.vue
缓存行为需要同时满足下面三点:
| 操作 | 预期行为 |
|---|---|
| 在顶部页签之间切换 | 保留页面状态 |
| 在数据库与文件系统之间切换 | 保留各自状态 |
| 关闭顶部页签后重新打开 | 创建新实例,不恢复旧状态 |
问题一:关闭页签触发 deactivate 报错
最初的布局只有一个 <router-view />。切换路由时,旧组件会被卸载,筛选条件和分页也随之丢失。
加入 <keep-alive> 后,切换状态得以保留,但还需要在关闭页签时让该缓存失效。第一版实现给路由组件动态包了一层具名组件,再通过 include 控制缓存名单:
<router-view v-slot="{ Component, route }">
<keep-alive :include="includeNames">
<component
v-if="Component"
:is="wrapComponent(Component, resolveCacheName(route))"
/>
</keep-alive>
</router-view>
关闭一个页签、收缩 include 后,控制台出现:
Uncaught (in promise) TypeError:
parentComponent.ctx.deactivate is not a function
触发条件
这次问题出现在以下组合中:
- 路由组件内部还有一层
<keep-alive>。 - 外层
<keep-alive>与实际路由组件之间插入了动态 wrapper。 - wrapper 的组件身份和
include名单同时发生变化。
Vue 在剪枝缓存时会沿组件树处理带有 keep-alive 标记的 vnode。当前结构让卸载链上的父上下文与标记不再匹配,最终调用不到 KeepAlive 实例提供的 deactivate。
这个结论来自当前项目的触发结构和调用栈。它不是“动态 wrapper 在所有场景都不可用”,但在嵌套缓存中继续依赖这套结构,维护成本和升级风险都偏高。
用版本化 key 管理缓存身份
移除 wrapper 和动态 include,让外层 <keep-alive> 直接持有路由组件。用业务页签 key 加版本号表达缓存身份:
<!-- layouts/index.vue -->
<router-view v-slot="{ Component, route }">
<keep-alive :max="20">
<component
v-if="Component"
:is="Component"
:key="resolveCacheKey(route)"
/>
</keep-alive>
</router-view>
// stores/tabs.js
const versions = ref({})
function getCacheKey(tabKey) {
if (!tabKey) return ''
const version = versions.value[tabKey] || 0
return version ? `${tabKey}@${version}` : tabKey
}
function removeTab(tabKey) {
const tabIndex = tabs.value.findIndex((tab) => tab.key === tabKey)
if (tabIndex === -1) return null
tabs.value.splice(tabIndex, 1)
versions.value[tabKey] = (versions.value[tabKey] || 0) + 1
if (activeKey.value !== tabKey) return null
const nextTab = tabs.value[tabIndex] || tabs.value[tabIndex - 1]
activeKey.value = nextTab?.key || ''
return nextTab?.path || null
}
const resolveCacheKey = (route) => {
const tabKey = route?.meta?.tabKey || route?.path || ''
return tabsStore.getCacheKey(tabKey)
}
这套方案的行为是:
- 页签切换时 key 不变,组件命中原缓存。
- 页签关闭时版本号递增。
- 同一路径再次打开时 key 已变化,Vue 创建新实例。
:max="20"限制缓存数量,旧版本按 LRU 规则淘汰。
这里需要区分“缓存失效”和“立即销毁”。版本号方案保证旧实例不会在下次打开时复用,但旧条目可能继续留在 KeepAlive 缓存中,直到超过 max 后被淘汰。若业务要求关闭瞬间触发 onUnmounted 或立即释放重资源,应给路由组件设置稳定的组件名,并用官方 include/exclude 管理真实组件,而不是动态创建 wrapper。
问题二:非活跃组件发起重复请求
页面内的数据库和文件系统组件都通过 query 参数驱动编辑状态:
const action = computed(() => route.query.action)
const editingId = computed(() => route.query.id)
watch(
() => [action.value, editingId.value],
() => syncEditingRow(),
{ immediate: true },
)
function openEdit(row) {
router.push({ query: { action: 'edit', id: row.id } })
}
用户先访问文件系统 Tab,再切到数据库 Tab 并编辑 ID 为 99 的记录。网络面板出现两个请求:
| 请求 | 结果 | 来源 |
|---|---|---|
GET /data-source/database/99 | 304 | 当前激活的数据库组件 |
GET /data-source/file-system/99 | 404 | 已停用但仍被缓存的文件系统组件 |
请求是怎样重复的
数据库 Tab 点击“编辑”
└─ router.push({ query: { action: 'edit', id: 99 } })
└─ 当前路由的 query 更新
├─ 数据库组件的 watch 触发
│ └─ 请求 database/99
└─ 文件系统组件仍在 KeepAlive 缓存中
└─ 它的 watch 也触发,请求 file-system/99
useRoute() 提供的是当前路由的响应式状态,不是某个 Tab 独享的局部状态。与此同时,<keep-alive> 停用组件时只移走其 DOM,并不会停止组件的响应式依赖、computed 或 watch。因此,两个仍然存活的组件会同时观察到 query 变化。
用激活状态约束副作用
通过 onActivated 和 onDeactivated 记录组件是否处于激活状态,并在派生状态的入口处拦截非活跃组件:
import {
computed,
onActivated,
onDeactivated,
ref,
} from 'vue'
const isActive = ref(true)
onActivated(() => {
isActive.value = true
})
onDeactivated(() => {
isActive.value = false
})
const action = computed(() =>
isActive.value ? route.query.action : undefined,
)
const editingId = computed(() =>
isActive.value && route.query.id != null
? String(route.query.id)
: null,
)
watch(
() => [action.value, editingId.value],
([nextAction, nextId]) => {
if (nextAction !== 'edit' || !nextId) return
syncEditingRow(nextId)
},
{ immediate: true },
)
组件停用后,action 和 editingId 先归为空值。后续 query 变化不再满足请求条件。再次激活时,派生状态会读取最新 query,是否恢复编辑态由当前 URL 决定。
也可以在 watch 回调或请求函数中判断 isActive。从派生状态入口拦截的好处是,视图模式、详情请求等所有下游逻辑使用同一套作用域规则,不容易遗漏。
验证清单
| 场景 | 预期结果 |
|---|---|
| 打开 A,再打开 B,然后切回 A | A 的筛选和分页保留 |
| 在数据库与文件系统 Tab 间切换 | 两边状态分别保留 |
| 在数据库 Tab 编辑一条记录 | 只请求数据库详情接口 |
| 关闭 A 后重新打开 A | 产生新 key,挂载新实例 |
| 多次关闭并重开同一页签 | 版本号持续递增,不复用旧状态 |
| 打开页签超过缓存上限 | 最久未使用的缓存被淘汰 |
| 查看控制台 | 不再出现 deactivate is not a function |
结论
多页签缓存需要分别处理两个维度:
- 缓存身份由稳定的业务 key 决定,版本号负责让旧身份失效。
- 副作用作用域由激活状态决定,非活跃组件不应继续请求接口或响应全局路由状态。
<keep-alive> 保存的是组件实例及其响应式系统,不只是页面 DOM。缓存解决状态恢复问题,但请求、订阅和定时器是否应该继续运行,仍需要业务代码明确管理。