在电商系统中,我们经常会遇到这样的需求:
- 商品列表页需要缓存筛选条件与滚动位置
- 从详情页返回时必须保持状态
- 但从其他模块进入列表页时,又希望重新请求数据
KeepAlive 本应完美解决这类问题。 (# Vue3 嵌套路由 KeepAlive:动态缓存与反向配置方案)
然而,在嵌套路由场景下,我却遇到了一个非常诡异的现象。
第一阶段:现象的对比与排查
最初我发现了一个极其矛盾的现象:
✅ 非 User 路由(如 Home)一切正常
这些页面同样配置了 keepAlive: true,
回退时状态完整保留,onMounted 不会重新触发。
❌ User 下子路由全部失效
例如:
- UserProfile
- UserFavorites
配置方式完全一致,缓存逻辑完全相同。
但每次从其他页面返回:
onMounted()
都会重新触发,页面状态完全丢失。
第一次关键洞察
既然:
- 配置一样
- include 逻辑一样
- 组件 name 正确
- key 也没有问题
那么问题一定不在“子页面组件本身”。
我开始注意到一个关键差异:
Home 结构:
WebsiteLayout
└── Home
User 结构:
WebsiteLayout
└── UserLayout
└── UserFavorites
Home 只有一层容器。
User 多了一层 UserLayout。
这时我意识到:
问题很可能出在“层级结构”上,而不是组件配置上。
第二阶段:名单的“不完整性”
接着我开始打印缓存名单:
console.log(cacheStack.value)
结果让我彻底清醒。
在进入 UserFavorites 时,我看到:
["UserFavorites"]
但:
UserLayout 并不在名单里
这意味着什么?
逻辑推导
顶级容器 WebsiteLayout 中有一个:
<keep-alive :include="cacheStackList">
<component :is="Component" />
</keep-alive>
当路由切换到 User 模块时:
- WebsiteLayout 会判断:User 是否在 include 名单中?
- 发现不在
- 于是直接销毁旧的 UserLayout 实例
- 再挂载一个新的 UserLayout
结果就是:
子页面虽然在缓存名单里,但它的“容器”被物理销毁了。
第三阶段:物理中断的确认
为了验证这个猜想,我给 UserLayout 加了生命周期日志:
onMounted(() => {
console.log('UserLayout mounted')
})
onUnmounted(() => {
console.log('UserLayout unmounted')
})
测试结果:
UserLayout unmounted
UserLayout mounted
瞬间破案。
问题根本不是子页面缓存失效。
而是:
父容器被销毁,导致内部缓存被一锅端。
覆巢之下,无完卵
这一刻我真正理解了 KeepAlive 的底层规律:
KeepAlive 缓存的不是“单个组件”。
它缓存的是:
当前这棵组件子树。
结构如下:
WebsiteLayout
└── UserLayout
└── UserFavorites
如果 UserLayout 被销毁:
- 内部 DOM 被销毁
- 内部状态被销毁
- 内层 keep-alive 实例被销毁
- 所有缓存组件全部物理抹除
这就是:
覆巢之下,无完卵。
最终抽象:父随子存原则
经过这次排查,我抽象出了一个核心设计原则:
子页面想存活,父容器必须先存活。
或者说:
缓存必须是“链路级”的,而不是“页面级”的。
在嵌套路由中:
- 只缓存最底层 Page 是不够的
- 需要确保整条 matched 链路都被纳入缓存名单
这就是后来我设计“反向配置”缓存方案的起点。
结语
很多开发者在嵌套路由下遇到 KeepAlive 失效时,会怀疑:
- name 是否匹配?
- include 是否错误?
- key 是否导致重新挂载?
- 异步组件是否影响缓存?
但真正的原因往往是:
父容器没有进入缓存链路。
理解这一点之后,缓存策略的设计思路会完全改变。
KeepAlive 不再是“给页面加个开关”。
而是一次对组件树生命周期的精确控制。