本文适合:搞微前端(qiankun)的同学、被"时好时坏"的 bug 折磨过的同学、以及所有在深夜对着屏幕说过"这不科学"的同学。

一、先说现象,有多诡异
想象一下这个场景。
你在一个微前端架构的中台系统里,左边一排菜单,十来个子应用随点随切,平时点哪个都好好的。
但只要你先点过一次"子应用 A",再点"子应用 B"------好家伙,直接把你踢回登录页。重新登录?。地址栏里的 URL 也变得面目全非,莫名其妙多了一段不属于当前应用的 hash 路径。
更骚的是这几个特征:
-
慢慢点、一个一个点?没事。
-
专门快速切这两个?必现。
-
换台电脑?时好时坏。
-
同事的电脑必现,你的电脑偶尔出现------最气人的就是这种"看脸"的 bug。
还有个玄学细节:用无痕浏览器也一样挂。说明跟缓存、Cookie 都没关系。
一个 bug 有了"不确定性",在团队里就会获得"灵异事件"的称号。
而我想说的是:凡是时好时坏的 bug,九成九是竞态。别烧香,去抓时序。
二、排查:先当一回"URL 侦探"
排查这种问题的第一反应不该是翻代码,而是先让证据开口说话。
URL 是谁改的?浏览器提供了一对现成的钩子:history.pushState 和 history.replaceState。给它俩打个补丁,所有的地址变化就全都记录在案了:
js
// 控制台一贴,十行代码,让灵异 bug 现出原形
window.__urlChanges = []
const origPush = history.pushState
const origReplace = history.replaceState
history.pushState = function () {
window.__urlChanges.push({ type: 'push', url: String(arguments[2]) })
return origPush.apply(this, arguments)
}
history.replaceState = function () {
window.__urlChanges.push({ type: 'replace', url: String(arguments[2]) })
return origReplace.apply(this, arguments)
}
// 顺手把 hashchange、popstate 也监听上

然后复现一遍"快速切换",翻出记录一看,真相大白了:
css
[push] /micro/sub-app-a/#/home ← 进入子应用 A,正常
[replace] /home ← ⚠️ 就是你!
[push] /iframe/sub-app-b/#/ ← 基座导航到子应用 B,正常
注意第二条:一个 replaceState,把完整路径 /micro/sub-app-a/#/home 直接写成了裸路径 /home。
整个 URL 像是被砍头了一样。 基座路由一看这地址不认识,判定"非法路由",顺手把你踢回登录页------这就是"重新登录"的由来。
问题从"灵异"变成了"具体":谁在这个时机调了 replaceState?
三、真凶:一次教科书级的"竞态"
顺着"谁会写 #/home"去翻子应用 A 的代码,很快锁定了路由守卫:
ts
router.beforeEach(async (to) => {
// ...
const menus = await loadPermissions() // 👈 竞态窗口:这里要请求后端,耗时数秒
// ...
})
把整个时间线摊开,案发过程是这样的:
- 点击"子应用 A",qiankun 加载子应用,Vue Router 发起初始导航 ,目标
#/home; - 导航被权限守卫拦住,
await loadPermissions()发出请求,导航挂起; - 就在等接口返回的这几秒里,用户点了"子应用 B"------基座切换 URL,qiankun 开始卸载子应用;
- 权限接口这时候才慢悠悠回来,守卫放行;
- Vue Router:好嘞,导航继续!走完最后一步
finalizeNavigation,执行replaceState------把已经不属于自己的主窗口 URL 写坏了。

一句话总结根因:
异步守卫挂起期间,宿主(基座)已经把子应用切走了;守卫晚到返回后,不知道"天下已变"的它继续执行导航,污染了全局 URL。
为什么同事的电脑必现、我的偶尔出现?因为机器越慢,接口等待窗口越长,用户手速"跑赢"接口的概率越高。竞态 bug 的复现率 = 你的手速 ÷ 机器的性能,这就解释了所有的"看脸"。
四、第一版修复:看着挺美,其实埋雷
知道根因后,第一版方案几乎是条件反射:卸载的时候,把 router 的 history 销毁掉,监听器都没了,看你还怎么响应?
ts
async function unmount(props) {
const history = router.options.history
history.destroy() // 把监听器都干掉
await originalUnmount(props)
}
部署上去一测,URL 污染没了。当时内心:完美,收工。
结果,一周后被打脸了。
复测发现一个新的灵异现象:多切几轮之后,子应用里的浏览器**"后退"按钮失灵了**------点了没反应,路由纹丝不动。老的 bug 刚走,新的 bug 又来了。
去翻 vue-router 4.5 的源码,真相大白:
js
// createWebHashHistory 里,window 上的 popstate 监听只注册这一次
window.addEventListener('popstate', popStateHandler)
// destroy() 直接 removeEventListener,且 install() 不会重新注册
而我们的 router 是模块级单例 ,qiankun 子应用会经历多次 mount → unmount → mount。第一次 destroy() 之后,window 监听器被永久移除,第二次挂载时 install() 并不会把它加回来。

