wujie 微前端踩坑实录:一个子应用的 7 个生产事故现场

wujie 微前端踩坑实录:一个子应用的 7 个生产事故现场

token 有三种来源,window 有两个归属,同一段代码要跑三种形态------以及它带来的 7 个坑。

背景:一个「三栖」子应用

我们维护的一个企业级管理端系统,技术栈 Vue 3 + Vite + TypeScript。它不是一个普通 SPA,而是同时以三种形态运行的 wujie 子应用:

运行形态 谁在跑 window.$wujie token 从哪来 跨应用通信
① wujie 嵌入主系统 宿主 9444 页面里的沙箱 有 wujie props 注入 wujie bus + postMessage
② 独立访问 自己就是顶层页面 undefined sessionStorage(SSO 流程写入) 无
③ 独立 iframe 嵌入 宿主用普通 iframe 直挂某个路由 undefined URL query 携带 postMessage(与宿主)

形态③是工作流模块的特殊需求:宿主要把「流程表单页」「报告页」「桩关联查看页」当组件一样嵌进自己的页面,不经过 wujie。

所有复杂度都来自这个矩阵------同一段代码,鉴权有三条路径,通信有三套通道,「窗口」有两个归属。

还有一个让排错事倍功半的事实:本地开发时子应用跑 5174、宿主跑 9444,跨源;部署后同源。 这意味着:

  • 一半的坑只在开发环境出现(跨源拦截类),生产上「碰巧没事」;
  • 另一半的坑只在生产出现(HTTP 环境无 Service Worker),开发环境永远复现不了。

在 dev 复现不了 ≠ 没有 bug;在 dev 复现了 ≠ 生产会有。 每个跨应用的修复,都要在「三种形态 × 两种环境」里各验一遍。

下面是 7 个真实事故。每个都有:现象 → 根因 → 修复代码 → 教训。


事故 1:每次点击都刷「event validation failed」

现象

接入 wujie 后,本地联调时控制台被同一行警告淹没------用户每点一次任何按钮,就刷一条:

复制代码
[Vue warn]: Invalid event arguments: event validation failed for event "click".

功能完全正常,但告警把真正有价值的日志全埋了。

根因:wujie 的沙箱模型,和跨 realm 的 instanceof

wujie 的沙箱结构 = iframe(JS 运行环境)+ 容器(DOM 渲染环境):子应用的 JavaScript 跑在宿主页面里一个隐藏的 iframe 中,渲染出来的 DOM 却挂在宿主文档里。

这意味着两个 realm 各有一套独立的原生构造器。用户真实点击产生的 MouseEvent 对象,是在主应用的 realm 里创建的;而子应用里 element-plus 的 el-button 对 click 事件做了 emits 校验(简化后):

ts 复制代码
emits: {
  click: (evt) => evt instanceof MouseEvent
}

校验器里的 MouseEvent 来自子应用 iframe 的 realm 。跨 realm 的 instanceof 恒为 false------于是每次真实点击,校验都"失败"一次。

修复:定向静音

先确认破坏面:校验失败只报警,事件照常传给 handler,功能无损,纯 dev 噪音。所以我们没有魔改 element-plus,而是在 app.config.warnHandler 里精确静音这一条、放行其余全部:

ts 复制代码
// src/main.ts
app.config.warnHandler = (msg, _instance, trace) => {
  // 仅静音跨 realm instanceof 误报;其余警告照常输出,不掩盖真问题
  if (msg.includes('event validation failed')) return
  console.warn(`[Vue warn]: ${msg}${trace ? `\n${trace}` : ''}`)
}

教训

微前端沙箱里,所有依赖「同 realm 原生构造器」的类型判断都不可信。 不止 element-plus------任何第三方库内部做了 instanceof Event / instanceof HTMLElement 之类的检查,在 wujie 下都可能翻车。排查思路是把报错的库代码翻译成一个-question:「它在这里拿到的构造器,和产生这个对象的构造器,是同一个 realm 的吗?」


事故 2:postMessage 石沉大海------targetOrigin 填了「自己」

现象

