上午 10 点,刚泡好的咖啡还没凉,项目群里突然炸锅:"兄弟,怎么点一下 Tab 切个页面,接口发了两次?用户都在吐槽加载慢得像蜗牛!"
我心里一惊,切回 Chrome 的 Network 面板,好家伙——那熟悉的 API 请求像双胞胎一样并排出现。盯着这两行一模一样的请求,我心里只有一句话:这锅,看来是背定了。
这种"看起来简单、实则藏了不少 Vue 底层细节"的 Bug 挺有意思。修完线上事故的同时,也是把组件生命周期、缓存机制这些八股文落地变现的机会。
下面把"排查到根治"的过程拆开说——不整虚的,只讲干货和避坑。
一、案发现场:一次点击,双倍快乐?
场景其实很常见:首页有两个 Tab,一个是"门票(Ticket)",一个是"生活(Life)"。原本的代码逻辑是用动态组件来切换的:
<!-- 父组件 index.vue -->
<KeepAlive>
<component :is="activeTab === 'ticket' ? Ticket : Life" />
</KeepAlive>只要一点 Tab,控制台里的接口请求就跟不要钱一样发了两次。在弱网环境下,Loading 转圈的时间直接翻倍,用户体验极差。
第一反应:是不是子组件里写了重复逻辑?
打开 Ticket.vue 和 Life.vue,一眼看到了"罪魁祸首":
onMounted(() => {
init() // 这里的 init() 里包含了 onLoad() 发请求
})破案了?并没有。
每个组件挂载时拉数据没毛病。但问题是:明明加了 KeepAlive,为啥切回来还会重新触发 onMounted?说好的缓存呢?
二、抽丝剥茧:KeepAlive + 动态组件的"致命组合"
为了验证猜想,我先把 KeepAlive 去掉,结果发现——还是两次。这说明问题不在缓存失效,而在组件的创建机制上。
稍微翻了一下 Vue 的源码(对,被逼急了是真的会看源码的),真相其实很简单:
当你在用 `<component :is="condition ? A : B" />` 时,Vue 面对的是两个完全不同类型的组件。
从 A 切到 B:Vue 认为是"拆迁队进场",把 A 铲平(Unmount),原地盖个 B(Mount)。
即便包了 KeepAlive,它缓存的是实例。但如果你在切换逻辑里让组件频繁销毁重建,或者触发了某些导致 key 变化的机制,KeepAlive 也救不了你。它就像个冰箱,你今天放披萨,明天放寿司,冰箱只能说:"哥们,你换菜了,我只能重新做。"
再对比一下 v-show:v-show 只是 CSS 层面的 display: none。组件一开始就全部挂载好了(onMounted 触发一次)。切换 Tab 只是"拉窗帘"和"开窗帘",根本不会触发生命周期钩子。
结论是:只有两个固定 Tab 频繁切换时,动态组件 + KeepAlive 反而把简单问题搞复杂了,v-if / v-show 才是正解。
三、根治方案:v-show + Key 的"移花接木"
既然找到了病灶,药方就得下得准。最终方案只有两招,却刀刀致命:
1. 父组件:用 v-show 控显示,用 Key 控刷新
别让组件"自作主张"去刷新,把控制权收回到父组件手里。
<!-- index.vue -->
<script setup>
const refreshKey = ref(0) // 这是一个核心的"遥控器"
const homeStore = useHomeStore()
// 监听业务数据的变化(比如切换了城市/目的地)
watch(() => homeStore.getDestinationitem()?.socialRegionId, (newId, oldId) => {
if (newId && oldId && newId !== oldId) {
// 只有目的地真的变了,才通知子组件:"嘿,你需要重置了"
refreshKey.value++
}
})
</script>
<template>
<!-- 这里的 Key 才是灵魂! -->
<!-- Key 没变时,v-show 切换只是改 CSS,0 开销 -->
<!-- Key 变了(换城市了),Vue 才会真正销毁重建组件,触发新请求 -->
<Ticket
v-show="activeTab === 'ticket'"
:key="`ticket-${refreshKey}`"
/>
<Life
v-show="activeTab === 'life'"
:key="`life-${refreshKey}`"
/>
</template>2. 子组件:彻底"躺平"
子组件要像个乖宝宝,不要自己搞事情:删除 onMounted 里的主动请求逻辑(或者只留第一次初始化的兜底)。移除那些花里胡哨的 document.addEventListener('visibilitychange')。这玩意是监听浏览器 Tab 切后台的,拿来监听组件 Tab 切换简直是高射炮打蚊子——打不准还费劲。
效果立竿见影:改完上线后,Tab 切换变成了纯 CSS 操作,丝般顺滑。只有在真正切换"城市/目的地"时,才会触发数据刷新。接口调用量直接砍半,产品经理看完演示,反手就是一个点赞。
四、踩坑后的"碎碎念"
这次修 Bug,与其说是改代码,不如说是对 Vue 机制的一次再思考。几个血泪教训分享给大家:
KeepAlive 不是万能胶:它适合"列表页 -> 详情页"这种保留状态的场景。但在"Tab A <-> Tab B"这种同级高频切换场景,尤其是组件差异巨大时,盲目上 KeepAlive 容易把自己绕进去。
生命周期不是摆设,但也别乱用:onMounted 不等于"每次用户看见时执行"。真正需要"每次可见都刷新"的场景,请考虑 onActivated(配合 KeepAlive),或者像上面那样用 key 强行重置。
父子职责要分清:别让每个子组件都去监听全局 Store。父组件才是"指挥官",子组件只负责渲染。父组件通过 props 或 key 告诉子组件"该干活了",代码逻辑才不会乱成一锅粥。
写在最后
前端有意思的地方,往往藏在这些不起眼的 Bug 里。下次再遇到 Tab 切换、KeepAlive、动态组件这套组合拳,别慌。喝口咖啡,想想"冰箱"和"窗帘"的例子——读懂了框架的脾气,比死磕 API 管用。