谁动了我的 URL?——记一次微前端"灵异 Bug"的排查实录

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

一、先说现象,有多诡异

想象一下这个场景。

你在一个微前端架构的中台系统里,左边一排菜单,十来个子应用随点随切,平时点哪个都好好的。

但只要你先点过一次"子应用 A",再点"子应用 B"------好家伙,直接把你踢回登录页。重新登录?。地址栏里的 URL 也变得面目全非,莫名其妙多了一段不属于当前应用的 hash 路径。

更骚的是这几个特征:

  • 慢慢点、一个一个点?没事。

  • 专门快速切这两个?必现

  • 换台电脑?时好时坏。

  • 同事的电脑必现,你的电脑偶尔出现------最气人的就是这种"看脸"的 bug。

还有个玄学细节:用无痕浏览器也一样挂。说明跟缓存、Cookie 都没关系。

一个 bug 有了"不确定性",在团队里就会获得"灵异事件"的称号。

而我想说的是:凡是时好时坏的 bug,九成九是竞态。别烧香,去抓时序。

二、排查:先当一回"URL 侦探"

排查这种问题的第一反应不该是翻代码,而是先让证据开口说话

URL 是谁改的?浏览器提供了一对现成的钩子:history.pushStatehistory.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()  // 👈 竞态窗口:这里要请求后端,耗时数秒
  // ...
})

把整个时间线摊开,案发过程是这样的:

  1. 点击"子应用 A",qiankun 加载子应用,Vue Router 发起初始导航 ,目标 #/home;
  2. 导航被权限守卫拦住,await loadPermissions() 发出请求,导航挂起;
  3. 就在等接口返回的这几秒里,用户点了"子应用 B"------基座切换 URL,qiankun 开始卸载子应用;
  4. 权限接口这时候才慢悠悠回来,守卫放行;
  5. 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 秒极速切换都没打穿它,实际风险已经可以忽略。

七、复盘:这次学到了什么

  1. 凡是"时好时坏"的 bug,先怀疑竞态,别怀疑人生。 复现率跟机器性能、手速相关的,基本可以实锤是时序问题。

  2. 监控先行,十行代码让灵异现形。 pushState/replaceState 打个补丁,所有 URL 操作尽收眼底。排查"谁改了我的全局状态"类问题,这个思路通用。

  3. 异步守卫 + 可卸载宿主 = 天然竞态。 任何 await 之后,都要问一句:这个世界还是我离开前的那个世界吗?

  4. 销毁类 API,先确认可逆性。 destroy() / dispose() / teardown() 这些名字听着干净利落,但"能不能复活"决定了它能不能用在会反复挂载的场景里。

  5. 修复必须配套回归测试。 每次改动,问一句"谁会被这个改动伤害",把第一版方案的翻车扼杀在上线前。

彩蛋:一个可以带走的"代际令牌"模式

以后再遇到"异步任务晚到,宿主已换人"的场景,送你一个比布尔标志更严谨的模式------代际令牌:

ts 复制代码
let epoch = 0

router.beforeEach(async (to) => {
  const myEpoch = epoch               // 进门先领号
  const menus = await loadPermissions()
  if (myEpoch !== epoch) return false // 号过期了,改朝换代了:期间发生过卸载,放弃执行
  // ... 正常逻辑
})

// unmount 里:epoch++

它比布尔标志强的地方:卸载又重挂之后,布尔的"闸门"会重新打开,上一代遗留的挂起任务照样能溜进来;而代际令牌下,旧时代的任务拿着过期的号码,永远进不了新时代的门

好了,这次的"灵异事件"就讲到这里。你有没有遇到过类似的"看脸 bug"?评论区聊聊,让我看看谁的最玄学。


如果这篇文章帮你少掉了一根头发,点个赞再走呗。

相关推荐
JavaGuide43 分钟前
轻量开源版 IDEA 来了!
前端·后端
张洪权1 小时前
nest.js websocket 群聊----私聊功能
前端·nestjs
流光D1 小时前
AI Era: Building Web Sites and Configuring Nginx Reverse Proxy Process
前端·人工智能·nginx
sakidd1 小时前
VS Code 的 AI Chat 现在已经这么能干了?
前端
计算机魔术师1 小时前
英伟达砸130亿美元买下一个平台,黄仁勋到底在怕什么?
前端
leoZ2311 小时前
第 2 篇:搭建地基——Vue3 + Vite + Tailwind v4 + shadcn-vue
前端·javascript·vue.js·人工智能·目标检测·数据挖掘·语音识别
用户5619035069331 小时前
多环境 API 怎么管理?dev/test/prod 一套规范
ai编程
coderCN2 小时前
Nodejs path OS process child_process FS crypto zlib ffmpeg Markdown 转 html
前端
lifallen2 小时前
长任务怎样选择遗忘:clearing、compaction 与 memory
人工智能·学习·ai·ai编程