站内搜索

输入关键词开始搜索

Vue 3 多页签 keep-alive:缓存失效与重复请求排查

文章目录

后台管理系统常用“左侧菜单 + 顶部多页签”的布局。页签切换时需要保留筛选条件、分页和滚动位置,关闭后再次打开又要得到全新页面。页面内部如果还有一层 Tab 缓存,问题会更复杂。

本文记录两个相互关联的问题:

  1. 嵌套 <keep-alive> 配合动态 wrapper 和 include 时,关闭页签触发 deactivate is not a function。
  2. 被缓存但未激活的子组件继续监听 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

触发条件

这次问题出现在以下组合中:

  1. 路由组件内部还有一层 <keep-alive>。
  2. 外层 <keep-alive> 与实际路由组件之间插入了动态 wrapper。
  3. 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/99304当前激活的数据库组件
GET /data-source/file-system/99404已停用但仍被缓存的文件系统组件

请求是怎样重复的

数据库 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,然后切回 AA 的筛选和分页保留
在数据库与文件系统 Tab 间切换两边状态分别保留
在数据库 Tab 编辑一条记录只请求数据库详情接口
关闭 A 后重新打开 A产生新 key,挂载新实例
多次关闭并重开同一页签版本号持续递增,不复用旧状态
打开页签超过缓存上限最久未使用的缓存被淘汰
查看控制台不再出现 deactivate is not a function

结论

多页签缓存需要分别处理两个维度:

  • 缓存身份由稳定的业务 key 决定,版本号负责让旧身份失效。
  • 副作用作用域由激活状态决定,非活跃组件不应继续请求接口或响应全局路由状态。

<keep-alive> 保存的是组件实例及其响应式系统,不只是页面 DOM。缓存解决状态恢复问题,但请求、订阅和定时器是否应该继续运行,仍需要业务代码明确管理。

相关文章

Vue 3 按钮埋点实现:v-track 指令与统一上报

从点击事件进入自定义指令开始,讲清 Vue 3 按钮埋点如何更新绑定参数、收集页面上下文并隔离上报异常。

前端渲染大型表格方法总结

前端渲染大型表格方法总结