案例 wujie 子应用的 7 个生产事故:跨 realm 校验、origin 误用与 SW 注册边界

2026-11-05 21:31:47

wujie 子应用的 7 个生产事故:跨 realm 校验、origin 误用与 SW 注册边界

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

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

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

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

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

还有一个让排错事倍功半的事实:本地开发时子应用跑 5174、宿主跑 9444,跨源;部署后同源。于是一半的坑只在开发环境出现(跨源拦截类),生产上碰巧没事;另一半只在生产出现(HTTP 环境无 Service Worker),开发环境永远复现不了。dev 复现不了 ≠ 没 bug,dev 复现了 ≠ 生产会有。每个跨应用修复都要在「三种形态 × 两种环境」里各验一遍。

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

现象:接入 wujie 后,用户每点一次按钮就刷一条警告,功能正常,但日志被埋:

[Vue warn]: Invalid event arguments: event validation failed for event "click".

根因:wujie 的沙箱结构是 iframe(JS 运行环境)+ 容器(DOM 渲染环境)。子应用的 JS 跑在宿主页面里一个隐藏 iframe 中,渲染出的 DOM 挂在宿主文档里,两个 realm 各有一套原生构造器。用户真实点击产生的 MouseEvent 在主应用 realm 里创建,而子应用里 element-plus 的 el-button 对 click 做了 emits 校验:

emits: {
click: (evt) => evt instanceof MouseEvent
}

校验器里的 MouseEvent 来自子应用 iframe 的 realm,跨 realm 的 instanceof 恒为 false。

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

// 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 都可能翻车。排查时把报错库代码翻译成一个问题:「它拿到的构造器,和产生这个对象的构造器,是同一个 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 校验的是接收窗口的 origin,不是发送方的。子应用想当然传了 window.location.origin(自己的 5174),而接收方 window.top 是宿主(9444)→ 不匹配 → 浏览器拦截。传 '*' 能通,但等于丢掉 postMessage 唯一的内置安全设计:任何嵌入你的页面都能收到这条带鉴权语义的消息。

修复:按场景推导 origin。

// 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 // 独立打开时没有宿主,回退自身
}

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

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

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

教训:postMessage 有两套 origin 语义——主动发往 top 用 referrer 推导的宿主 origin,回复消息用 event.origin。固化成工具函数,别散在各处手写:全项目向 top 发消息的站点有十来个,散写必然有人写错。

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

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

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

// 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。而请求拦截器里有一个历史遗留的静默兜底:

// src/utils/request.ts —— 问题代码
const token = `Bearer ${tokenString || ''}`

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

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

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

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

现象:为解决 / / 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').

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

修复:只在顶层窗口注册。


(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");
});
})();

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

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

教训:微前端下遇到所有「按 origin 划界」的浏览器能力(Service Worker、Cookie、Storage、postMessage),先问:这个能力属于哪个 origin?谁有资格注册/读写?答案往往和「谁在写这段代码」不是同一个应用。注意 HTTP 环境没有 SW,这一半问题在 dev 里永远复现不了。

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

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

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

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

// 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 清单 / 编码规范。高频犯的错靠清单,不靠记性。

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

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

Cannot read properties of undefined (reading 'bus')

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

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

window.$wujie?.bus?.$on('goPage', goPage)
const token = window.$wujie?.props?.outLinkToken

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

教训:子应用的第一设计原则是「宿主缺席时应用必须仍然完整」。token、事件、导航等所有跨应用能力都设计成「可选注入 + 本地兜底」,而不是「必然存在」。

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

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

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

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

// 子应用大屏页(节选简化)
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 状态的同步,别自己监听,让宿主广播、子应用订阅。

子应用军规 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. 跨应用交互必须登记事件表:方向、事件名、监听位置、用途,缺一项不许上线。

第 10 条是前面所有条目的前提:微前端的大部分坑,根因都是「约定存在,但没人知道它存在」。把约定变成文档里的表格、代码里的注释、清单里的检查项,比任何个人的记性都可靠。


本文基于一个企业级管理端(Vue 3 + Vite + TypeScript + wujie-vue3)的真实生产问题整理,代码节选自线上仓库(已脱敏)。原文来自博客园「中文还在写码」:wujie 微前端踩坑实录:一个子应用的 7 个生产事故现场。

复制全文 生成海报 wujie 微前端 Vue3 ServiceWorker

推荐文章

程序员茄子在线接单