一句话:destroy() 是个不可逆操作,炸完不留重建通道。
教训一:调用任何"销毁类"API 之前,先去源码确认两件事------它清理的范围有多大,以及有没有恢复通道。
教训二:修复必须做回归测试。第一版方案"看起来好了",但悄悄伤害了前进/后退。每修一个 bug,都要问一句:这个改动会伤害谁?
五、最终方案:三道"可逆闸门"
思路彻底转变:不销毁路由,改拦截导航。而且每一道闸都必须可逆------卸载时落下,挂载时抬回。
ts
let appActive = true
// 闸门①:导航的"最后一道安检"
router.beforeResolve(() => appActive)
async function mount(props) {
appActive = true
router.listening = true // 闸门②:重新恢复听力
try {
await originalMount(props)
} catch (e) {
appActive = false // 挂载失败要回滚,不能停在"假活着"状态
router.listening = false
throw e
}
}
async function unmount(props) {
appActive = false
router.listening = false // 同步关闸,不等 app.unmount() 慢慢执行
await originalUnmount(props)
}

逐个说下这三道闸的分工,它们各自堵住不同的时序窗口:
闸门①:beforeResolve + 激活标志 ------ 拦"在途导航"
vue-router 的导航顺序是:守卫链 → beforeResolve → 写 URL(finalizeNavigation)。
beforeResolve 是"所有守卫都过了、马上要动地址栏"之前的最后检查点。哪怕权限接口晚到十分钟、守卫全部放行,只要应用已经卸载(appActive === false),这里返回 false,整个导航被取消,URL 一个字节都不会写。
这正好掐灭了我们的污染链路:接口晚到 → 守卫放行 → 撞上闸门 → 取消,完事。
闸门②:router.listening = false ------ 拦"被动导航"
看 vue-router 源码,popstate 事件处理函数的第一行是:
js
if (!router.listening) return
在 unmount 里同步 把这个开关关掉,router 立刻"失聪"------基座改 URL 触发的 popstate 再也叫不醒它。这一步的意义在于不等 app.unmount() 慢慢执行完(慢机器上,从开始卸载到真正 unmount 完成之间有致命的间隙)。
闸门③:框架自带的清理 ------ 白嫖 vue-router 的内建机制
这是最省心的一道。vue-router 的 install() 会包装 app.unmount():最后一个 app 卸载时,自动移除 history 监听、重置内部状态;下次挂载时自动恢复。
这套机制本来就是可逆 的,前提只有一个:你老老实实让 app.unmount() 被调用,别去手动 destroy 它。我们的手动闸门只是把清理"提前量"补上了,框架该做的事还是让框架做。
六、验证:连开八轮"极限压力测试"
方案再漂亮,也得数据说话。注入监控后实测:
css
[replace] /micro/sub-app-a/#/home ← 写的是当前完整 URL,无害
[push] /iframe/sub-app-b/#/ ← 正常导航
| 测试项 | 结果 |
|---|---|
| 1 秒间隔快速切换 4 轮 | ✅ 零污染 |
| 0.5 秒极限切换 3 轮 | ✅ 零污染 |
| 5 轮挂载/卸载后测浏览器后退 | ✅ 路由正常响应(无 destroy 回归) |
| 5 轮挂载/卸载后测 hash 变化 | ✅ 视图实时更新 |
特别是第三条------第一版方案就是死在这里,这次专门盯着验。
诚实说一句残留风险:理论上"基座写入新 URL 之后、unmount 执行之前"还有一个微任务级的窗口,但 0.5 秒极速切换都没打穿它,实际风险已经可以忽略。
七、复盘:这次学到了什么
-
凡是"时好时坏"的 bug,先怀疑竞态,别怀疑人生。 复现率跟机器性能、手速相关的,基本可以实锤是时序问题。
-
监控先行,十行代码让灵异现形。
pushState/replaceState打个补丁,所有 URL 操作尽收眼底。排查"谁改了我的全局状态"类问题,这个思路通用。 -
异步守卫 + 可卸载宿主 = 天然竞态。 任何
await之后,都要问一句:这个世界还是我离开前的那个世界吗? -
销毁类 API,先确认可逆性。
destroy() / dispose() / teardown()这些名字听着干净利落,但"能不能复活"决定了它能不能用在会反复挂载的场景里。 -
修复必须配套回归测试。 每次改动,问一句"谁会被这个改动伤害",把第一版方案的翻车扼杀在上线前。
彩蛋:一个可以带走的"代际令牌"模式
以后再遇到"异步任务晚到,宿主已换人"的场景,送你一个比布尔标志更严谨的模式------代际令牌:
ts
let epoch = 0
router.beforeEach(async (to) => {
const myEpoch = epoch // 进门先领号
const menus = await loadPermissions()
if (myEpoch !== epoch) return false // 号过期了,改朝换代了:期间发生过卸载,放弃执行
// ... 正常逻辑
})
// unmount 里:epoch++
它比布尔标志强的地方:卸载又重挂之后,布尔的"闸门"会重新打开,上一代遗留的挂起任务照样能溜进来;而代际令牌下,旧时代的任务拿着过期的号码,永远进不了新时代的门。
好了,这次的"灵异事件"就讲到这里。你有没有遇到过类似的"看脸 bug"?评论区聊聊,让我看看谁的最玄学。
如果这篇文章帮你少掉了一根头发,点个赞再走呗。