token 过期时,子应用要在 401 拦截器里通知主系统重新登录,向 window.top 广播 toQuit。本地联调时主系统永远收不到,控制台只有一行不起眼的报错:

复制代码
Failed to execute 'postMessage' on 'DOMWindow':
The target origin provided ('http://localhost:5174') does not match
the recipient window's origin ('http://localhost:9444').

根因:targetOrigin 校验的是「接收方」

postMessage 的第二个参数 targetOrigin,校验的是接收窗口 的 origin,不是发送方的。子应用想当然地传 window.location.origin(自己的 5174),而接收方 window.top 是宿主(9444)→ 不匹配 → 浏览器拦截。

有人会说:那就传 '*' 呗。能通,但等于把 postMessage 唯一的内置安全设计丢掉了------任何嵌入你的页面都能收到这条含鉴权语义的消息。

修复:按场景推导 origin

ts 复制代码
// src/utils/common-utils.ts
export function getTopWindowOrigin(): string {
  if (window.top !== window.self && document.referrer) {
    try {
      // iframe 嵌入时:按宿主页地址(document.referrer)推导顶层窗口 origin
      return new URL(document.referrer).origin
    } catch {
      // referrer 非法时兜底走自身 origin
    }
  }
  return window.location.origin // 独立打开时没有宿主,回退自身
}

两个使用场景,语义完全不同,不要混用:

ts 复制代码
// 场景一:主动发往顶层宿主 ------ 用「推导出的宿主 origin」
window.top?.postMessage?.({ action: 'toQuit', errMsg: '登录已过期' }, getTopWindowOrigin())

// 场景二:回复消息 ------ 用「发送方自报的 origin」
window.addEventListener('message', (event) => {
  // ...处理 event.data
  event.source?.postMessage({ action: 'outSubmitFunBack', result }, event.origin)
})

教训

postMessage 有两套 origin 语义:主动发往 top,用从 document.referrer 推导的宿主 origin;回复消息,用 event.origin(对方自报家门,最可信)。 把它固化成一个工具函数,而不是散在各处手写------我们统计过,全项目向 top 发消息的站点有十来个,散写必然有人写错。


事故 3:幽灵 401------URL 携 token 的页面漏了登记

现象

主系统某处「查看」按钮打开的桩变更单关联视图,整页 401。没有堆栈指向根因,接口本身、token、权限全查了都没问题,而且不是每次必现------只有从那个入口进才挂。

根因:一条没登记的隐式约定

形态③的那批页面 token 只能从 URL query 传,main.ts 按 path 关键字识别并读取:

ts 复制代码
// src/main.ts(节选)
} else if (
  window.location.pathname.indexOf('flow-frame-page') >= 0 ||
  window.location.pathname.indexOf('report-frame-page') >= 0 ||
  window.location.pathname.indexOf('marker-relate-info') >= 0 ||
  window.location.pathname.indexOf('pipeline-related-page') >= 0
) {
  const token = new URLSearchParams(window.location.search).get('token') || ''
  window.sessionStorage.setItem('token', token)
  syncSwToken(token) // 同步给 Service Worker,见事故 4
}

新页面没在这里登记 → sessionStorage 里没有 token。而请求拦截器里有一个历史遗留的静默兜底:

ts 复制代码
// src/utils/request.ts ------ 问题代码
const token = `Bearer ${tokenString || '<硬编码的历史 token>'}`

没 token 就拿一个硬编码的旧 token 顶上 → 请求不会报「缺 token」,而是带着过期凭证打到后端 → 整页 401,根因被包裹了一层。

修复

  1. 登记新页面的 path 关键字;
  2. 在分支注释里写明「未登记的后果」(我们把当时的 401 根因直接写进了注释,后来的人才知道这条为什么存在);
  3. 把硬编码兜底 token 单独立项清理。

教训

「隐式约定」必须有登记清单,且清单要写明违反后果。 更通用的一条:静默兜底把「配置缺失」变成了「更深的运行时故障」------它掩盖失败原因的代价,远大于显式报错省下的那点事。宁可启动时 console.warn 一行「token 缺失,来自 URL 嵌入但未登记」,也不要让它带病运行。


