先说结论,免得有人看到一半就骂我标题党:keep-alive 本身没 bug。有问题的是我,我把它当成"性能优化"来用了,而它其实是个"交互体验"层面的东西。这两个词听着差不多,落到内存上差得挺远。
去年做的一个后台系统,左侧菜单 40 多个页面,产品过来说"用户老抱怨切回来又要重新加载,你能不能让他切回来的时候还停在原来的位置"。我当时想都没想,App.vue 里包了一层 <keep-alive>,提测,收工。第一周确实爽,切 tab 秒开,投诉归零。第二周开始,运营那边有人反馈"用半个小时浏览器就开始转圈"。第三周,一个同事的 Chrome 直接 Aw Snap,还把崩溃截图发到了群里。
下面是正经的排查过程,如果你手里也有个越用越卡的后台,可以照着走一遍。
别猜,先量
Chrome DevTools 打开,切到 Memory 面板,点左上角那个垃圾桶图标强制 GC 一次,再拍 Heap snapshot。我拍了三张:刚进系统一张,切了 10 个 tab 一张,切到第 25 个 tab 再拍一张。
数字是这样的:
- 第 1 张:JS 堆 96MB,DOM 节点 12,847 个
- 第 2 张:JS 堆 341MB,DOM 节点 51,203 个
- 第 3 张:JS 堆 812MB,DOM 节点 118,660 个
然后在第 3 张快照的筛选框里输入 Detached,出来 7 万多个分离的 HTMLDivElement。点开 Concat string,往上翻引用链,一大半挂在一个叫 zr-dom 的容器下面——那是 ECharts 自己建的层。到这一步方向就清楚了,不是路由的问题,也不是哪个接口返回的数据太大,是图表实例从来没被销毁过。
顺带提一句,Performance 面板也能录,但录出来的火焰图信息密度太高,几百毫秒的 GC 淹没在成片的函数调用里,不如 Memory 快照直观。我当时先录了一段 Performance,看了十分钟没看出名堂,换回 Memory 五分钟就定位了。
keep-alive 到底缓存了什么
这里得把三种方案摆在一起说,因为很多人(包括当时的我)把它们混着理解。
v-show:DOM 一直在,只是把 display 改成 none。组件挂载、更新、事件监听全都正常跑,只是你看不见。适合那些切换频繁、内容轻的块,比如表单里的折叠区域。
v-if:彻底销毁再重建。onUnmounted 会触发,定时器、监听器、第三方实例该清就清。代价是每次切回来都要重新挂载、重新请求数据、重新初始化组件。
keep-alive:第三种。组件实例被从当前 vnode 树里摘出来,塞进一个 Map 存着,DOM 节点也留在内存里(deactivate 时会被移到一个隐藏容器)。关键在于——它既没销毁,也没隐藏,而是"冻起来存着"。
所以 onUnmounted 不会触发,因为压根没卸载。你在 onMounted 里干的任何事,都会一直活在内存里:setInterval、addEventListener、new WebSocket、echarts.init、new AMap.Map、ResizeObserver,一个都跑不掉。
我们那个项目里问题最严重的是 ECharts。几乎每个带图表的页面都有这么一段:
onMounted(() => {
chart = echarts.init(el.value)
chart.setOption(option)
window.addEventListener('resize', resizeHandler)
})
echarts.init 会在容器上挂实例和 canvas 层,resize 监听挂在 window 上。切了 25 个页面,就是 25 个 chart 实例加 25 个 resize handler 同时活着。每次窗口大小一变,25 个 handler 全跑一遍,每个都调 chart.resize()。所以用户感觉到的"越用越卡",其实不是内存不够,是主线程被拖死了——内存只是那个更早暴露出来的症状。
我改的四步
第一步,给 keep-alive 加 include 和 max,别裸用。
<keep-alive :include="cachedViews" :max="8">
<router-view :key="route.fullPath" />
</keep-alive>
include 是按组件的 name 匹配的。这里有个坑:<script setup> 里没有显式 name,要么用 defineOptions({ name: 'XXX' })(Vue 3.3+),要么额外加一个普通的 <script> 块写 export default { name: 'XXX' }。我们项目当时锁的 Vue 3.2,只能用后面那种写法。
max="8" 是 LRU,超过 8 个就淘汰最久没访问的那个。很多人觉得"我页面不多"就不写,但缓存的成本不是线性的——一个带地图或者富文本编辑器的页面,单独就能吃 100MB 以上。
第二步,所有资源清理挪到 onDeactivated。
onDeactivated(() => {
window.removeEventListener('resize', resizeHandler)
chart?.dispose()
clearInterval(timer)
})
onActivated(() => {
chart = echarts.init(el.value)
chart.setOption(option)
window.addEventListener('resize', resizeHandler)
})
注意 onActivated 在首次挂载后也会触发一次,顺序是 mounted -> activated。所以别在 onMounted 里也写一遍初始化,会 init 两次,第二次那个实例谁也拿不到引用,白占内存。我第一次改就踩了这个,图表叠了两层,鼠标悬浮 tooltip 闪得跟鬼片一样。
第三步,路由守卫里动态维护 include 列表。
不是所有页面都值得缓存。列表页、详情页这种带筛选条件的,缓存有价值;纯展示的报表页、只有一个大图的大屏页,缓存纯亏。
router.afterEach((to) => {
if (to.meta.keepAlive && !cachedViews.includes(to.name)) {
cachedViews.push(to.name)
}
})
第四步,把最重的几个页面从缓存里摘出来。
我们后来把 5 个带 ECharts 的页面改成"缓存查询参数 + 接口数据,组件重新挂载"。用户完全没感觉到区别,因为数据是秒出的,只有图表要重新渲染,而图表渲染本来也就 100 多毫秒。
改完之后又量了一遍:
- 切到第 25 个 tab:JS 堆从 812MB 降到 210MB,DOM 节点从 118,660 降到 21,400
- 窗口 resize 到重绘完成:从 340ms 降到 26ms(Performance 面板测的)
- 连续用一小时:从几乎必崩,变成稳定在 240MB 上下浮动
最后说点我自己的看法
现在网上讲 keep-alive 的文章,基本都在教你怎么写 include、怎么让 <script setup> 拿到 name。这些都对,但我觉得更该先问一句:这个页面真的需要被缓存吗?
我们的产品原话是"切回来别重新加载"。但用户真正在意的其实是"别让我重填筛选条件、别让我滚回顶部"。这是状态,不是 DOM。状态可以用 Pinia 存、可以塞进 URL query、可以写 sessionStorage,成本比冻住一整棵组件树低一个数量级。
所以我的观点是:keep-alive 该是个例外手段,不是默认手段。判断标准就一条——这个页面的重新挂载成本,是不是真的高到用户能感知出来?如果是,用;如果只是"想省一次接口请求",那你去缓存接口数据就行了,别动不动整个组件树都给它冻上。
还有一个细节,include 匹配的是组件的 name,不是路由的 name。这两个东西名字长得一样的时候特别容易混,我调了半天才发现路由叫 OrderList、组件叫 order-list,include 里写 'OrderList' 永远不生效,但也不报错,页面照常跑,就是不缓存。这种静默失败挺坑的,建议命名的时候统一一下大小写风格,省得以后自己咬自己。