事故 4:一注册就炸的 Service Worker

现象

为了解决 <img> / <iframe> / CSS 背景这类「JS 注不进请求头的原生请求」过不了鉴权的问题,我们把鉴权逻辑放进了 Service Worker(网络层拦截文件请求、注入 Authorization------这套机制本身够再写一篇)。注册脚本写进 index.html 后,一嵌入主系统就抛:

复制代码
SecurityError: Failed to register a ServiceWorker:
The origin of the provided scriptURL ('http://localhost:9444')
does not match the current origin ('http://localhost:5174').

根因:SW 是 origin 级资产

wujie 下子应用以 iframe/srcdoc 嵌在宿主页面里。在 iframe 里调 navigator.serviceWorker.register('/sub-app/sw.js'),相对路径的 origin 被解析为顶层文档(宿主)的 origin,而 SW 注册要求脚本与当前文档同源 → 必炸。

修复:只在顶层窗口注册

html 复制代码
<!-- index.html 内联脚本 -->
<script>
  (function () {
    // 嵌入在 iframe 中时 origin 必然不匹配,跳过,避免 SecurityError 噪音
    if (window.top !== window.self) return;
    if (!("serviceWorker" in navigator)) return;
    window.addEventListener("load", function () {
      navigator.serviceWorker.register("/sub-app/sw.js");
    });
  })();
</script>

关键认知:SW 一旦在顶层注册成功,就作用于整个 origin------同源部署后,子 iframe 里的原生请求照样被它拦截。子应用不需要(也不能)自己注册。

顺带一提它的另一半:SW 环境里没有 localStorage(同步存储不属于 SW 作用域),token 用 IndexedDB 做主线程 ↔ SW 的中转;<img> 默认是 no-cors 请求,浏览器会剥离自定义请求头,SW 拦截后要用 mode: 'cors' 重新发起,Authorization 头才带得上去。

教训

微前端下遇到所有「按 origin 划界」的浏览器能力(Service Worker、Cookie、Storage、postMessage),先问一句:这个能力属于哪个 origin?谁有资格注册/读写? 答案往往和「谁在写这段代码」不是同一个应用。


事故 5:点一次触发 N 次------bus 上的幽灵监听

现象

待办任务弹窗里的列表接口被调用了两遍;同类「事件触发一次、回调执行多次」的问题在多个页面间歇出现。

根因:bus 是全局单例,比组件活得久

wujie 的 bus 生命周期与应用级实例绑定,组件销毁后,挂在 bus 上的监听依然存活 。只在 onMounted 里 $on 而从不 $off,每次重新进入组件就叠加一层监听------事件来一次,回调跑 N 次。

修复:注册/注销配对,且尽早订阅

ts 复制代码
// setup 顶层:尽早订阅------主系统可能在子应用 ready 之前就发事件,晚订阅会漏
window.$wujie?.bus.$on('goPage', goPage)
window.$wujie?.bus.$on('open-task', todoTask.open)

onUnmounted(() => {
  // 注销,避免重复绑定与内存泄漏
  window.$wujie?.bus.$off('goPage', goPage)
  window.$wujie?.bus.$off('open-task', todoTask.open)
})

一个容易踩的细节:$off 必须传同一个函数引用 ,所以 handler 要提出去用具名函数,写在 $on 位置的匿名箭头函数永远 off 不掉。

教训

把「bus 事件 $on/$off 配对」写进团队的固定检查项(code review 清单 / AI 编码规范)。我们把它固化进错误案例库之后,这类问题就绝迹了------高频犯的错,靠清单而不是靠记性。


事故 6:脱离宿主就白屏------$wujie 空指针

现象

接入 bus / props 的页面,单独打开(形态②)直接白屏:

复制代码
Cannot read properties of undefined (reading 'bus')

根因

wujie 只在嵌入态注入 window.$wujie。但开发者的肌肉记忆是「$wujie 一定在」------毕竟日常联调永远在形态①里。独立访问时它是 undefined,第一个 .bus 就炸。

修复:全线可选链,宿主缺席也完整

ts 复制代码
window.$wujie?.bus?.$on('goPage', goPage)
const token = window.$wujie?.props?.outLinkToken

独立访问时订阅静默跳过------没有宿主,本来也没有事件可听,这正是期望行为;token 走 sessionStorage / URL 分支。三种形态共用同一段代码,靠的是一条设计原则:每个跨应用 API 都允许缺席。

教训

微前端子应用的第一设计原则:宿主缺席时,应用必须仍然完整。 token、事件、导航等所有跨应用能力,都设计成「可选注入 + 本地兜底」,而不是「必然存在」。这也是为什么错误案例库里的典型反例就是裸写 window.$wujie.bus。


事故 7:窗口变了你却不知道------resize 在子应用里失灵

现象

6 个数据大屏按 1920×1080 设计稿制作,用 transform: scale 等比拉伸适配。嵌入主系统后,拉伸浏览器窗口,大屏纹丝不动;单独打开时一切正常。

根因:「窗口」有两个归属

大屏跑在子应用的 iframe 里,宿主窗口 resize 时,子应用的 window 不一定收到 resize 事件。而计算缩放比例需要的,恰恰是宿主视口的尺寸。

修复:宿主广播,子应用重算

主系统在窗口 resize 时通过 wujie bus 广播 resize-message,6 个大屏页订阅并重算:

ts 复制代码
// 子应用大屏页(节选简化)
window.$wujie?.bus.$on('resize-message', handleResize)
onUnmounted(() => window.$wujie?.bus.$off('resize-message', handleResize))

function scaleDivToFit(el: HTMLElement | null, tw = 1920, th = 1080) {
  if (!el) return
  // 注意:取的是宿主(parent)的视口尺寸,不是子应用自己的 window
  const wr = window?.parent?.innerWidth / tw
  const hr = window?.parent?.innerHeight / th
  el.style.transform = `scale(${wr},${hr})`
  el.style.transformOrigin = 'top left'
  el.style.width = `${tw}px`
  el.style.height = `${th}px`
  el.style.position = 'absolute'
}

教训

微前端里「窗口」「视口」「滚动条」都有两个归属。凡涉及宿主 UI 状态的同步,别自己监听,让宿主广播、子应用订阅。 我们的 wujie 事件表里专门有一类「主→子」的下行广播事件(resize-message、goPage、open-task),就是为这类场景服务的。


写在最后:子应用军规 10 条

7 个事故浓缩成 10 条军规,贴在仓库的编码规范里:

  1. 宿主缺席时应用必须完整 :所有 window.$wujie 全程 ?.,类型声明为可选。
  2. bus 事件成对出现 :setup 顶层尽早 $on,onUnmounted $off;handler 用具名函数。
  3. postMessage 分清两个 origin :发往 top 用 getTopWindowOrigin()(referrer 推导),回复用 event.origin,禁止 '*'。
  4. 不信任跨 realm 的 instanceof:第三方库做类型校验报警时,先想沙箱再想 bug。
  5. 新增独立 iframe 嵌入页,必须登记 token 分支,注释写明未登记的后果。
  6. 按 origin 划界的能力先问「属于谁」:SW 只在顶层窗口注册,注册一次全 origin 生效。
  7. 不做静默兜底:token/配置缺失要显式失败,带病运行会把配置问题变成幽灵故障。
  8. 宿主 UI 状态由宿主广播:resize、滚动、关闭弹窗,子应用只订阅不探测。
  9. 先确认环境再下结论:dev 跨源、生产同源;HTTP 生产无 SW。修复要覆盖「三形态 × 两环境」。
  10. 跨应用交互必须登记事件表:方向、事件名、监听位置、用途,缺一项就不许上线。

最后一条其实是最重要的一条:微前端的大部分坑,根因都是「约定存在,但没人知道它存在」。把约定变成文档里的表格、代码里的注释、清单里的检查项,比任何个人的记性都可靠。


*本文基于一个企业级管理端(Vue 3 + Vite + TypeScript + wujie-vue3)的真实生产问题整理,代码均节选自线上仓库(已脱敏)。