微前端从入门到精通
本文面向已经能独立开发单页应用 (SPA)、但尚未系统接触微前端的前端工程师。全文遵循一条由浅入深的主线: 先从单体应用真实的协作困境引出微前端要解决的问题, 再拆解它背后那几个绕不开的技术原理 (应用加载、路由分发、JS 隔离、样式隔离、应用通信), 然后逐个剖析业界主流框架的实现思路与取舍, 接着做一次多维度横向对比 (尤其是最容易踩坑的兼容性), 最后落到 qiankun 的完整接入实战与坑点补丁。每个新名词在首次出现时都会就地解释, 并尽量与你熟悉的后端微服务、Nginx 反向代理、Docker 做类比。读完这一篇, 目标是让你既能讲清原理, 也能上手接入并排障。
一、为什么需要微前端
在谈"是什么"之前, 先要想清楚"为什么"。任何架构都是为了解决具体问题而生的, 脱离问题谈架构就是过度设计。
单体前端应用的困境
设想一个已经迭代了三四年的中后台系统, 所有功能都写在同一个代码仓库、打包成同一个 SPA。随着业务膨胀, 你大概率会遇到这些问题:
- 构建越来越慢: 代码量堆到几十万行, 改一行代码, 本地热更新要等十几秒, 全量构建要好几分钟。
- 技术栈被焊死: 项目三年前用 Vue 2 起步, 现在想用 Vue 3 或 React 重写某个模块, 但牵一发动全身, 根本不敢动。
- 团队协作互相踩脚: 十几个人在同一个仓库里提交, 冲突频繁, 一个人写的全局样式或全局变量污染了别人的模块。
- 发布牵一发而动全身: 只改了订单模块一个小 bug, 却要把整个应用重新构建、全量部署, 风险和成本都不成比例。
- 老系统无法融合: 公司还有个用 jQuery 写的老后台想整合进来, 但技术栈完全不同, 无从下手。
这些痛点, 和后端从单体架构走向微服务时遇到的问题几乎一模一样。后端的答案是微服务, 前端的答案就是微前端 (Micro Frontends)。
二、微前端到底是什么
微前端是一种架构思想: 把一个庞大的前端单体应用, 按业务边界拆分成多个能够独立开发、独立部署、独立运行的小型应用, 再由一个容器应用把它们在运行时聚合成一个整体, 对最终用户呈现为一个无缝的产品。
这里有两个关键角色, 后文会反复用到:
- 主应用 (基座, Container / Host): 提供整体框架、导航、公共布局, 负责在合适的时机加载、挂载、卸载各个微应用。它类似后端微服务里的 API 网关, 也类似 Nginx 里那台做反向代理的入口机器。
- 微应用 (子应用, Micro App / Remote): 一个个被拆出来的业务模块, 每个都是相对完整的应用, 可以独立跑起来, 也能被主应用集成。它类似后端一个个独立部署的服务。
名词: 微前端与后端微服务的类比
后端微服务把一个巨型后端拆成若干个按业务划分、独立部署的服务, 通过网关统一对外。微前端做的是同一件事, 只是搬到了浏览器里: 主应用是网关, 微应用是服务, 用户在浏览器里点击导航切换的过程, 相当于网关把请求转发给不同的后端服务。区别在于, 后端服务运行在各自的进程里天然隔离, 而微应用最终要跑在同一个浏览器页面、同一个 window 上下文里, 所以微前端最核心的技术难题, 恰恰是如何在共享的环境里做出隔离。
微前端的核心价值
真正合格的微前端方案, 应当同时满足以下几点, 这也是判断一个方案是否"到位"的标尺:
- 技术栈无关: 主应用不限制微应用用什么框架, Vue、React、Angular 甚至 jQuery 老项目都能共存。这是融合历史遗留系统的关键。
- 独立开发、独立部署: 每个微应用有自己的仓库和流水线, 改订单模块只需重新部署订单微应用, 主应用和其他微应用完全不受影响。
- 运行时隔离: 微应用之间、微应用与主应用之间, JS 全局变量与 CSS 样式不能互相污染。
- 独立运行: 一个微应用即使脱离主应用, 也能单独启动调试, 不强绑定基座。
什么情况下不该用微前端
微前端不是银弹, 它引入了额外的复杂度 (隔离、通信、部署编排)。如果你的应用规模不大、团队只有几个人、技术栈统一, 那么单体 SPA 加上良好的模块化 (比如按路由拆分 chunk) 就足够了, 强上微前端反而是负担。微前端真正的价值场景是: 多团队并行、存在异构技术栈、需要独立部署、或要整合历史遗留系统。
三、核心技术原理
无论上层框架叫什么名字, 一个运行时微前端方案要跑起来, 底层都必须回答五个问题。把这五个问题想透了, 再看任何框架都只是这五点的不同排列组合。
3.1 应用加载: 如何把子应用运行起来
主应用要在浏览器里把一个部署在别处的子应用跑起来, 本质上就是"拿到它的静态资源 (HTML / JS / CSS), 然后在指定的 DOM 容器里执行渲染"。业界有两种主流思路。
JS Entry (入口是一个 JS 文件)
子应用打包成一个 UMD 格式的 JS 文件, 主应用通过动态创建 <script> 标签把它加载进来, 执行后子应用会往全局挂载一组约定好的生命周期函数 (如 bootstrap / mount / unmount), 主应用拿到这组函数来控制子应用。single-spa 早期就是这个路子。
JS Entry 的局限
子应用的所有资源 (包括 CSS、图片) 都得打进这一个 JS 文件里, 无法做代码分割和懒加载, 首屏体积容易失控。所以现在很少单独用它。
HTML Entry (入口是子应用的 HTML)
主应用把子应用的 index.html 当作入口整个拉过来, 解析这段 HTML, 提取里面的 <script>、<style>、<link>, 再逐个加载并执行。qiankun 用的就是这种方案, 背后依赖一个叫 import-html-entry 的库。它的好处是: 子应用怎么写就怎么打包, 接入方几乎无感, 天然支持子应用自己的资源分片。
// HTML Entry 的核心思路 (简化伪代码)
async function loadMicroApp(entry, container) {
// 1. 把子应用 index.html 整个 fetch 回来
const html = await fetch(entry).then((res) => res.text())
// 2. 解析 HTML, 分离出模板、脚本地址、样式
const { template, scripts, styles } = parseHTML(html)
// 3. 把样式和 DOM 模板塞进主应用的容器
container.innerHTML = template
styles.forEach((css) => injectStyle(css))
// 4. 依次拉取并执行脚本, 拿到子应用导出的生命周期
const lifecycles = await executeScripts(scripts)
return lifecycles // { bootstrap, mount, unmount }
}名词: 生命周期 (Lifecycle)
微前端里的"生命周期"指子应用暴露给主应用调用的一组约定函数。最经典的是三个: bootstrap (首次加载时初始化一次)、mount (每次被激活、渲染到页面时调用)、unmount (被切走、需要清理时调用)。主应用不关心子应用内部怎么实现, 只按这套契约在恰当的时机调用它们——这和后端定义好接口契约、彼此按接口协作是同一个道理。
3.2 路由分发: 什么时候激活哪个子应用
主应用需要一套规则决定"当前 URL 应该显示哪个子应用"。做法是给每个子应用配一条 激活规则 (activeRule), 通常是路由前缀。主应用劫持路由变化 (监听 popstate、hashchange, 并重写 pushState / replaceState), 每次 URL 变化时遍历所有子应用的激活规则:
- 规则命中、但子应用还没挂载 -> 调用它的
mount; - 规则不再命中、但子应用还挂着 -> 调用它的
unmount。
这套机制和 Nginx 按 location 前缀把请求转发到不同后端, 思路完全一致——只不过转发发生在浏览器里, 转发的目标是"渲染哪个子应用"而不是"请求哪台服务器"。
3.3 JS 隔离: 沙箱机制
这是微前端最核心也最难的部分。多个子应用跑在同一个 window 上下文里, 如果子应用 A 往 window.axios 挂了一个版本, 子应用 B 又挂了另一个版本, 或者 A 卸载后留下的定时器还在跑, 就会互相污染。沙箱 (Sandbox) 的作用就是给每个子应用一个"看起来独立"的全局环境, 卸载时能干净回滚。业界有三种实现。
快照沙箱 (Snapshot Sandbox)
原理很朴素: 子应用挂载前, 把整个 window 上的属性拍一张快照存起来; 子应用卸载时, 对比当前 window 与快照的差异, 把子应用运行期间新增/修改的属性还原回去。
class SnapshotSandbox {
activate() {
this.snapshot = {}
// 挂载前: 记录当前 window 的所有属性
for (const prop in window) {
this.snapshot[prop] = window[prop]
}
}
deactivate() {
// 卸载时: 把被子应用改动过的属性还原
for (const prop in window) {
if (window[prop] !== this.snapshot[prop]) {
window[prop] = this.snapshot[prop]
}
}
}
}快照沙箱的两个硬伤
一是性能差: 遍历整个 window 做全量快照和 diff, 开销不小。二是不支持多实例: 它直接操作真实 window, 同一时刻只能有一个子应用在"记录/还原", 无法让多个子应用同时挂载。它是 Proxy 不可用 (如 IE11) 时的降级方案。
Proxy 代理沙箱 (Legacy / Proxy Sandbox)
现代浏览器支持 Proxy, 就能做得优雅得多: 给每个子应用创建一个"假 window" (fakeWindow), 子应用对全局的读写都被 Proxy 拦截——写操作只落在自己的 fakeWindow 上, 读操作先查自己的 fakeWindow、查不到再回退到真实 window。这样子应用之间天然隔离, 卸载时直接丢弃 fakeWindow 即可, 无需 diff, 还能支持多个子应用同时挂载。
function createProxySandbox() {
const fakeWindow = {}
const proxy = new Proxy(fakeWindow, {
get(target, key) {
// 优先读子应用自己的, 没有再读真实 window
return key in target ? target[key] : window[key]
},
set(target, key, value) {
// 写操作只落在 fakeWindow, 不污染真实 window
target[key] = value
return true
}
})
return proxy
}子应用代码执行时, 通过 with(proxyWindow) { ... } 或把代码包在 (function(window){ ... })(proxyWindow) 里, 就能让子应用里所有裸写的全局变量都指向这个代理。qiankun 支持 Proxy 时默认用这套 (单实例场景) 或多实例的 ProxySandbox。
沙箱管不到的副作用
沙箱能拦截对 window 的属性读写, 但无法拦截对真实 DOM 的直接操作。比如子应用直接往 document.body 上 appendChild 一个弹窗, 或第三方 SDK 直接改 document.title, 这些副作用沙箱管不到, 需要框架额外做 DOM 劫持 (记录子应用运行期间新增的 DOM/事件/定时器, 卸载时统一清理), 或由开发者在 unmount 里手动清理。
上面那段 createProxySandbox 只是最小骨架。真正生产级的 Proxy 沙箱 (以 qiankun 的 ProxySandbox 为例), 还要处理三类棘手问题, 这也是"看懂原理"与"能自己写一个"的分水岭:
其一, document / BOM 也要代理, 不只是 window。 子应用里的 document.querySelector 应该查到自己那棵子树而不是整个主文档, window.location 的读写要受控。qiankun 用一套 patchDocument 把 document.createElement、document.querySelector 等重定向到子应用容器; 更彻底的 wujie/micro-app 干脆借 iframe 拿到独立的 document。
其二, 有些属性必须"穿透"到真实 window, 不能拦。 例如子应用调用 window.location.href = ... 要真的跳转、window.history 要能改地址栏、document/window 本身的引用不能被替换。沙箱内部维护一份"逃逸白名单", 对这些属性直接读写真实 window。名单划错, 轻则功能失灵, 重则死循环。
其三, 副作用要挂账, 卸载时统一清算。 子应用运行期间的 setTimeout/setInterval、addEventListener、动态 appendChild 的 <script>/<style>, 沙箱都会劫持并记进一个 effect 列表, unmount 时逐一 clearTimeout / removeEventListener / 移除节点。这就是为什么 qiankun 卸载子应用后, 它注册的定时器会自动停——不是魔法, 是记账。
// qiankun 副作用劫持的思路 (简化)
const timers = []
proxyWindow.setInterval = (fn, ms) => {
const id = rawSetInterval(fn, ms)
timers.push(id) // 挂账
return id
}
function deactivate() {
timers.forEach((id) => rawClearInterval(id)) // 卸载时清算
}名词: LegacySandbox 与 ProxySandbox 的区别
qiankun 内部其实有两种 Proxy 沙箱。LegacySandbox 仍会短暂改写真实 window (只记录 diff, 卸载时还原), 因此只支持单实例, 但兼容一些依赖真实 window 的老库; ProxySandbox 完全不碰真实 window, 每个子应用一个独立 fakeWindow, 支持多个子应用同时挂载 (singular: false)。start 默认 singular: true 走单实例路径, 想让多个微应用同屏共存 (如工作台同时嵌两个) 才需要显式关掉。
iframe 沙箱
最彻底的隔离: 直接开一个 iframe, 借用浏览器给 iframe 的原生隔离——iframe 有自己独立的 window、document、全局作用域。wujie (无界) 和 micro-app 的类 iframe 模式走的就是这条路: 把子应用的 JS 丢进一个空的 iframe 里执行, 拿到干净的 window, 但 DOM 渲染仍放在主文档里 (通过 Proxy 把 iframe 的 document 操作代理到主文档的容器), 从而兼顾"JS 完全隔离"与"渲染不受 iframe 布局限制"。
3.4 样式隔离: 别让 CSS 互相打架
JS 隔离之外, CSS 也会互相污染: 子应用 A 写了个 .title { color: red }, 可能把主应用或子应用 B 的标题也染红了。主流有三种隔离手段。
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| Shadow DOM | 把子应用挂到 Shadow DOM 里, 样式天然作用域隔离 | 隔离最彻底, 浏览器原生支持 | 挂到 document.body 的弹窗跑到 Shadow DOM 外, 样式丢失; 老浏览器不支持 |
| Scoped 前缀 (运行时加属性) | 给子应用所有选择器自动加上 div[data-qiankun="app"] 前缀 | 兼容性好, 不破坏弹窗结构 | 动态插入到 body 的样式无前缀, 仍会漏; 有一定运行时开销 |
| 约定式 (BEM / CSS Modules) | 靠命名规范或编译期哈希保证类名唯一 | 零运行时副作用 | 依赖团队自觉或构建配置, 无法约束第三方样式 |
名词: Shadow DOM
Shadow DOM 是浏览器 Web Components 标准的一部分, 它能给一个 DOM 节点挂一棵"影子树", 这棵树内部的样式和外部完全隔离——外面的 CSS 进不来, 里面的 CSS 也出不去。这就像给子应用套了个玻璃罩子。代价是: 有些第三方 UI 库 (Element Plus、Ant Design) 的弹窗、消息提示默认挂到 document.body, 也就是玻璃罩子外面, 于是罩子里的样式作用不到它们, 弹窗就"裸奔"了。这个坑在实战章节还会具体讲怎么补。
上面三种方案里, Shadow DOM 和约定式都好理解, 唯独 scoped 前缀 常被当成黑盒。但它恰恰是 qiankun experimentalStyleIsolation 与 micro-app scopecss 的默认路径, 也是"看懂"与"精通"样式隔离的分水岭——下面把它拆开。
scoped 前缀不是加 class, 而是运行时逐规则重写 CSS。 很多人以为它像 Vue 的 scoped 那样给元素打 data-v-xxx, 其实运行时方案做不到"改元素", 只能改样式表: 框架把子应用每一段 <style> 的文本捞出来, 用一个 CSS Parser 解析成规则树, 给每条选择器前面插一个属性前缀 (qiankun 用 div[data-qiankun="app"], micro-app 用 micro-app[name=xxx]), 再把改写后的 CSS 塞回去。原理骨架:
// 把 ".title { color: red }" 改写成 "micro-app[name=app1] .title { color: red }"
function scopeCss(cssText, prefix) {
const sheet = parseToRules(cssText) // 解析成规则数组
return sheet
.map((rule) => {
if (rule.type === 'style') {
// 每个逗号分隔的选择器都要单独加前缀
rule.selector = rule.selector
.split(',')
.map((sel) => `${prefix} ${sel.trim()}`)
.join(', ')
}
return stringify(rule)
})
.join('\n')
}真正的难点全在边界规则上——这是 scopecss 有几百行的原因。 一个能上生产的 CSS 改写器必须正确处理这些 case, 漏一个就出诡异 bug:
@media/@supports: 是嵌套规则, 不能给@media本身加前缀, 要递归进入内部规则再给每条选择器加;@keyframes: 动画名是全局标识符, 绝对不能加前缀, 否则animation: spin 1s就找不到spin了;@font-face/url(): 里面的相对路径要补全成子应用的绝对地址 (否则字体 404), 但选择器层面不加前缀;body/html/:root: 子应用里写的body { margin: 0 }若直接加前缀会失效 (主文档的 body 不在子应用容器内), micro-app 的做法是把它们替换成子应用的容器元素 (如micro-app-body);!important与行内样式: 前缀只提升了选择器特异性, 子应用里的行内 style 和主应用的!important仍可能互相压制, 这是 scoped 方案无法根治的裂缝。
scoped 前缀的两个"漏点"
其一, 动态注入的样式漏前缀。子应用运行期间用 JS 往 document.head 塞的 <style> (styled-components、CSS-in-JS、el.style 之外的运行时样式), 不在首次解析范围内。qiankun/micro-app 用 MutationObserver 监听 head 的 childList 变化, 抓到新 <style> 立刻走一遍改写。没有这层监听, CSS-in-JS 的样式就会裸奔。
其二, 挂到 body 的元素漏隔离。scoped 前缀限定的是"容器内的元素", 而弹窗默认 appendChild 到 document.body (容器外), 前缀选择器自然选不中——这就是下面要讲的弹窗逃逸。
Shadow DOM 的弹窗逃逸: 隔离太彻底反而成了坑。 Shadow DOM 把样式关进影子树, 可 Element Plus 的 Dialog、Ant Design 的 Message 默认把 DOM 挂到 document.body——影子树外面。样式在罩子里、元素在罩子外, 弹窗就没样式。补法不是改隔离方案, 而是把弹窗容器指回子应用内部, 各 UI 库都留了这个口子:
// Element Plus: 全局把弹层挂载点设到子应用容器 (而非 document.body)
import { ElConfigProvider } from 'element-plus'
// <el-config-provider :append-to="子应用根节点">
// Ant Design: 组件级 getPopupContainer 指回触发节点的父级
<Select getPopupContainer={(trigger) => trigger.parentNode} />
<Modal getContainer={() => document.querySelector('#sub-app-root')} />CSS 变量要能"穿透", 隔离不是把主题也隔死。 隔离的目标是"子应用别互相污染", 但主题色、字号这类设计 token 恰恰需要从主应用流到子应用。CSS 自定义属性 (--primary-color) 天然继承、能穿透 Shadow DOM 边界 (继承的变量对影子树可见), 因此生产里的通行做法是: 主应用在 :root 定义一套 CSS 变量, 子应用只 var(--primary-color) 引用而不硬编码颜色, 换肤时主应用改一处、全站子应用跟着变。这也是为什么"样式隔离"和"主题共享"能同时成立——隔离的是规则冲突, 共享的是变量继承。
一句话选样式隔离
现代浏览器、子应用少、要彻底隔离 → Shadow DOM (记得补弹窗容器); 要兼容弹窗和第三方库、能接受一点运行时开销 → scoped 前缀 (记得开 MutationObserver 追动态样式); 团队可控、想零运行时 → CSS Modules / BEM 约定。三者可叠加: scoped 打底 + CSS 变量做主题穿透, 是最稳的组合。
3.5 应用通信: 主子应用如何传数据
微应用之间需要共享登录态、用户信息、主题等。常见通信方式:
- Props 下发: 主应用挂载子应用时通过参数把数据传进去, 这是最基础、最推荐的方式, 类似父组件给子组件传 props。
- 全局状态池: 框架提供一个发布订阅的全局 store (如 qiankun 的
initGlobalState), 主子应用都能读写并监听变化。 - 自定义事件 / EventBus: 通过
window.dispatchEvent加CustomEvent, 或框架内置的事件中心广播消息。 - URL 参数: 把状态放到路由 query 上, 简单但只适合少量、可公开的数据。
通信要克制
微前端的初衷是解耦, 如果子应用之间通信过于频繁、耦合过深, 说明业务边界可能没划分好。通信应该只用于传递少量共享的全局态 (登录信息、权限、主题), 而不是把它当成跨应用的万能数据总线。
五个框架的通信 API 放在一起对比, 能看出它们的设计取向。 同样是"传数据", 有的给你响应式 store, 有的只给你事件, 有的干脆什么都不给:
// qiankun: initGlobalState 响应式全局态 (发布订阅 + onChange 监听)
// 主应用
import { initGlobalState } from 'qiankun'
const actions = initGlobalState({ user: null, theme: 'light' })
actions.onGlobalStateChange((state, prev) => console.log(state, prev))
actions.setGlobalState({ user: { name: 'Ada' } })
// 子应用 (mount 时通过 props 拿到 actions)
export async function mount(props) {
props.onGlobalStateChange((state) => {/* 响应变化 */})
props.setGlobalState({ theme: 'dark' })
}
// single-spa: 官方只给 props, 没有内置通信 —— 要共享态得自己传一个 store 进去
const store = createSharedStore() // 自建, 常用 RxJS BehaviorSubject / mitt
singleSpa.registerApplication({
name: 'app1', app: loadApp, activeWhen: '/app1',
customProps: { store, user: currentUser }, // 只能靠 props 下发
})
// wujie: props 下发 + bus 事件总线 (跨应用广播)
import WujieVue from 'wujie-vue3'
const { bus } = WujieVue
bus.$on('login', (user) => {/* 主应用监听子应用事件 */})
// <WujieVue name="sub" :url="url" :props="{ user }" />
bus.$emit('logout') // 任意方广播
// micro-app: EventCenter 数据通信 (data 属性下发 + dispatch 上报)
import microApp from '@micro-zoe/micro-app'
// 主 → 子: <micro-app name='app1' :data='{ user }' />
microApp.setData('app1', { theme: 'dark' }) // 主动发给指定子应用
microApp.addDataListener('app1', (data) => {/* 收子应用数据 */})
// 子 → 主: window.microApp.dispatch({ from: 'app1' })
// Module Federation: 没有"通信"概念 —— 直接 import 对方暴露的模块/store
import sharedStore from 'host/store' // 像用本地模块一样共享单例
sharedStore.setUser(user)规律: qiankun / micro-app / wujie 给了开箱即用的响应式或事件通道; single-spa 只给 props, 共享态要自己造; MF 层级最低——它共享的是模块本身, 通信退化成"import 一个单例", 最灵活但也最需要纪律 (谁都能改这个单例)。
两种通信模型该怎么选? 落到具体业务, 不外乎两类, 混用是常态:
| 共享 Store (状态池) | 事件总线 (EventBus) | |
|---|---|---|
| 适合 | 持续态: 登录信息、权限、主题、语言 | 瞬时动作: "用户登出了"、"打开某弹窗" |
| 语义 | "当前值是什么", 后订阅也能拿到最新 | "刚发生了什么", 只有在场的能收到 |
| 风险 | 谁都能写, 易变成隐式全局耦合 | 事件满天飞, 难追溯谁发谁收 |
| 代表 | qiankun initGlobalState、MF 共享 store | wujie bus、micro-app dispatch、CustomEvent |
一句话: 持续态用 store (带初值、可回放), 一次性动作用事件 (轻、无残留)。别用事件同步持续态 (后加入的子应用收不到历史事件, 会拿到空值)。
通信契约: 微前端最容易腐化的地方
通信一旦跨了应用边界, 就是跨团队的接口契约, 和后端 API 一样需要治理, 否则半年后没人敢改那个全局 store。三条底线:
- 约定 payload 结构并显式版本化。 全局态和事件的数据结构应写成 TypeScript 类型 (或 JSON Schema) 放在共享包里, 主子应用都从这个包 import 类型。
{ type: 'user/login', version: 1, payload: {...} }带上 version, 结构演进时老子应用能识别并降级。 - 事件命名带命名空间, 别用裸字符串。
bus.$emit('refresh')这种全局裸事件, 多个子应用都监听就会误触发。用订单中心/order-created这样的命名空间前缀, 从名字就能追溯归属。 - 向后兼容, 只增不改。 给 payload 加字段是安全的 (老代码忽略新字段); 改字段名、改类型是破坏性变更, 必须走版本号 + 灰度。基座团队应维护一份"通信契约文档", 记录每个全局态字段、每个事件的语义与 owner。
四、主流框架剖析
理解了五大原理, 再看框架就清晰了——每个框架都是这五点的一套具体答案加上不同的取舍。下面按出现顺序逐个剖析。
4.1 iframe: 最原始也最稳的方案
iframe 是浏览器原生能力, 不需要任何框架。主应用放一个 <iframe src="子应用地址">, 切换导航时换 src 或控制显隐即可。它的隔离是所有方案里最彻底的: 独立的 window、document、CSS 上下文, 天然互不干扰。
优点
- JS 与 CSS 完全隔离, 集成第三方应用几乎零风险;
- 接入极简单, 任何技术栈、任何老系统都能塞进来。
缺点
- 状态易丢: 主应用刷新时, iframe 会重新加载
src对应的初始 URL, 内部路由状态、表单填写全部丢失; - 弹窗无法覆盖全局: iframe 里的模态框只能在 iframe 区域内居中, 无法相对整个主应用页面居中遮罩;
- 通信笨重: 主子应用跨浏览上下文, 只能靠
postMessage, 且数据同步、URL 同步都要手写; - 性能与体验: 每次加载都有独立的浏览器上下文开销, 首次加载有白屏。
正是这些缺点, 催生了后面一系列"既要 iframe 的隔离、又要避开 iframe 缺点"的框架。
4.2 single-spa: 微前端框架的鼻祖
single-spa 是最早成体系的微前端框架 (2018), 后来几乎所有 JS SDK 方案都建立在它的思想之上。它的核心非常纯粹: 只负责路由劫持和生命周期编排, 不管隔离、不管加载资源。
它要求每个子应用导出 bootstrap / mount / unmount 三个生命周期函数, 主应用注册子应用时给出 name、加载函数和激活规则。single-spa 监听路由变化, 在合适的时机调用对应子应用的生命周期。
// single-spa 主应用注册
import { registerApplication, start } from 'single-spa'
registerApplication({
name: 'app-vue',
app: () => System.import('app-vue'), // 自己负责怎么加载
activeWhen: '/vue'
})
start()single-spa 的取舍
它把隔离、样式、资源加载、通信全都交给开发者自己实现——灵活性拉满, 但胶水代码多、上手门槛高。JS 沙箱、CSS 隔离、HTML Entry 这些能力它都没有。可以把它理解成微前端的"汇编语言": 强大但原始。qiankun 正是在它之上补齐了这些能力。
4.3 qiankun: 国内事实标准
qiankun (乾坤) 由阿里蚂蚁团队出品 (2019), 在 single-spa 之上补齐了开箱即用的能力, 是目前国内生态最成熟、资料最多、踩坑前人最多的方案。它的技术组合是:
- HTML Entry: 通过
import-html-entry把子应用 HTML 整体拉过来解析, 接入方几乎无需改动打包产物结构; - JS 沙箱: 支持
Proxy时用代理沙箱 (可多实例), 不支持时降级为快照沙箱 (仅单实例); - CSS 隔离: 提供
strictStyleIsolation(Shadow DOM) 和experimentalStyleIsolation(scoped 前缀) 两种可选; - 通信: 内置
initGlobalState全局状态池 + props 下发; - 预加载: 支持在浏览器空闲时提前加载其他子应用资源。
一句话: qiankun = single-spa 的路由编排 + HTML Entry + 沙箱 + 样式隔离 + 通信, 把散落的能力打包成了开箱即用的框架。它的完整接入和坑点是本文后半部分的重点。
qiankun 的短板
最大的问题是对 Vite 支持不佳。qiankun 的 import-html-entry 无法解析执行 Vite 产出的原生 ESM (<script type="module">), 需要借助 vite-plugin-qiankun 这类社区插件 hack, 且此时 JS 沙箱基本失效。此外子应用需按约定改造 entry 与生命周期, 老项目改造有一定成本。
4.4 Module Federation: 构建时的模块共享
Module Federation (模块联邦, 简称 MF) 是 Webpack 5 官方内置的能力 (2020), 严格说它和上面几个不是一个维度的东西——它不是运行时的应用编排框架, 而是构建时的模块共享机制。
它的核心思想: 一个应用 (Remote) 可以在构建时"暴露" (expose) 自己的某些模块, 另一个应用 (Host) 可以在运行时像 import 本地模块一样, 直接远程加载并使用这些模块, 并且公共依赖 (如 React、Vue) 只需加载一份。
// webpack.config.js —— Remote 暴露模块
new ModuleFederationPlugin({
name: 'remoteApp',
filename: 'remoteEntry.js',
exposes: { './Button': './src/Button' }, // 暴露组件
shared: ['react', 'react-dom'] // 声明共享依赖
})
// Host 消费远程模块
const RemoteButton = React.lazy(() => import('remoteApp/Button'))MF 的真正定位
MF 解决的是"多个应用/团队之间如何优雅地共享代码和依赖", 天生擅长依赖去重, 避免每个子应用各打一份 React。但它不提供 JS 沙箱、不提供 CSS 隔离、不管路由分发——所有模块共享同一个运行时上下文。所以实践中常见的组合是: qiankun 负责隔离与路由编排, MF 负责在子应用之间共享公共依赖, 两者互补而非二选一。
4.5 micro-app: 基于 WebComponent 的低侵入方案
micro-app 由京东出品 (2021), 最大卖点是接入成本极低——把子应用当成一个 HTML 标签来用:
<micro-app name="app1" url="http://localhost:8081/" baseroute="/app1"></micro-app>它的实现融合了两项 Web 标准: 用 CustomElement (自定义元素) 把子应用封装成 <micro-app> 标签, 用 Shadow DOM 或 scoped 前缀做样式隔离, JS 隔离则提供了基于 Proxy 的类 iframe 沙箱。因为不依赖 single-spa, 它对子应用的改造要求比 qiankun 更少, 还支持 keep-alive 保活。
优点
- 接入像用组件一样简单, 子应用几乎零改造;
- 构建工具无关, Webpack / Vite 都能接;
- 支持子应用保活 (keep-alive)、预渲染。
缺点
- 依赖 CustomElement 和 Proxy, 不支持 IE 及过老的浏览器;
- 深度隔离场景下仍有一些边界 case 需要处理。
4.6 wujie (无界): iframe 隔离的现代复活
wujie (无界) 由腾讯出品 (2022), 思路很巧: 用 iframe 做 JS 沙箱, 用 WebComponent 做渲染容器, 把两者的优点结合起来。
具体做法是: 开一个空的、隐藏的 iframe, 把子应用的 JS 放进去执行——这样就白嫖了 iframe 原生的 window 隔离; 但子应用的 DOM 不渲染在 iframe 里, 而是通过 Proxy 把 iframe document 的操作代理到主文档的一个 WebComponent 容器里渲染。这样既有 iframe 级别的彻底 JS 隔离, 又没有 iframe "弹窗被困在框里、布局受限"的毛病。
优点
- JS 隔离用 iframe 原生能力, 彻底且可靠;
- CSS 用 Shadow DOM, 天然隔离;
- 子应用几乎零改造, 接近独立运行的体验;
- 天然支持保活, 切换子应用状态不丢失;
- 构建工具无关, 对 Vite 友好。
缺点
- 依赖
Proxy+WebComponent+ iframe, 是所有方案里对浏览器能力要求最高的, 老浏览器完全无法使用; - iframe 与主文档的通信代理链路较复杂, 极端场景下有调试成本。
4.7 Garfish: 字节的类 qiankun 方案
Garfish 由字节跳动出品 (2022), 整体思路与 qiankun 接近 (HTML Entry + Proxy 沙箱 + 路由劫持), 在沙箱性能、多实例、与字节内部工程体系 (如 Modern.js) 的集成上做了优化。它是 qiankun 之外一个成熟度较高的国产替代, 但社区生态与资料量目前仍不及 qiankun。
4.8 一张图看懂框架谱系
五、多维度横向对比
前面分开看了每个框架, 这里把它们放到一起横向比较, 帮助选型。
5.1 综合能力对比
| 维度 | single-spa | qiankun | Module Federation | micro-app | wujie |
|---|---|---|---|---|---|
| 接入成本 | 高 (手写胶水层) | 中 (改 entry + 生命周期) | 中 (改 webpack 配置) | 低 (一个标签) | 低 (一个标签) |
| JS 隔离 | 无, 自行实现 | Proxy / 快照沙箱 | 无 (共享上下文) | Proxy 类 iframe 沙箱 | iframe 原生沙箱 |
| CSS 隔离 | 无 | ShadowDOM / scoped | 无 | ShadowDOM | iframe / ShadowDOM |
| 技术栈无关 | 支持 | 支持 | 支持 | 支持 | 支持 |
| 多实例并存 | 支持 | Proxy 沙箱支持 | 支持 | 支持 | 支持 |
| 依赖共享 | 手动 | 手动 / externals | 原生强项 | 手动 | 手动 |
| 子应用保活 | 不支持 | 不支持 (切换即销毁) | 不适用 | 支持 keep-alive | 天然保活 |
| 预加载 | 不支持 | 支持 | 部分支持 | 支持 | 支持 |
| 社区活跃度 | 中 | 高 (国内事实标准) | 高 | 中高 | 中高 |
5.2 兼容性对比 (选型时最容易踩的坑)
兼容性必须拆成三层看, 实际选型翻车几乎都出在这里。
第一层: 浏览器兼容性
| 框架 | 关键 API 依赖 | IE11 | 老旧浏览器表现 |
|---|---|---|---|
| single-spa | 无强依赖 | 可支持 | 最好, 取决于你自己的代码 |
| qiankun | Proxy (沙箱) | 降级为快照沙箱, 只能单实例 | 支持 Proxy 则正常, 否则功能受限 |
| Module Federation | ES2015+ 动态导入 | 需大量 polyfill, 较麻烦 | 现代浏览器良好 |
| micro-app | CustomElement v1 + Proxy | 不支持 | 不支持 WebComponent 的浏览器直接不可用 |
| wujie | Proxy + WebComponent + iframe | 不支持 | 依赖最多, 老浏览器无法用 |
一句话规律: 隔离做得越好、接入越简单的新框架, 对浏览器能力要求越高。要兼容 IE 或政企的老旧环境, 基本只能选 single-spa, 或使用降级为快照沙箱模式的 qiankun (且放弃多实例)。
第二层: 构建工具兼容性
- qiankun: 对 Webpack 支持完善, 但原生不支持 Vite。Vite 用原生 ESM, qiankun 的
import-html-entry无法解析执行, 需要vite-plugin-qiankun之类的插件 hack, 且此时沙箱能力打折。 - Module Federation: 强绑定 Webpack 5; 用 Vite 需要
@originjs/vite-plugin-federation, 稳定性弱于 Webpack 原生。换构建工具的成本最高。 - micro-app / wujie: 属于运行时方案, 构建工具无关, Vite、Webpack、Rspack 都能接, 这是它们相对 qiankun 的一大优势。
第三层: 框架/技术栈兼容性
主流框架都宣称"技术栈无关", React、Vue2、Vue3、Angular 混用理论上都行, 实际差异在:
- Angular 接入普遍最麻烦 (zone.js、路由 base href、构建产物), 各框架都要额外配置;
- wujie / micro-app 因隔离更彻底, 子应用几乎不用改造, 跨技术栈混用体验最好;
- qiankun 子应用需按约定改造 entry 与生命周期, 老项目、jQuery 项目改造成本相对高。
兼容性选型速记
- 要兼容 IE / 政企老环境 -> single-spa 或降级模式 qiankun;
- 用 Vite -> micro-app / wujie 优先, qiankun 需插件且沙箱打折;
- 只需共享依赖、都在 Webpack 5 -> Module Federation;
- 现代浏览器 + 追求零改造和保活 -> wujie / micro-app。
5.3 被忽略的第四个维度: 加载性能
选型时大家盯着隔离和兼容, 却常常忽略加载性能——它在生产环境才暴露, 且几乎不可事后补救。不同方案的加载模型有本质差异:
| 方案 | 加载模型 | 首屏代价 | 依赖去重 |
|---|---|---|---|
| single-spa / qiankun | HTML/JS Entry, 运行时 fetch + 解析子应用资源 | 子应用首次进入有网络往返 + 解析开销 | 默认各打一份, 靠 externals/预加载缓解 |
| Module Federation | 构建时约定 shared, 运行时按需拉 chunk | 首屏只加载 host + 用到的 remote 分片 | 原生强项, 公共依赖只下一份 |
| wujie | iframe 承载 + 可预加载 + 保活 | 首次起 iframe 有成本, 但保活后切换几乎零代价 | 各自独立, 无去重 |
| micro-app | 类 iframe + 预渲染 + keep-alive | 类似 wujie | 各自独立 |
几个精通级的性能要点:
- 预加载 (prefetch) 的本质是"用空闲时间换首屏体验"。 qiankun 的
prefetch: 'all'、wujie 的preloadApp、micro-app 的<micro-app prefetch>都是在浏览器空闲 (requestIdleCallback) 时提前把其他子应用的资源拉到缓存, 用户真正点进去时直接命中。代价是挤占带宽, 子应用多时要按需 prefetch 而非无脑 all。 - 公共依赖重复是运行时方案的通病。 主应用 Vue + 三个子应用各自的 Vue, 首屏可能白下载 3 份框架。qiankun/single-spa 只能靠
externals+ import map 共享 (但要求版本严格一致), 这恰恰是 Module Federation 的降维打击: 它在构建时就协商好 shared, 天然只加载一份。这也是"qiankun 管隔离 + MF 管共享"组合的动机。 - 保活是另一种性能策略。 wujie/micro-app 的 keep-alive 让子应用切走不销毁, 再切回来瞬间恢复 (DOM 与状态都在)。对"多 tab 工作台"这类频繁切换场景, 保活带来的体验提升远超首屏那点开销——但内存常驻是代价, 子应用多了要权衡。
- 沙箱本身有运行时成本。 Proxy 沙箱对每次全局访问都多一层拦截, scoped 样式隔离要在运行时重写每条 CSS 规则。绝大多数场景可忽略, 但在子应用有密集全局读写或超大样式表时会显现, 属于"知道有这回事"的冷知识。
一句话
隔离、兼容、接入成本之外, 把加载性能作为第四个选型维度: 依赖共享诉求强 → MF; 频繁切换 → 带保活的 wujie/micro-app; 首屏敏感 → 务必开预加载并做依赖外置。
六、qiankun 接入实战
选型落到最常用的 qiankun, 完整走一遍接入。qiankun 接入分主应用 (基座) 和子应用两部分, 核心三板斧是: 子应用暴露 bootstrap / mount / unmount、注入动态 public-path、打包成 umd。
6.1 主应用 (基座) 配置
基座只做三件事: 注册子应用、留一个挂载容器、启动 qiankun。
// 基座 main.js
import { registerMicroApps, start } from 'qiankun'
registerMicroApps([
{
name: 'sub-vue', // 唯一, 需与子应用打包 library name 一致
entry: '//localhost:8081', // 子应用的 HTML entry
container: '#subapp-container', // 挂载点
activeRule: '/sub-vue' // 路由匹配到这个前缀就激活
},
{
name: 'sub-react',
entry: '//localhost:8082',
container: '#subapp-container',
activeRule: '/sub-react'
}
])
start({
prefetch: 'all', // 预加载所有子应用资源
sandbox: { experimentalStyleIsolation: true } // 开启 scoped 样式隔离
})<!-- 基座页面里留一个容器给子应用挂载 -->
<div id="subapp-container"></div>6.2 子应用改造 (以 Vue3 为例)
子应用要做三件事: 注入动态 publicPath、暴露生命周期、调整打包配置。
第一步, 在入口最顶部 (所有 import 之前) 引入动态 publicPath:
// public-path.js
if (window.__POWERED_BY_QIANKUN__) {
// eslint-disable-next-line no-undef
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__
}第二步, 在 main.js 里引入它并暴露生命周期:
import './public-path' // 必须是第一行
import { createApp } from 'vue'
import App from './App.vue'
import { createRouter, createWebHistory } from 'vue-router'
import routes from './routes'
let app = null
function render(props = {}) {
const { container } = props
app = createApp(App)
const router = createRouter({
// 路由 base 要带上 activeRule 前缀, 否则跳转 404
history: createWebHistory(
window.__POWERED_BY_QIANKUN__ ? '/sub-vue' : '/'
),
routes
})
app.use(router)
// 挂载点要从 container 里找, 不能写死 #app
app.mount(container ? container.querySelector('#app') : '#app')
}
// 独立运行时 (不在 qiankun 环境) 直接渲染
if (!window.__POWERED_BY_QIANKUN__) {
render()
}
// 暴露给 qiankun 调用的三个生命周期
export async function bootstrap() {}
export async function mount(props) {
render(props)
}
export async function unmount() {
app.unmount()
app = null
}第三步, 调整打包配置, 打成 UMD 并允许跨域 (以 Vue CLI 为例):
// vue.config.js
const { name } = require('./package.json')
module.exports = {
configureWebpack: {
output: {
library: `${name}`, // 必须与 registerMicroApps 里的 name 一致
libraryTarget: 'umd', // 打包成 umd 格式, qiankun 才能识别
chunkLoadingGlobal: `webpackJsonp_${name}` // 避免多应用全局变量冲突
}
},
devServer: {
port: 8081,
headers: {
'Access-Control-Allow-Origin': '*' // 基座跨域拉取子应用资源
}
}
}至此, 一个最小可跑的 qiankun 主子应用就搭好了。接下来是重头戏——实战里几乎一定会踩的坑。
运行时效果 · 动手跑一跑
本文每个框架都配了可运行样例(1 主应用 + Vue 3 与 React 两个异构子应用), 均按官方权威方式接入。
- qiankun 在线预览: https://stoicatom.github.io/demos/micro-frontends/qiankun/
- 全部框架样例导航: https://stoicatom.github.io/demos/micro-frontends/
- 源码: https://github.com/lorainwings/demos/tree/master/micro-frontends/qiankun
点击顶部导航切换 Vue / React 子应用, 打开控制台可看到 initGlobalState 的全局状态变更日志。
七、qiankun 坑点补丁汇总
qiankun 是最成熟、踩坑前人最多的方案, 单独用一章汇总它的高频坑。qiankun 90% 的坑集中在三个方向: 资源路径 (publicPath)、样式隔离、Vite 兼容。记住这三个方向去排查, 基本不会跑偏。下面逐个给出现象、根因和补丁。
坑 1: 子应用静态资源 (图片 / 字体 / CSS) 404
现象: 子应用独立跑好好的, 接入基座后图片、字体全部 404, 请求路径变成了基座域名下。
根因: 子应用打包时 publicPath 是相对路径, 运行时被基座域名劫持了。
补丁: 就是上面 public-path.js 那段, 必须放在入口第一行 import, 且早于所有其他 import。原理是 qiankun 会往 window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ 注入子应用真实的资源地址, 赋值给 __webpack_public_path__ 后, webpack 运行时加载的所有资源都会拼上正确的前缀。
坑 2: CSS 样式互相污染
现象: 子应用的全局样式 (如 body、.el-button) 泄漏到基座或其他子应用。
根因: 默认情况下所有子应用样式都注入到同一个主文档, 全局选择器互相覆盖。
补丁: qiankun 提供两种隔离开关, 根据浏览器要求二选一:
start({
sandbox: {
// 方案 A: Shadow DOM, 隔离最彻底
strictStyleIsolation: true,
// 方案 B: scoped 前缀, 兼容性更好 (二选一, 通常选这个)
experimentalStyleIsolation: true
}
})推荐 experimentalStyleIsolation, 它会给子应用所有选择器自动加上 div[data-qiankun="sub-vue"] 前缀, 不破坏弹窗 DOM 结构, 兼容性也更好。
坑 3: 弹窗 / 消息提示样式丢失
现象: 开了样式隔离后, Element Plus 的 el-dialog、Ant Design 的 Modal、Message 等组件样式裸奔。
根因: 这些组件默认挂载到 document.body——也就是 Shadow DOM 罩子外面, 或 scoped 前缀作用域外面, 于是隔离样式作用不到它们。
补丁: 强制让弹窗类组件挂载到子应用容器内部:
// Element Plus: 关闭 append-to-body
<el-dialog :append-to-body="false" />
// Ant Design: 指定 getPopupContainer 到子应用根节点
<Select getPopupContainer={(node) => node.parentNode} />
<Modal getContainer={() => document.querySelector('#sub-app-root')} />坑 4: 子应用路由跳转失效 / 刷新 404
现象: 子应用内部点击跳转白屏, 或刷新页面直接 404。
根因: 子应用路由 base 没有带上 activeRule 前缀, history 模式下路径对不上。
补丁: 子应用路由 base 动态设置为 qiankun 注入的前缀 (即 6.2 里 createWebHistory 那段)。要点是: 子应用路由 base、registerMicroApps 的 activeRule 三者前缀必须完全一致。hash 模式基本没这个问题, 但企业项目多用 history, 务必对齐。
坑 5: 全局变量与副作用未清理
现象: 子应用卸载后, 它注册的定时器还在跑、事件监听还在触发, 或多个子应用抢同一个全局变量。
根因: qiankun 的沙箱能自动回滚对 window 的属性写入, 也会清理沙箱期间新增的定时器和事件, 但通过第三方 SDK 直接操作真实 DOM 的副作用 (埋点、地图实例) 拦截不到。
补丁: 在 unmount 里手动做彻底清理, 双保险:
export async function unmount() {
app.unmount()
clearInterval(timer) // 清定时器
window.removeEventListener('resize', handler) // 清监听
mapInstance?.destroy() // 销毁第三方实例
app = null
}坑 6: Vite 子应用接入 (最大的兼容坑)
现象: 子应用用 Vite 构建, 接入后直接报错或白屏, 生命周期拿不到。
根因: qiankun 的 import-html-entry 无法解析执行 Vite 的原生 ESM (<script type="module">)。
补丁: 使用 vite-plugin-qiankun 插件:
// vite.config.js
import qiankun from 'vite-plugin-qiankun'
export default {
base: '/sub-vite/',
plugins: [vue(), qiankun('sub-vite', { useDevMode: true })],
server: { headers: { 'Access-Control-Allow-Origin': '*' } }
}// main.js 用插件封装的生命周期
import {
renderWithQiankun,
qiankunWindow
} from 'vite-plugin-qiankun/dist/helper'
renderWithQiankun({
mount(props) {
render(props)
},
bootstrap() {},
unmount() {}
})
if (!qiankunWindow.__POWERED_BY_QIANKUN__) render()Vite 模式的代价
Vite 模式下 qiankun 的 JS 沙箱基本失效 (ESM 无法被沙箱包裹), 隔离能力打折。如果项目对 Vite 强依赖且要求隔离, 更该考虑 micro-app 或 wujie。
坑 7: 公共依赖重复加载, 体积膨胀
现象: 基座和每个子应用各打一份 Vue / React, 首屏加载好几 MB。
根因: 每个应用独立打包, 公共库没有共享。
补丁: 两条路。一是 externals + CDN, 把公共库外置, 但要求基座和所有子应用版本严格一致; 二是引入 Module Federation 做依赖共享, 让 qiankun 管隔离和路由、MF 管共享。
坑 8: 主子应用通信混乱
现象: 到处往 window 挂全局变量传数据, 难以维护、易冲突。
补丁: 用 qiankun 官方的 initGlobalState, 走发布订阅:
// 基座
import { initGlobalState } from 'qiankun'
const actions = initGlobalState({ user: null })
actions.onGlobalStateChange((state, prev) =>
console.log('变更', state, prev)
)
actions.setGlobalState({ user: 'atom' })
// 子应用通过 props 拿到 actions
export async function mount(props) {
props.onGlobalStateChange((state) => {
/* 响应全局态变化 */
})
render(props)
}坑点排查口诀
资源 404 查 public-path; 样式串了查隔离开关和弹窗挂载点; Vite 报错查 vite-plugin-qiankun; 路由 404 查 base 与 activeRule 是否对齐; 卸载不干净查 unmount 里的手动清理。
八、single-spa 接入实战
qiankun 是 single-spa 的封装, 想真正理解微前端, 值得亲手接一遍最原始的 single-spa。它只做路由编排与生命周期调度, 隔离、加载、通信都交给你, 因此接入更"手工", 但也最能看清底层。
8.1 官方架构: 三个角色
single-spa 官方把应用分成三部分: root-config(基座, 渲染 HTML 并注册应用)、application(可被 bootstrap/mount/unmount 的子应用)、parcel(可复用的 UI 片段)。官方推荐用浏览器原生 ES modules 加 import map(或 SystemJS 兜底)来加载各子应用, 从而实现独立部署。
8.2 基座: registerApplication + import map
基座核心是一张 import map(把模块名映射到 URL)和一组 registerApplication:
<!-- 基座 index.html: 声明子应用模块地址 -->
<script type="systemjs-importmap">
{
"imports": {
"@org/sub-vue": "/single-spa/sub-vue/js-app.js",
"@org/sub-react": "/single-spa/sub-react/js-app.js"
}
}
</script>
<script src="https://cdn.jsdelivr.net/npm/systemjs@6.15.1/dist/system.min.js"></script>// root-config.js
import { registerApplication, start } from 'single-spa'
registerApplication({
name: '@org/sub-vue',
app: () => System.import('@org/sub-vue'),
activeWhen: (location) => location.pathname.startsWith('/vue')
})
registerApplication({
name: '@org/sub-react',
app: () => System.import('@org/sub-react'),
activeWhen: '/react'
})
// 必须调用 start(), 否则应用只 load 不 mount
start({ urlRerouteOnly: true }) // Vue 3 路由要求开启官方注意点
start() 与 registerApplication 分开, 是为了让你先注册(触发预加载)、等首屏数据就绪再 start。Vue 3 的 vue-router 要求 root-config 里 urlRerouteOnly: true(single-spa 5 及以下默认 false), 否则路由会异常。
8.3 子应用: 官方辅助库包装生命周期
子应用要导出 bootstrap / mount / unmount。官方提供 single-spa-vue / single-spa-react 把框架实例包装成这三个钩子:
// Vue 3 子应用 main.js
import { h, createApp } from 'vue'
import singleSpaVue from 'single-spa-vue'
import App from './App.vue'
const vueLifecycles = singleSpaVue({
createApp,
appOptions: { render: () => h(App) }
})
export const { bootstrap, mount, unmount } = vueLifecycles// React 18 子应用 main.js
import React from 'react'
import ReactDOMClient from 'react-dom/client'
import singleSpaReact from 'single-spa-react'
import Root from './root.component'
export const { bootstrap, mount, unmount } = singleSpaReact({
React,
ReactDOMClient, // React 18 用 react-dom/client
rootComponent: Root
})打包时, 子应用要编译成 SystemJS 格式(webpack output.libraryTarget: 'system', 或 Rollup format: 'system'), 这样 publicPath 由 SystemJS 运行时解析, 天然可移植。
8.4 通信: 官方只给 props
这是 single-spa 和 qiankun 最大的区别: single-spa 核心不内置任何全局状态或事件总线 API。官方机制只有注册时的 customProps 和生命周期 props(里面带 name、mountParcel、singleSpa)。跨应用共享状态是"官方推荐用 import map 共享一个工具模块", 至于用 RxJS 还是事件总线, 属于社区自由发挥。
别把社区方案当官方 API
网上很多 single-spa 通信教程用了各种 store, 那些都不是 single-spa 提供的。官方能保证的只有 props 下发。选型时若通信需求重, qiankun 的 initGlobalState 会省心很多。
8.5 深入原理: 生命周期状态机与 reroute
会用 registerApplication 只是入门, 真正读懂 single-spa 要看它的两个核心: 一台状态机 + 一个调度中枢。这也是排查"子应用为什么没挂载 / 挂了又消失 / 坏了之后再也不加载"的钥匙。
每个应用是一台状态机。 single-spa 内部给每个应用维护一个 status, 共 12 个状态:
NOT_LOADED → LOADING_SOURCE_CODE → NOT_BOOTSTRAPPED → BOOTSTRAPPING
→ NOT_MOUNTED → MOUNTING → MOUNTED → UNMOUNTING → NOT_MOUNTED …
另有 UPDATING(仅 parcel)、UNLOADING、LOAD_ERROR、SKIP_BECAUSE_BROKEN状态流转不是一张显式的表, 而是"进入生命周期函数即写中间态, 成功再写终态"的模式, 且每个函数开头都校验前置状态、不符就幂等返回。几个精通级细节:
SKIP_BECAUSE_BROKEN是不可逆的死状态。 一旦应用因activeWhen抛错、生命周期 reject、或 mount 失败后 unmount 也失败而进入这个状态, 调度器的分桶逻辑里没有它的 case, 于是永久忽略、再也不加载。这就是"某个微应用坏了之后整块区域再也起不来"的根因——去查它是不是在某次 mount 里抛了错。- mount 失败会先"假装挂载成功"再清理。 源码在 mount reject 时, 先把状态临时设回
MOUNTED、调 unmount 尝试清干净, 再落SKIP_BECAUSE_BROKEN——这是防止半挂载 DOM 泄漏的精巧设计。 unloadApplication卸载后会重新走 load。 unload 把应用的bootstrap/mount/unmount函数引用删掉、状态回到NOT_LOADED, 下次激活时重新下载代码。这与 unmount(只是回到NOT_MOUNTED, 代码还在)是两码事。
reroute 是唯一的调度中枢。 每次路由变化, single-spa 调用 reroute(), 它按"当前状态 + 是否应该激活"把所有应用分成四桶: appsToLoad、appsToMount、appsToUnmount、appsToUnload, 然后编排:
- 卸载与加载并发, 但挂载必须等所有卸载完成(
mount等unmountAllPromise)——保证同一时刻旧应用的 DOM 已清干净, 新应用才进场, 避免闪烁与串味。 - 双重
shouldBeActive检查: 加载慢时用户可能已经切走, 所以 bootstrap 前查一次、mount 前再查一次, 绝不挂载一个"已经不该激活"的应用。 LOAD_ERROR有 200ms 重试窗口: 加载失败的应用不是永久放弃, 200ms 后若仍应激活会重新进appsToLoad, 给网络抖动一个自愈机会。
路由是怎么被劫持的? single-spa 包裹了 history.pushState/replaceState(因为这两者原生不触发 popstate, 它手动派发一个人造 popstate 事件), 并 monkey-patch window.addEventListener: 把应用注册的 popstate/hashchange 监听器扣留起来, 等自己完成 mount/unmount 之后再重放——保证应用感知路由变化的时机, 晚于 DOM 的切换。这解释了 urlRerouteOnly: true 的意义: 它让"URL 没真正变化时不触发 reroute", 是个减少无谓调度的性能开关(Vue 3 路由必须开)。
名词: 为什么 start() 不调用就白屏
start() 内部只做一件事: 把 started 标志置真, 然后触发一次 reroute。而 reroute 里, 未 start 时应用只会被推进到 NOT_BOOTSTRAPPED(下载了代码、校验了生命周期), 永远不会 bootstrap/mount。所以"注册了应用但页面空白"几乎总是忘了调 start()——registerApplication 甚至有个 5 秒定时器, 到点还没 start 就 console.warn 提醒你。
Parcel: 跨框架共享 UI 的官方机制。 application 由路由自动挂载, 而 parcel 是手动挂载的 UI 片段(mountParcel(config, { domElement })), 复用同一套生命周期但不受路由驱动。典型场景: React 应用导出一个"新建联系人"弹窗 parcel, Vue 应用用 mountParcel 挂载它——跨框架复用组件而无需两边同技术栈。用 mountParcel(从生命周期 props 拿)挂的 parcel, 父应用卸载时会自动卸载; 用 mountRootParcel(全局具名导出)挂的则要手动卸载。
超时不等于报错。 single-spa 给每个生命周期配了超时(bootstrap 4000ms、mount/unmount 3000ms), 但默认 dieOnTimeout: false——超时只打印 error、不 reject、继续等。所以"子应用加载很慢但最终还是出来了、控制台一堆超时 warning"是正常现象; 只有显式设 dieOnTimeout: true 才会真正判死、落入 broken。
8.6 手写一个极简 single-spa
把上面的机制浓缩成代码, 六十行就能还原 single-spa 的骨架。能写出这个, 才算真正"精通"它——你会发现所谓微前端框架, 内核不过是"注册表 + 路由劫持 + 状态机调度"三件事。
const apps = [] // 注册表
let started = false
export function registerApplication({ name, app, activeWhen }) {
apps.push({ name, load: app, activeWhen, status: 'NOT_LOADED', instance: null })
}
export function start() {
started = true
reroute()
}
// 路由劫持:重写 pushState/replaceState + 监听 popstate
;['pushState', 'replaceState'].forEach((fn) => {
const raw = history[fn]
history[fn] = function (...args) {
const url = location.href
raw.apply(this, args)
if (location.href !== url) reroute() // URL 真变了才调度(urlRerouteOnly 的思想)
}
})
window.addEventListener('popstate', reroute)
async function reroute() {
if (!started) return // 未 start 只注册不挂载
const shouldActive = (a) => a.activeWhen(location)
// 分桶:该卸载的、该挂载的
const toUnmount = apps.filter((a) => a.status === 'MOUNTED' && !shouldActive(a))
const toMount = apps.filter((a) => a.status !== 'MOUNTED' && shouldActive(a))
await Promise.all(toUnmount.map(async (a) => {
await a.instance.unmount({ name: a.name })
a.status = 'NOT_MOUNTED'
}))
await Promise.all(toMount.map(async (a) => {
try {
if (a.status === 'NOT_LOADED') {
a.instance = await a.load() // 动态加载子应用模块
await a.instance.bootstrap({ name: a.name })
a.status = 'NOT_BOOTSTRAPPED'
}
if (!shouldActive(a)) return // 加载慢?挂载前再查一次(防挂载已切走的应用)
await a.instance.mount({ name: a.name })
a.status = 'MOUNTED'
} catch (e) {
a.status = 'SKIP_BECAUSE_BROKEN' // 出错即拉黑, 不再参与调度
console.error(`${a.name} 挂载失败`, e)
}
}))
}对照官方源码, 这个极简版省略了 12 状态的完整流转、超时管理、事件监听器扣留重放、parcel, 但三个精髓都在: started 标志控制"只加载还是也挂载"、reroute 的分桶调度、以及"挂载前二次检查 + 出错拉黑"。真实的 single-spa 只是把每一步做得更健壮而已。
手写内核可交互验证
上面的极简实现已对齐官方源码核心——12 状态常量、四桶调度、appChangeUnderway 并发护栏——并做成了真实可运行的样例:
- 在线验证: https://stoicatom.github.io/demos/micro-frontends/from-scratch/
- 源码: https://github.com/lorainwings/demos/tree/master/micro-frontends/from-scratch
点击"切到 /vue / /react / /(都不激活)"按钮, 在日志里观察状态机从 NOT_LOADED → BOOTSTRAPPING → NOT_MOUNTED → MOUNTING → MOUNTED → UNMOUNTING 的完整流转。
运行时效果
- 在线预览: https://stoicatom.github.io/demos/micro-frontends/single-spa/
- 源码: https://github.com/lorainwings/demos/tree/master/micro-frontends/single-spa
样例基座用 webpack 5 + import map, Vue 与 React 子应用均编译成 System.register 格式, 演示 single-spa 最原始的路由编排。
8.7 single-spa 坑点排查
single-spa 手动挡的代价, 是坑集中在"加载格式、模块解析、生命周期导出、挂载容器"这条链上。以下报错串均来自官方源码与 SystemJS 官方错误文档, 遇到可直接对号入座。
坑 1: 子应用打包格式不对, 加载后"什么都没发生"
现象: bundle 能下载, 页面却无渲染。控制台报 Unable to load module(SystemJS Error #3)或 Module did not instantiate(Error #2, 触发条件是"模块被下载并执行了, 但没调用 System.register()")。
根因: single-spa 推荐用 SystemJS 加载, 要求子应用编译成 System.register 格式。若 webpack 仍输出默认 UMD/ESM, SystemJS 拿到的模块不会调用 System.register(), 于是下载成功但没注册, 拿不到生命周期。
补丁: webpack 设 output.libraryTarget: 'system'(rollup 对应 format: 'system'); 不要设 output.library(SystemJS 不需要名字); 共享依赖用 externals 标记。
// webpack.config.js
output: { libraryTarget: 'system' }, // 关键
externals: [/^@org-name\/.+/], // 共享依赖不打进 bundle坑 2: import map 模块名对不上, bare specifier 无法解析
现象: Unable to resolve bare specifier(SystemJS Error #8); 相关还有 Import Map contains invalid JSON(#1)、Failed to fetch module(#7)。
根因: registerApplication 引用的模块名(如 @org/app1)是裸标识符。SystemJS 无法自行把裸标识符解析成 URL, 名字未在 import map 声明或大小写不一致就解析失败。
补丁: 在 root HTML 用 <script type="systemjs-importmap">(type 必须是 systemjs-importmap)声明, key 与 registerApplication 的模块名逐字一致:
<script type="systemjs-importmap">
{ "imports": { "@org/app1": "//localhost:8081/app1.js" } }
</script>坑 3: 子应用未正确导出生命周期函数
现象: 控制台出现带 died in status 前缀的报错, 应用进入 SKIP_BECAUSE_BROKEN。可搜到的精确串: does not export a valid bootstrap function or array of functions、does not export a mount function...、does not export a unmount function...; 生产环境压缩为 single-spa minified message #${code} 并附 single-spa.js.org/error/?code=${code}。
根因: 每个子应用必须导出 bootstrap/mount/unmount(函数或函数数组), 缺任一项, load.js 的 validLifecycleFn 校验失败即标记 broken。常见诱因就是打包格式问题(坑 1)导致拿不到导出。
补丁: 用官方辅助库统一产出三件套: export const { bootstrap, mount, unmount } = singleSpaReact({ /* ... */ })。
坑 4: single-spa-vue / single-spa-react 挂载后白屏, 或渲染到页面底部
现象: 生命周期跑通、无报错, 但页面空白; 或内容渲染到非预期位置(页面底部凭空多出一个 div)。
根因: 挂载容器没配对。不指定容器时, 辅助库会默认创建一个 div 追加到 document.body——这就是"内容跑到页面底部"的来源。
补丁: React 用 domElementGetter 返回已存在的容器; Vue 通过 appOptions.el 指定(它透传给 Vue 的挂载选项, 与 single-spa 层面的 domElementGetter 不是一回事, 别混):
// React
singleSpaReact({ /* ... */, domElementGetter: () => document.getElementById('app1-container') })
// Vue
singleSpaVue({ /* ... */, appOptions: { el: '#app1-container' } })坑 5: 跨域(CORS)加载子应用失败
现象: root config 从另一域名/端口加载 bundle 时抛 blocked by CORS policy: No 'Access-Control-Allow-Origin' header, 脚本加载失败。
根因: 微前端天然从不同 origin 加载 JS, 本地各子应用跑不同端口, dev-server 未放开跨域头就被拦。
补丁: 子应用 dev-server 放开跨域头: devServer: { headers: { 'Access-Control-Allow-Origin': '*' } }。生产环境同样需要服务器/CDN 返回 CORS 头(通用 Web 行为)。
single-spa 没有官方通信
single-spa 没有内置 EventBus / 消息 API, 它的哲学是"微前端应解耦、不应频繁通信"。官方推荐靠 props 下发、utility modules(直接 export/import 的共享模块)、或少量 UI 状态用 window.dispatchEvent + CustomEvent。若两个应用需要频繁传状态, 官方的建议是——考虑把它们合并。另: urlRerouteOnly 是 start(opts) 的选项(当前默认 true), Vue3 的 router 需要它为 true 才能正常工作。
九、Module Federation 接入实战
Module Federation(模块联邦, MF)和前面几个不是一个维度: 它不是运行时的应用编排, 而是构建时的模块共享机制。它解决的是"多团队如何共享代码与依赖", 天生擅长依赖去重。
9.1 先厘清版本: MF 2.0 才是当下主流
这是最容易踩的认知坑, 务必分清:
- webpack 5 内置的
ModuleFederationPlugin是 MF 1.0,webpack.js.org上那篇文档讲的就是它; - MF 2.0 是
@module-federation/enhanced(2026 年主流, 支持 webpack 与 Rspack), 在 1.0 之上加了运行时 API、类型提示、Manifest、Runtime Plugin, 权威文档在module-federation.io。
一句话: 新项目用 @module-federation/enhanced, 别再直接用 webpack.container.ModuleFederationPlugin。
9.2 remote(暴露方)
// rspack.config.js —— remote 暴露模块
const { ModuleFederationPlugin } = require('@module-federation/enhanced/rspack')
new ModuleFederationPlugin({
name: 'remote_react',
filename: 'remoteEntry.js',
exposes: { './App': './src/App.jsx' }, // 暴露组件
shared: {
react: { singleton: true, requiredVersion: deps.react },
'react-dom': { singleton: true, requiredVersion: deps['react-dom'] }
}
})构建产物除了 remoteEntry.js, 还有 mf-manifest.json —— MF 2.0 推荐用它作为 entry, 比裸 remoteEntry.js 多出类型提示、资源预加载和 DevTool 支持。
9.3 host(消费方): 运行时注册最可移植
传统写法把 remote 地址写死在配置里(remotes: { app: 'app@http://xxx/remoteEntry.js' }), 换域名就废。MF 2.0 推荐用运行时 API 动态注册, 地址在运行时按当前环境推导:
import { registerRemotes, loadRemote } from '@module-federation/enhanced/runtime'
// 运行时按当前部署位置推导 remote 地址, 零硬编码
registerRemotes([
{ name: 'remote_react', entry: `${base}remote-react/mf-manifest.json` }
])
// React.lazy + loadRemote 动态加载
const RemoteApp = React.lazy(() => loadRemote('remote_react/App'))
// <Suspense fallback="加载中"><RemoteApp /></Suspense>9.4 shared: 单例是纪律
shared 是 MF 的灵魂。React、Vue 这类有内部状态的库必须 singleton: true, 否则 host 和 remote 各加载一份, 会因多实例报错(React 会直接 Hooks 崩溃)。requiredVersion 做版本校验, 版本不匹配时告警并回退。
MF 与 qiankun 不是二选一
MF 不提供 JS 沙箱和样式隔离, 所有模块共享同一运行时。实践中常见组合是: qiankun 管隔离与路由, MF 管依赖共享, 两者互补。
9.5 深入原理: shareScope 协商与 container 运行时
MF 的魔法都在两个运行时机制里: 一个共享作用域的版本协商 + 一个容器的 init/get 握手。看懂它们, 才能治好"两个 React"、"eager consumption 报错"这类顽疾。
shareScope: 依赖协商的中央登记处。 MF 在全局维护一张四层嵌套的表(shareScopeMap[scope][包名][版本] = { get, from, loaded, shareConfig }), webpack 里对应 __webpack_share_scopes__.default。所有 host 与 remote 的 shared 依赖都登记在这里, 加载某个 shared 时先来这查"该用哪个版本的实例"。singleton 的协商逻辑是精通级关键:
- singleton 下, 无论版本是否满足
requiredVersion, 永远返回选中的那个实例(默认按 semver 选最高版本)。版本不匹配时, 默认strictVersion: false只在控制台 warn 不中断; 设strictVersion: true才error。这就是"版本对不上但页面照跑、控制台一堆Version X does not satisfy...警告"的来源——那些 warning 正是协商在提示你版本有风险。 - 选版本的策略可配:
version-first(默认, 纯选最高)vsloaded-first(优先已加载的版本, 避免重复实例化, SSR/性能敏感场景更优)。
container 的 init/get 握手。 每个 remote 的 remoteEntry.js 暴露一个 container 对象, 只有两个方法, 但顺序不能乱:
await __webpack_init_sharing__('default') // 1. 填充本地 share scope
const container = window[scope] // 2. 拿到 remote 容器
await container.init(__webpack_share_scopes__.default) // 3. 把 host 的共享作用域注入 remote
const factory = await container.get('./App') // 4. 拿到模块工厂(此时还没求值)
const Module = factory() // 5. 调用工厂才真正求值模块关键在第 3 步: init(shareScope) 把 host 已有的共享依赖注入给 remote, 让 remote 用 host 那份 React、而不是自己再实例化一份——这正是"依赖只加载一次"的实现机制。MF 内部用 inited/initing/initPromise 三个标志防止重复初始化和自加载死循环。
mf-manifest.json 为什么比 remoteEntry.js 强。 remoteEntry.js 是个黑盒, 必须执行才知道里面暴露了什么; 而 mf-manifest.json 是静态 JSON, 让工具链不执行代码就能读到 exposes 列表(→ 支撑 IDE 类型提示)、shared 的版本与 assets(→ 支撑 preloadRemote 资源预加载)、以及调试元信息。这是 MF 2.0 相对 1.0 的核心增量, 也是推荐用 manifest 作 entry 的原因。
几个根因级深坑:
- "两个 React"/Hooks 报错: 十有八九是没设
singleton: true, 于是 host 和 remote 各拿一份满足requiredVersion的副本; 或者两边shareScope名字不一致, 根本没在同一张表里协商。 Shared module is not available for eager consumption: 入口同步引用了 shared, 但填充 share scope 的__webpack_init_sharing__是异步的、还没跑完。标准修法是加一层异步边界import('./bootstrap')(本文样例的 host 入口就是这么做的), 而不是无脑给 shared 设eager: true(那会导致它总是被下载, 增大入口体积)。- SSR 场景退化: MF 在服务端会退化成单体(独立部署优势在 server 侧消失), 且
__webpack_share_scopes__是全局变量, 多请求并发要防串; server bundle 不会 tree-shakeprocess.browser分支, 靠if(process.browser)藏敏感信息的做法会失效。
9.6 典型反模式: MF 不是万能钥匙
Module Federation 是把好锤子, 但不是所有问题都是钉子。真正精通它, 要知道哪些场景不该用它——这比学 API 更重要。
反模式一: 把 MF 当微前端沙箱用。 MF 的设计目标是依赖共享("多个应用共用一份 React"), 而不是隔离。所有 remote 共享同一个 window、同一套 DOM、同一张 __webpack_share_scopes__ 全局表, 一个 remote 的全局污染会直接影响其他 remote。需要沙箱隔离的场景(多团队各自技术栈、CSS 要隔离、全局状态要互不干扰)应该选 qiankun/wujie/micro-app, 而不是强行用 MF + Shadow DOM 拼凑——MF 的共享机制与沙箱的隔离诉求天然矛盾。
反模式二: 过度细粒度 expose。 看到"按需加载"就把每个组件都 expose 一遍(./Button、./Input、./Select...)。实际问题:
- 每次
loadRemote都要等网络(跨页组件引用会导致几十次往返), 延迟远超 code splitting。 - 共享依赖的版本协商开销随 expose 数量线性增长, 启动变慢。
- expose 越多、remote chunk 切分越碎, HTTP/2 优势也被抵消。
正确做法: 按业务域聚合 expose(如 ./widgets 暴露一个 barrel 模块, 里面统一导出十几个组件), 或干脆只 expose 一个 ./mount 函数当子应用入口——MF 的甜点是"中等粒度的模块共享", 不是 npm 那种细粒度包管理。
反模式三: 把 remoteEntry 路径硬编码到源码。 写死 remote: { app1: 'https://cdn.example.com/remoteEntry.js' } 后, 要换域名/回滚版本都得重新构建 host——这违背了"独立部署"的初衷。应该用 mf-manifest 动态配置(见 §9.5)或环境变量注入, 让运行时按 REACT_APP_REMOTES_CONFIG 加载最新映射, host 一次构建到处跑。
反模式四: 用 MF 做跨框架混用时忽略框架上下文隔离。 虽然 MF 能 expose React 组件给 Vue host 用, 但二者共享同一个 window——React 的 <StrictMode> 全局副作用、Vue 的 devtools 注入都会相互干扰。跨框架联邦的正确姿势是 expose 框架无关的挂载函数(如 export function mount(el) { createApp(App).mount(el) }), 而不是直接 expose Vue 组件让 React 去渲染——后者既绕不开框架上下文冲突, 也失去了 MF"共享依赖"的优势(两个框架的运行时不可能共享)。
反模式五: 把单体应用"强行拆成 MF"以示微前端。 一个原本就是单体、团队也没分离、部署也没独立的项目, 为了"微前端"而硬拆成 host + 几个 remote, 结果:
- 没有独立部署(还是一起发布), MF 的优势体现不出来。
- 增加了构建配置复杂度、多了 remote 加载的网络开销、共享协商的启动延迟。
- 版本管理成本反增(shared 版本不一致时 fallback 走哪个版本?)。
MF 的价值在于已经有独立团队、独立仓库、独立发布节奏时, 用它把依赖共享起来、减少重复下载。没有这个前提, 用 code splitting 就够了, 别为了微前端而微前端。
名词: 为什么说 MF 是"构建时"方案
MF 的 exposes/shared/remotes 是 webpack/Rspack 插件在构建期织入的: 造容器、把 remote 编译成"异步 external"、按 requiredVersion 切分 chunk。虽然 MF 2.0 有了运行时能力(registerRemotes 动态注册、指向 manifest), 但共享边界、expose 列表、chunk 切分仍在构建期定死——这是它与 single-spa(纯运行时编排、加载器无关)的根本分野。也正因为它的目标是"共享"(让多个 build 共用一份 React、一个 window、一张全局 share scope), 它天然不做隔离——隔离要交给沙箱或 Shadow DOM, 二者正交。
运行时效果
- 在线预览: https://stoicatom.github.io/demos/micro-frontends/module-federation/host/
- 源码: https://github.com/lorainwings/demos/tree/master/micro-frontends/module-federation
样例用 Rspack + @module-federation/enhanced 2.8.0, React host 运行时加载 Vue 与 React 两个 remote; 其中 Vue remote 暴露一个框架无关的 mount 函数, 演示跨框架联邦。
9.7 Module Federation 坑点排查
MF 的坑不在"隔离", 而在"共享协商"这套异步机制, 以及 1.0 与 2.0 的混淆。以下报错串已按版本严格区分——写错版本会误导排查方向。
坑 1: Shared module is not available for eager consumption
现象: 白屏 + Uncaught Error: Shared module is not available for eager consumption: webpack/sharing/consume/default/react/react。
根因: 共享模块协商是异步的(要先异步比对 share scope 里的版本)。若入口同步 import 了共享依赖, 在 share scope 就绪前就同步消费, 运行时工厂函数还不是 function, 抛错。
补丁: 官方首选"异步边界"——入口 index.js 只留一行 import('./bootstrap'), 把初始化逻辑全搬进 bootstrap.js, host 和 remote 两边都要拆:
// index.js —— 只做异步边界
import('./bootstrap')
// bootstrap.js —— 原来的初始化逻辑搬来这里
import { createApp } from 'vue'
// ...次选给共享依赖加 eager: true(会下载所有 provided 模块, 官方建议只在壳应用一处用)。
坑 2: shared singleton 版本不一致(注意 1.0 与 2.0 报错串不同)
现象:
- webpack 原生 MF 1.0:
Unsatisfied version 1.0.0 from ... of shared singleton module ... (required ^3.1.0); - MF 2.0(
@module-federation/enhanced运行时):Version 18.0.0 from ... of shared singleton module react does not satisfy the requirement of ... which needs ^17.0.0。
根因: singleton: true 表示 share scope 内该库只留一个实例, 运行时选出胜出版本。不满足某消费方 requiredVersion 时, strictVersion: true 抛 error 阻断, false 只 warn 继续用胜出版本。requiredVersion 常来自传递依赖的 peerDependencies。
补丁: 显式声明版本; 想降级为警告放行用 strictVersion: false; 子路径导入(如 @scope/pkg/sub)会误取父包版本, 需为子路径单独指定 version:
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
}坑 3: publicPath 缺失导致 remote 资源 404 / ChunkLoadError
现象: remote 单独能跑, 被 host 集成后按需 chunk、图片、字体以 host 域名请求 → 404, 或 ChunkLoadError。MF 2.0 里 remote 生产构建缺 publicPath 会命中错误码 BUILD-002 PublicPath is required in prod mode.。
根因: webpack 用 output.publicPath 决定运行时资源基址, remote 默认 ''// 时被不同源 host 加载就错基址。
补丁: remote 配 output: { publicPath: 'auto' }, 让 webpack 运行时按当前脚本位置自动推断基址。
坑 4: 运行时动态注册远程要用 registerRemotes(MF 2.0 专属)
现象: 想在运行时(而非构建期 remotes)动态注入或覆盖远程, 沿用 MF 1.0 的 container.init() + container.get() 心智会很繁琐。
根因: MF 1.0 无官方运行时注册 API, 靠手动脚本注入 + container.init(__webpack_share_scopes__.default); MF 2.0 提供了一等公民 API。
补丁: 用 @module-federation/enhanced/runtime 的 registerRemotes(注意从 enhanced 的 /runtime 出口引, 以和构建插件共用同一实例), entry 一般指向 mf-manifest.json:
import { registerRemotes, loadRemote } from '@module-federation/enhanced/runtime'
registerRemotes([{ name: 'sub2', entry: 'http://localhost:2002/mf-manifest.json' }])
loadRemote('sub2/util').then((m) => m.add(1, 2, 3))坑 5: Vue SFC + Rspack 下模板/样式处理异常
现象: Vue SFC 在 Rspack + vue-loader 下 <template>/<style> 块的 loader 规则匹配失效。
根因: experimentalInlineMatchResource 是 vue-loader 的配置项, 不是 MF 的。vue-loader 把 SFC 每个块当虚拟模块处理, 默认靠 resourceQuery 匹配, 在 webpack 5 / Rspack 下会失效, 该选项改用 webpack 的 inline matchResource 语法匹配。
补丁:
{ test: /\.vue$/, loader: 'rspack-vue-loader', options: { experimentalInlineMatchResource: true } }按错误码排查 MF 2.0
MF 2.0 独有诊断机制(1.0 无): 报错带错误码 + 自动文档链接, 如 Failed to locate remote. #RUNTIME-004。常见 RUNTIME-001 Failed to get remoteEntry exports、RUNTIME-003 Failed to get manifest、RUNTIME-004 Failed to locate remote、RUNTIME-008 Failed to load script resources。按错误码定位比按文案搜索更可靠——001/003/008 查 remote 地址/CORS/publicPath(坑 3), 004 查 remotes 或 registerRemotes(坑 4)是否注册。
十、wujie 接入实战
wujie(无界, 腾讯)的思路: 用 iframe 做 JS 沙箱, 用 WebComponent 做渲染容器, 兼顾 iframe 的彻底隔离与正常的页面布局。
10.1 主应用: 组件式加载
Vue 主应用用 wujie-vue3 提供的 <WujieVue> 组件:
import WujieVue from 'wujie-vue3'
app.use(WujieVue)<WujieVue
width="100%" height="500px"
name="sub-vue"
:url="subAppUrl"
:sync="false"
:alive="true"
/>也可以用框架无关的 startApp({ name, url, el, alive }) API。
10.2 子应用: 保活模式几乎零改造
wujie 的杀手锏是子应用可以不改造。官方按运行模式区分:
- 保活模式(
alive: true)/ 重建模式: 子应用无需任何生命周期改造, 只要满足跨域(CORS)即可, 就是个普通应用; - 单例模式: 才需要把渲染挂到
window.__WUJIE_MOUNT、销毁挂到window.__WUJIE_UNMOUNT。
// 单例模式才需要;保活模式跳过这段
if (window.__POWERED_BY_WUJIE__) {
window.__WUJIE_MOUNT = () => { app = createApp(App); app.mount('#app') }
window.__WUJIE_UNMOUNT = () => { app.unmount() }
} else {
createApp(App).mount('#app')
}10.3 隔离与保活
wujie 的 JS 隔离是 iframe 原生能力(子应用 JS 跑在同域空 iframe 里), 样式隔离用 ShadowDOM, 都是架构级默认行为, 没有开关。alive: true 开启保活, 切换子应用时实例常驻内存、状态不丢。老浏览器可用 degrade: true 降级为 iframe 承载 DOM。
10.4 通信: bus 事件总线
官方给三种: props 注入、window(iframe 与主应用同域)、以及推荐的去中心化 bus:
// 主应用
import { bus } from 'wujie'
bus.$emit('main-message', payload)
// 子应用
window.$wujie?.bus.$on('main-message', (data) => { /* ... */ })Vite 子应用两个坑
一是子应用里 window.location.host 要改用 window.$wujie.location.host(Vite 的 type=module 无法被闭包代理); 二是异步加载时需在定义生命周期后主动调 window.__WUJIE.mount()。
10.5 深入原理: 双容器如何"物理分离"JS 与 DOM
wujie 最精妙的设计, 是把一个子应用拆成两个物理容器: 一个 iframe 只跑 JS(拿它独立的 window realm 做沙箱), 一个 <wujie-app> 自定义元素的 ShadowRoot 只做渲染(拿它做 CSS 隔离)。子应用的 JS 在 iframe 里执行, 但它操作的 DOM 被代理到主文档的 ShadowRoot 里——子应用对此毫无感知。
proxyDocument 是连接两个容器的桥。 wujie 用 Object.defineProperty 把 iframe 里 Document.prototype 上的方法劫持到一个 proxyDocument 上: 子应用调 document.querySelector('.x') 时, 先在 ShadowRoot 里查、查不到再兜底到 iframe document; document.body、document.head 全部指向 ShadowRoot 的对应节点; document.createElement 用 iframe 的原生方法建元素、再打补丁。于是"JS 在 iframe、DOM 在 ShadowRoot"这两个割裂的世界被缝合成一个子应用眼中的普通 document。
iframe 为什么天然隔离 window——而且几乎零性能损耗。 这是 wujie 相对 qiankun 的关键优势: iframe 有浏览器给的独立 window/document/history/location realm, 子应用 JS 直接在这个真实 iframe window 上跑, 不需要 qiankun 那样用 with(proxyWindow){} 包裹, 因此运行时接近原生性能。代价只是 iframe 实例化的一次性开销, 用 preloadApp 提前创建就能摊平。
名词: srcdoc 空白页 + document.open() 的同源魔法
很多旧文章说 wujie 的 iframe "src 指向主应用域名"——这已过时。现行源码用 srcdoc 加载一个空白 HTML(按规范, srcdoc 的 origin 继承自主应用, 天然同源, 且不发网络请求), 然后在 iframe load 后于主应用上下文调用 document.open()。按 HTML 规范, document.open() 会同步把当前 document 的 URL 改写成调用方(主应用)的 URL, 从而让子应用 iframe 的 location.origin、history、路由都与主应用一致——既拿到了同源便利, 又零网络请求。这个演进(解决 issue #54)值得知道, 因为它决定了子应用路由为何能和主应用地址栏联动。
动态脚本会被"转接"回 iframe 执行。 子应用运行时 head.appendChild(script) 会被拦截: 脚本不真的插进 ShadowRoot, 而是送回 iframe 执行(ShadowRoot 里只留一个 <!--dynamic script replaced by wujie--> 注释占位); 动态 style 则劫持它的 textContent/insertRule(Vite HMR 直接改 style.textContent, 必须全链路劫持才追踪得到)。这解释了为什么 wujie 能兼容各种运行时注入样式/脚本的场景。
保活 vs 重建, 是内存换体验的取舍。 <wujie-app> 的 disconnectedCallback 触发时按模式三分:
- 保活(
alive: true): 只unmount, iframe 和 ShadowRoot 从不销毁; 再进入时直接把整个 ShadowRoot 宿主挪回新容器, 状态、DOM、滚动位置全部保留(类似 Vue 的 keep-alive)。代价是每个保活子应用独占一个 iframe + ShadowRoot 常驻内存。 - 重建: 直接
destroy, 全新创建, 有白屏。适合不常访问、不介意重来的子应用。
fiber 模式: 用空闲时间片执行脚本。 fiber: true 时, 每段子应用脚本的执行都包进 requestIdleCallback, 让长任务在浏览器空闲片里分批跑, 不阻塞主应用的渲染与交互——这对首屏有大量脚本的子应用尤其重要。
现在回头看那个"Vite 子应用 location 要用 $wujie.location"的坑就有根因了: 普通脚本 wujie 会用 (function(window, location){...})(proxy, proxyLocation) 的闭包注入代理 location, 但 ESM(type=module)有独立词法作用域, 无法被包进普通函数字符串, 所以 module 脚本拿到的是 iframe 真实 location(即主应用 host)。这不是 bug, 是 ESM 语言特性决定的, 只能靠 window.$wujie.location 显式绕过。
10.6 手写一个极简 wujie
wujie 的核心是"iframe 跑 JS + ShadowRoot 渲染 DOM + proxyDocument 连接两者"。下面用 70 行还原这个双容器架构——能写出这个, 你就理解了 wujie 为什么能"隔离得像 iframe、体验又像单页"。
class MiniWujie {
constructor(name, url) {
this.name = name
this.url = url
// 1. 创建 iframe(JS 沙箱)
this.iframe = document.createElement('iframe')
this.iframe.style.display = 'none'
document.body.appendChild(this.iframe)
this.iframeWindow = this.iframe.contentWindow
// 2. 创建 <wujie-app> 自定义元素的 ShadowRoot(CSS/DOM 沙箱)
this.shadowHost = document.createElement('wujie-app')
this.shadowHost.setAttribute('name', name)
this.shadowRoot = this.shadowHost.attachShadow({ mode: 'open' })
// 3. 生成 proxyDocument, 连接 iframe 与 shadowRoot
this.proxyDocument = this.createProxyDocument()
// 4. 劫持 iframe 的 Document.prototype, 让子应用的 document.xxx 走 proxyDocument
this.patchIframeDocument()
}
createProxyDocument() {
const { shadowRoot, iframeWindow } = this
return new Proxy(
{},
{
get(_, key) {
// document.querySelector 系列 → 优先在 shadowRoot 查, 查不到兜底到 iframe document
if (key === 'querySelector')
return (sel) => shadowRoot.querySelector(sel) || iframeWindow.document.querySelector(sel)
if (key === 'querySelectorAll')
return (sel) => shadowRoot.querySelectorAll(sel)
// document.body/head/documentElement → 指向 shadowRoot 的对应节点
if (key === 'body') return shadowRoot.querySelector('wujie-body') || shadowRoot.body
if (key === 'head') return shadowRoot.querySelector('wujie-head') || shadowRoot.head
// document.createElement → 用 iframe 原生方法(保持正确 ownerDocument)
if (key === 'createElement') return iframeWindow.document.createElement.bind(iframeWindow.document)
return iframeWindow.document[key] // 其余穿透到 iframe document
},
}
)
}
patchIframeDocument() {
const iframeDocument = this.iframeWindow.Document.prototype
const proxyDocument = this.proxyDocument
;['querySelector', 'querySelectorAll', 'body', 'head', 'createElement'].forEach((key) => {
Object.defineProperty(iframeDocument, key, {
get: () => proxyDocument[key],
})
})
}
async start(container) {
// 挂载 shadowHost 到主应用容器
container.appendChild(this.shadowHost)
// 加载子应用 HTML
const html = await fetch(this.url).then((r) => r.text())
// 简化: 直接把 body 内容放进 shadowRoot(真实 wujie 会解析 script/link 做转接)
const template = document.createElement('template')
template.innerHTML = html
this.shadowRoot.appendChild(template.content.cloneNode(true))
// 子应用脚本在 iframe 里执行(这里简化为同步 eval, 真实用 script 元素或 import())
const scripts = Array.from(this.shadowRoot.querySelectorAll('script'))
scripts.forEach((s) => {
this.iframeWindow.eval(s.textContent) // 子应用 JS 在 iframe window 上下文跑
this.shadowRoot.removeChild(s) // ShadowRoot 里不保留 script
})
}
}
// 使用
const app = new MiniWujie('sub-app', 'http://localhost:8080/')
app.start(document.querySelector('#container'))对照官方源码, 这个极简版省略了 srcdoc 同源魔法、动态脚本转接、路由同步、保活、fiber、降级, 但双容器的精髓在了: iframe 给 JS 独立 realm、ShadowRoot 天然隔离 CSS、proxyDocument 的分流让子应用以为在操作普通 document。wujie 只是在每个细节都做到生产级而已。
手写内核可交互验证
上面的极简实现已对齐官方源码核心——proxyDocument 分流、patchIframeDocument 劫持 iframe Document.prototype、动态脚本转接到 iframe 执行——并做成了真实可运行的样例:
- 在线验证: https://stoicatom.github.io/demos/micro-frontends/from-scratch/
- 源码: https://github.com/lorainwings/demos/tree/master/micro-frontends/from-scratch
点击"加载子应用到双容器"后再点"验证 JS 在 iframe、DOM 在 ShadowRoot", 日志会确认 iframe.__SUB_APP_RAN__=true 而主 window 上该变量为 undefined, ShadowRoot 里能查到 #sub-root 而主文档查不到——双容器隔离一览无余。
运行时效果
- 在线预览: https://stoicatom.github.io/demos/micro-frontends/wujie/
- 源码: https://github.com/lorainwings/demos/tree/master/micro-frontends/wujie
样例用 Vite + wujie-vue3, 主应用切换加载 Vue 与 React 子应用, 演示 bus 通信与保活。
10.6 wujie 坑点排查
wujie 用 iframe 承载 JS, 坑集中在"跨域、生命周期改造、资源路径、路由同步"这几处。注意几个全局变量的确切拼写——最容易记错。
坑 1: 子应用未做 CORS, 资源与接口全部被拦
现象: Access to fetch at ... has been blocked by CORS policy: No 'Access-Control-Allow-Origin', 子应用白屏, JS/CSS 与接口全 fail。
根因: wujie 通过主应用 fetch 拉子应用 HTML, 子应用资源和接口都在主域名下发起, 必然跨域。官方原文: 子应用必须做 cors 设置。
补丁: 子应用服务端开 CORS。若资源需带 cookie(表现为 302 跳登录页), 用 startApp 的 fetch 配置设 credentials: 'include', 此时 Access-Control-Allow-Origin 不能是 *, 要回填请求来源。
坑 2: 单例模式未做生命周期改造, 切走再回白屏
现象: alive: false 且切路由时, 首次能显示, 切走再切回后白屏、实例不重建。
根因: wujie 有三种模式——保活(alive: true, 无需改造)、重建(alive: false 未改造, 有白屏)、单例(alive: false 且已改造)。想要单例却没把渲染/销毁挂到生命周期全局函数, 切回时无从触发渲染。
补丁: 按 window.__POWERED_BY_WUJIE__ 判断环境, 把"路由创建 + 实例渲染"挂到 window.__WUJIE_MOUNT、"实例销毁"挂到 window.__WUJIE_UNMOUNT:
if (window.__POWERED_BY_WUJIE__) {
let app
window.__WUJIE_MOUNT = () => { app = createApp(App); app.mount('#app') }
window.__WUJIE_UNMOUNT = () => { app.unmount() }
} else {
createApp(App).mount('#app')
}变量拼写别记错
__POWERED_BY_WUJIE__(首尾各两下划线)、__WUJIE_PUBLIC_PATH__(首尾各两下划线), 但生命周期函数是 __WUJIE_MOUNT / __WUJIE_UNMOUNT——没有尾部下划线。记混会导致框架调不到你的渲染函数, 白屏且无报错。
坑 3: Vite 子应用不渲染——缺 base + 未主动调用 window.__WUJIE.mount()
现象: Vite 子应用白屏, 或做了生命周期改造仍不渲染, 资源路径错乱。
根因: ① Vite 默认 base: '/', 资源以绝对根路径去主应用域名找 → 404; ② Vite 异步加载, wujie 用 fiber 执行, 框架调 __WUJIE_MOUNT 时它可能还没挂到 window 上。
补丁: vite.config.ts 设 base: './'、server.cors: true; 在定义完 __WUJIE_MOUNT/__WUJIE_UNMOUNT 后主动调用一次 window.__WUJIE.mount()(__WUJIE 首两下划线无尾部, 内置 flag 不用担心重复 mount)。
坑 4: 图片/资源相对路径被解析到主应用域名(webpack 子应用)
现象: 通过 v-html/innerHTML/动态 template 引入的图片、背景图相对路径解析到主应用域名 → 404/图裂。
根因: wujie 默认插件会把 CSS 相对路径转绝对, 但覆盖不了运行时注入的 v-html/innerHTML, 此时 webpack publicPath 仍指向主应用。
补丁: 子应用入口顶部引入一段 config, 当 window.__POWERED_BY_WUJIE__ 为真时设 window.__webpack_public_path__ = window.__WUJIE_PUBLIC_PATH__(__WUJIE_PUBLIC_PATH__ 首尾各两下划线)。
坑 5: 保活/重建模式路由不同步, 刷新/分享 URL 后路由丢失
现象: 保活模式改主应用 url 参数子应用路由不变; 未开 sync 时刷新/分享 URL 后子应用回首页; 直接改 window.location.href 可能报 Blocked a frame with origin ... from accessing a cross-origin frame。
根因: 保活模式子应用只渲染一次, 改 url 不重渲染; 路由默认不写入主 URL; wujie 用 iframe 劫持 location, 直接改原生 location.href 会破坏沙箱。
补丁: 开启 sync(默认 false), 子应用路由会经 encodeURIComponent 挂到主 URL 查询参数。注意官方的单向限制: 只有初次实例化时才会从 URL 读回路由, 之后只单向地把子应用路由同步到主 URL。location 操作走 window.$wujie.location, 别直接改 window.location.href。
十一、micro-app 接入实战
micro-app(京东)把子应用做成一个 HTML 标签, 是接入成本最低的方案之一。
11.1 主应用: 一个自定义元素
// 入口
import microApp from '@micro-zoe/micro-app'
microApp.start()<micro-app name="sub-vue" url="/micro-app/sub-vue/" baseroute="/sub-vue"></micro-app>Vue 3 主应用需让编译器识别这个自定义元素, 否则报错:
// vite.config.js 的 @vitejs/plugin-vue
vue({ template: { compilerOptions: { isCustomElement: (tag) => tag === 'micro-app' } } })11.2 子应用改造
micro-app 不强制导出生命周期(区别于 qiankun), 但推荐:
// 子应用: 卸载函数(卸载时自动执行)
window.unmount = () => app.unmount()// public-path.js(静态资源 404 时): micro-app 注入的两个全局变量
if (window.__MICRO_APP_ENVIRONMENT__) {
__webpack_public_path__ = window.__MICRO_APP_PUBLIC_PATH__
}子应用路由用 window.__MICRO_APP_BASE_ROUTE__ 作为 base(即标签上的 baseroute)。
Vite 子应用必须切 iframe 沙箱
micro-app 默认是 with 沙箱, 但 Vite 产物是原生 ESM(type=module), with 沙箱拦不住。官方要求 Vite 子应用在标签上加 iframe 属性切 iframe 沙箱: <micro-app ... iframe>。
11.3 隔离与通信
JS 沙箱默认是 with 沙箱(Proxy 代理), 可切 iframe 沙箱; 样式默认 scopecss(给选择器加 micro-app[name=xxx] 前缀), 可选 shadowDOM。通信用官方 EventCenter, 且主子绑定(主应用只能发给指定子应用), 避免多子应用互相污染:
// 主应用发数据给指定子应用
microApp.setData('sub-vue', { user: 'atom' })
// 子应用接收
window.microApp.addDataListener((data) => { /* ... */ })
window.microApp.dispatch({ type: 'from-sub' }) // 回传主应用11.4 深入原理: with 沙箱、scopecss 与虚拟路由
micro-app 的三个核心机制——with 沙箱、scopecss、虚拟路由——各自都有值得深挖的设计。
with 沙箱: with + Proxy 的双重作用域劫持。 micro-app 默认沙箱把子应用代码包成这样:
;(function (proxyWindow) {
with (proxyWindow.__MICRO_APP_WINDOW__) {
// 子应用代码, 裸写的全局标识符先在 fakeWindow 上找
}
})(proxyWindow)with(fakeWindow) 让代码里裸写的 xxx 优先在 fakeWindow(一个空对象 + Proxy)上解析。Proxy 的 get 是隔离关键: 子应用自定义的全局变量、location、history 隔离在 fakeWindow; 而内置对象(document、DOM 构造函数、定时器)穿透到真实 window(其中 document 再被 proxyDocument 二次代理限定到子应用容器)。unmount 时把 injectedKeys 记录的新增全局变量删掉, 完成清理。
名词: 为什么 Vite 子应用非用 iframe 沙箱不可
with 语句无法作用于 ES module: ESM 有独立词法作用域、顶层 this 为 undefined、import/export 是静态语法, 根本没法被包进一个普通函数字符串再用 with 包裹。所以子应用是 <script type="module">(Vite 产物)时, micro-app 的 isTypeModule 判定会直接跳过 with 包裹——with 沙箱对它形同虚设。解法是切 iframe 沙箱(<micro-app iframe>): iframe 提供原生独立 window realm, ESM 直接在 iframe 里跑, 靠 patch iframe 的 document/location 实现隔离。这就是官方要求"Vite 子应用必须加 iframe 属性"的底层原因, 和 wujie 遇到的是同一个 ESM 语言约束。
scopecss: 手写 CSS Parser 逐规则加前缀。 micro-app 默认不用 ShadowDOM, 而是解析子应用每一段 CSS, 给选择器加 micro-app[name=xxx] 前缀。它是个完整的 CSS Parser, 处理各种边界: @media/@supports 递归进入内部规则加前缀、@keyframes 动画名保持不变、@font-face/url() 把相对路径补全成绝对、body/html 替换成子应用的 <micro-app-body>; 还用 MutationObserver 追踪 styled-components 这类运行时注入的样式。
scopecss 是单向隔离
这是个精通级的关键区别: scopecss 只防子应用样式外泄到主应用, 挡不住主应用的全局样式渗入子应用(前缀只加在子应用规则上, 主应用的 * { box-sizing } 照样作用到子应用里)。而 ShadowDOM 是双向物理隔离。micro-app 默认选 scopecss 是权衡了兼容性(ShadowDOM 会让很多第三方组件库的弹窗出问题), 想要双向隔离可显式开 shadowDOM 属性。
虚拟路由: 子应用路由默认活在内存里。 micro-app 1.0 的大改进是虚拟路由系统——子应用的 location/history 默认是虚拟的(memory-router), 通过 router-mode 决定子应用路径如何与浏览器 URL 关联:
| mode | 子应用路径存哪 | 适用 |
|---|---|---|
search(默认) | 主应用 URL 的 query(?sub-name=/path) | 地址可分享、刷新可还原 |
state | history.state 里 | 地址栏干净 |
native / native-scope | 子应用完全接管浏览器 URL | 单页只嵌一个子应用 |
pure | 纯内存, 不同步浏览器 URL | 弹窗、嵌套子应用 |
多子应用各自的路径存在 __MICRO_APP_STATE__[appName] 里互不干扰, keep-router-state 控制 unmount 后是否保留上次路径。这套虚拟路由正是"一个页面能同时嵌多个各自带路由的子应用"的基础。
EventCenter: 微任务批处理 + 浅比较去重。 数据通信内部用发布订阅, 但有两个精巧设计: dispatch 的数据不立即触发, 而是合并进 tempData 后用微任务(defer)批处理, 且触发前做浅比较——数据没变就不触发回调(React 每次 setState 都 dispatch, 靠这个去重避免无谓渲染)。每个子应用按 appName 建独立数据通道, 这就是"主应用只能发给指定子应用"的实现: 各自寻址、互不污染。
11.5 手写一个极简 with 沙箱
micro-app 的 with 沙箱是理解"如何在共享 window 上做隔离"的最佳教材。核心就是 with + Proxy + 副作用记账三件事, 五十行还原:
class WithSandbox {
constructor() {
this.microWindow = {} // fakeWindow:子应用的"假 window"
this.injectedKeys = new Set() // 记录子应用新增的全局 key, 卸载时清理
this.proxyWindow = this.createProxy()
}
createProxy() {
const rawWindow = window
return new Proxy(this.microWindow, {
get: (target, key) => {
// 子应用自己的 key、或 __MICRO_APP_ 开头的 → 读 fakeWindow(隔离)
if (key in target || String(key).startsWith('__MICRO_APP_')) return target[key]
// 内置对象(document/定时器/DOM 构造函数)→ 穿透到真实 window
const value = rawWindow[key]
// 函数要绑定 this 到真实 window, 否则 Illegal invocation
return typeof value === 'function' ? value.bind(rawWindow) : value
},
set: (target, key, value) => {
// 写操作只落在 fakeWindow, 不污染真实 window
if (!(key in target)) this.injectedKeys.add(key) // 挂账
target[key] = value
return true
},
has: (target, key) => key in target || key in rawWindow,
})
}
// 用 with 包裹子应用代码, 让裸写的全局标识符先在 fakeWindow 上解析
exec(code) {
const wrapped = `(function(proxyWindow){
with(proxyWindow){
${code}
}
})(this.proxyWindow)`
// eslint-disable-next-line no-new-func
new Function('', wrapped).call(this)
}
// 卸载:清理子应用新增的全局变量
unmount() {
this.injectedKeys.forEach((key) => delete this.microWindow[key])
this.injectedKeys.clear()
}
}这个骨架点出了三个精髓: with(proxyWindow) 劫持标识符解析、Proxy 的 get 决定"哪些隔离哪些穿透"(函数穿透时的 .bind(rawWindow) 是最容易漏的坑, 不绑定会报 Illegal invocation)、以及 set 时挂账 + unmount 时清算。真实的 micro-app 还要处理 document 代理、escapeProperties 逃逸、元素隔离等——但隔离的内核就是这几行。而它对付 Vite/ESM 无能为力的原因也一目了然: with 语句包不住 type=module, 只能改用 iframe 沙箱另辟蹊径。
手写内核可交互验证
上面的极简实现已对齐官方源码核心——fakeWindow + proxyWindow Proxy、has 拦截解决 Vue === undefined 问题(issue #686)、isConstructor 判断函数 bind 策略——并做成了真实可运行的样例:
- 在线验证: https://stoicatom.github.io/demos/micro-frontends/from-scratch/
- 源码: https://github.com/lorainwings/demos/tree/master/micro-frontends/from-scratch
点击"在沙箱里运行子应用代码"后再点"检查真实 window 是否被污染", 日志会确认真实 window.pollutant = undefined——with + Proxy 把污染挡在了 fakeWindow 里。再点"卸载沙箱"可验证 injectedKeys 清算后 microWindow 也恢复干净。
版本说明
micro-app 的 npm latest 是 1.0.0-rc.32(名义上仍是 RC, 但官方文档已全面转向 1.0, 组织也从 micro-zoe 迁到 jd-opensource)。若要严格的正式版号, 最后的 0.x 稳定版是 0.8.11。本文样例用 1.0.0-rc.32。
运行时效果
- 在线预览: https://stoicatom.github.io/demos/micro-frontends/micro-app/
- 源码: https://github.com/lorainwings/demos/tree/master/micro-frontends/micro-app
样例用 Vite + micro-app 1.0, Vite 子应用按官方要求开启 iframe 沙箱, 演示 EventCenter 数据通信。
11.6 micro-app 坑点排查
micro-app 的坑主要在"with 沙箱对 Vite/ESM 无效、环境变量拼写、样式/元素作用域的单向性、Vue 识别"这几处。注意官方仓库已从 micro-zoe/micro-app 迁移到 jd-opensource/micro-app。
坑 1: Vite 子应用白屏 / location 操作异常(with 沙箱无法处理 ESM)
现象: Vite 子应用白屏, 或操作 window.location(读 location.host、赋值 location.href)不生效。
根因: micro-app 默认 with 沙箱, 通过 with(){} 包裹拦截全局操作; 但 Vite 产物 <script type="module">(ESM)有独立作用域, 无法被 with 包裹, 故 with 沙箱对 Vite 失效, location 代理也失效。
补丁: <micro-app> 标签加 iframe 属性切到 iframe 沙箱; 操作 location 改用 window.microApp.location.href = '...' 等代理:
<micro-app name="app1" url="..." iframe></micro-app>坑 2: 静态资源(js/css/图片)404
现象: 子应用单独运行正常, 嵌入主应用后静态资源 404、样式丢失。
根因: micro-app 默认对 link/script/img 及 CSS 的 background-image/@font-face 相对路径自动补全, 但某些框架动态创建的元素拦截不到, 或关闭样式隔离/沙箱时补全失效。注意 Vite 应用不支持此 publicPath 方案(webpack 专属)。
补丁(webpack 子应用): 建 public-path.js, 入口最顶部引入:
// public-path.js
if (window.__MICRO_APP_ENVIRONMENT__) {
__webpack_public_path__ = window.__MICRO_APP_PUBLIC_PATH__
}环境变量确切写法(前后均双下划线): __MICRO_APP_ENVIRONMENT__(是否在微前端环境)、__MICRO_APP_PUBLIC_PATH__(资源前缀)、__MICRO_APP_BASE_ROUTE__(路由基准, 默认空串)、__MICRO_APP_NAME__。
坑 3: 沙箱内顶层变量丢失——xxx is not defined
现象: xxx is not defined / xxx is not a function / Cannot read properties of undefined, 常见于 webpack DllPlugin、<script> 引入的第三方 js、Module Federation 子应用。
根因: 正常环境下 var name/function name(){} 顶层变量会泄漏为全局(window.name 可访问), 但 micro-app 沙箱中顶层变量不泄漏为全局, window.xxx 为 undefined, 依赖全局挂载的库找不到变量。
补丁: DllPlugin 设 output.library.type = 'window'; Module Federation 设 library: { type: 'window', name: 'app1' }; 或用 micro-app 插件系统 plugins.modules 的 loader(code, url) 把 var xxx= 替换为 window.xxx=。
坑 4: 主应用样式污染子应用 + 元素作用域异步解绑错乱
现象: (4a)子应用样式被主应用覆盖(反向不会); (4b)主应用元素被错误插入 <micro-app> 内部, 或子应用元素插到非预期位置(常见于挂 document.body 的弹窗)。
根因: (4a)micro-app 样式隔离是单向的——以 micro-app[name=xxx] 前缀限制子应用样式, 但对主应用样式不隔离, 主应用全局样式仍向下影响子应用。(4b)元素隔离模拟 ShadowDOM, 但元素作用域解绑是异步的, 异步窗口期内主应用 DOM 操作可能被错误绑到子应用作用域。
补丁: (4a)约定 class 前缀 / CSS Modules / 组件库 prefixCls; 完全禁用子应用样式隔离用标签属性 disableScopecss 或全局 microApp.start({ disableScopecss: true })。(4b)调 removeDomScope() 解绑——主应用 import { removeDomScope } from '@micro-zoe/micro-app'; removeDomScope(true), 子应用 window.microApp.removeDomScope(true)。
坑 5: Vue 主应用循环刷新 + <micro-app> 未识别
现象: (5a)vue2 [Vue warn]: Unknown custom element: <micro-app>; vue3 [Vue warn]: Failed to resolve component: micro-app。(5b)嵌入子应用后页面循环刷新/闪烁, 或路由跳转时子应用频繁卸载重渲染。
根因: (5a)<micro-app> 是自定义元素, Vue 编译器默认不认; (5b)把 route.fullPath/route.path 设为 <router-view> 的 key 时, 路由变化会强制重建组件树, 导致 <micro-app> 反复卸载/渲染。
补丁: (5a)vue2 Vue.config.ignoredElements = ['micro-app']; vue3(vue-cli)chainWebpack 设 isCustomElement: tag => /^micro-app/.test(tag); Vite+Vue3 在 @vitejs/plugin-vue 的 template.compilerOptions.isCustomElement 同样处理。(5b)把 :key="$route.fullPath" 改为 :key="$route.name"。
数据通信别用 DOM dataset
micro-app 的数据通信是标签 data 属性(对象) + window.microApp API(dispatch/getData/setData/addDataListener), 不是 DOM dataset(后者是字符串键值对)——文档和源码都未提及 dataset, 写文章/教程时别用 dataset 一词, 会误导成 DOM 原生属性。主 → 子: <micro-app :data="{ user }" />; 子 → 主: window.microApp.dispatch({ from: 'app1' })。addDataListener(fn, true) 的第二个参数 autoTrigger 解决"绑定监听前数据已发送不触发"的坑。
十二、路由: 微前端最隐蔽的翻车重灾区
前面每个框架都零散提过路由, 这里集中拆一次——因为微前端一半的线上事故都出在路由: 刷新 404、子应用跳转丢 base、前进后退错乱、两个子应用抢地址栏。根因是微前端把"一个 SPA 一套路由"变成了"主应用一套 + N 个子应用各一套路由, 共用同一根地址栏", 这套多路由协同不理解透, 迟早翻车。
12.1 主子路由如何协同: 一根地址栏, 两层路由表
浏览器只有一个 location, 但主应用和子应用各有一张路由表。协同的模型是前缀分段:
- 主应用路由负责"激活哪个子应用"——它只关心第一段路径 (
/order/*→ 订单子应用), 匹配到就把子应用挂载进某个容器; - 子应用路由负责"子应用内部渲染哪个页面"——它接管
/order之后的部分 (/order/detail/1)。
这里的关键约定叫 basename (子应用路由基准): 子应用必须知道自己被挂在 /order 下, 否则它会拿整个 /order/detail/1 去匹配自己的路由表 (而它的表里只有 /detail/1), 直接 404。所以子应用的路由器要设 basename / base:
// Vue Router: 子应用从框架注入的 base 拿基准
const router = createRouter({
history: createWebHistory(window.__POWERED_BY_QIANKUN__ ? '/order' : '/'),
routes,
})
// React Router v6
<BrowserRouter basename={window.__POWERED_BY_QIANKUN__ ? '/order' : '/'}>各框架注入 base 的方式不同, 但语义一致: qiankun 通过 props.base / activeRule、single-spa 靠 activeWhen、micro-app 用 __MICRO_APP_BASE_ROUTE__、wujie 用 props 下发。独立运行时 base 为 /, 被主应用嵌入时 base 为分配的前缀——这个三元判断是子应用改造的标准动作。
12.2 hash 还是 history: 子路径部署下的生死抉择
这是选型时最该先拍板的一件事, 因为它直接决定"刷新会不会 404"。
hash 路由 (/#/order/detail) | history 路由 (/order/detail) | |
|---|---|---|
| 原理 | 路由信息在 # 后, 不进 HTTP 请求 | 路由信息在 path 上, 会随请求发给服务器 |
| 刷新表现 | 永远命中入口 HTML, 不会 404 | 服务器没有 /order/detail 这个文件 → 404 |
| 服务器要求 | 零配置 | 必须配 fallback (见 12.3) |
| 美观 / SEO | 带 #, 较丑, 对 SEO 不友好 | 干净, 对 SEO 友好 |
| 主子同步 | 两层都用 hash 会抢同一个 #, 易冲突 | 各自占 path 段, 天然不冲突 |
实战结论: 主应用用 history (地址栏干净、SEO 友好), 子应用在能配服务器时也用 history (体验最好); 部署在 GitHub Pages / 纯静态 CDN 这类无法配 rewrite 的环境, 子应用退回 hash 最省心 (刷新绝不 404)。最忌讳的是主、子都用 hash——一个 # 塞两层路由状态, 前进后退极易错乱。
本文样例的路由取舍
本文五个 GitHub Pages 样例, 主应用用 history, 但因 Pages 无服务端 rewrite, 采用了 12.3 的 404.html fallback 方案兜底深链刷新; 手写内核样例则直接用 hash 交互, 规避一切服务器依赖。这不是偷懒, 而是"部署环境决定路由模式"的现实体现。
12.3 刷新 404: 根因与三种解法
现象: 子应用页面 /order/detail/1 首次进入正常 (SPA 前端跳转), 一按 F5 刷新就 404 或白屏。
根因: 刷新 = 浏览器拿 /order/detail/1 向服务器发真实 GET 请求。但这是个 history 前端路由, 服务器上根本没有这个文件/路由, 于是返回 404。SPA 单体也有这问题, 微前端因为多了一层子应用嵌套而更频繁。
三种解法, 按部署环境选:
解法一 (推荐, 有 Nginx): try_files 回退到入口。
location /order/ {
try_files $uri $uri/ /order/index.html; # 找不到文件就回子应用入口
}解法二 (纯静态 / GitHub Pages): 404.html = index.html。 GitHub Pages 找不到文件时会返回同目录的 404.html。把它做成入口的副本, 深链刷新时就"假 404 真入口", 前端路由再接管。本文样例的 assemble-dist.mjs 正是给每个框架目录生成 404.html:
// 给每个子应用目录复制一份 index.html 作为 404.html 兜底
cpSync(resolve(dir, 'index.html'), resolve(dir, '404.html'))解法三 (无法碰服务器): 换 hash 路由。 如 12.2 所述, hash 不进 HTTP 请求, 刷新永远命中入口 HTML, 零服务器配置。代价是 URL 带 #。
附带的 .nojekyll 坑
GitHub Pages 默认用 Jekyll 处理站点, 会忽略下划线开头的文件/目录 (如 Module Federation 的 __federation_expose_*)。必须在部署根放一个空的 .nojekyll 文件关掉 Jekyll, 否则 MF 的远程模块 404。这个坑不属于路由但同属"静态托管的隐形规则", 一起记住。
12.4 子应用间跳转与前进后退
子应用跳到另一个子应用, 必须走主应用路由, 不能用自己的 router。 子应用 A 的 router.push('/order') 只会在 A 自己的路由表里找 /order (找不到), 因为它的 router 被 basename 限定在 /user 段内。跨子应用跳转要操作全局 history:
// 子应用 A 里跳到子应用 B: 用原生 history, 让主应用路由接管
window.history.pushState(null, '', '/order/detail/1')
// 或用框架提供的全局跳转: micro-app 的 window.microApp.router.push()
// wujie 的 bus.$emit('navigate', '/order')前进后退错乱, 通常是"两套 history 互相打架"。 主应用监听 popstate 做子应用切换, 子应用的 router 也监听 popstate 做内部渲染。一次后退可能同时触发两层响应, 若子应用卸载时没摘掉自己的 popstate 监听 (副作用没清理), 就会出现"退一步跳两页"。这把问题的根在副作用清理——回到 3.3 沙箱那句话: 子应用 unmount 必须 removeEventListener('popstate', ...), 框架的沙箱能兜底一部分, 手动注册的仍要自己摘。
12.5 虚拟路由: iframe 系框架的另一套玩法
wujie、micro-app 这类基于 iframe 的框架, 路由更特殊: 子应用的 JS 跑在 iframe 里, iframe 有自己独立的 history 和 location。如果放任子应用改 iframe 的 location, 地址栏 (主文档的) 不会变, 刷新就丢状态。
它们的解法是虚拟路由 / 路由同步: 劫持 iframe 内的 history.pushState, 把子应用的路由变化映射并同步到主文档地址栏 (常以 query 或路径段形式, 如 micro-app 的 ?app1=/detail/1), 反过来主文档地址栏变化也通知 iframe 内的 router。micro-app 甚至提供 router-mode(search / native / pure) 让你选同步策略。理解这层, 就明白为什么 wujie/micro-app 的子应用"零改造"还能刷新不丢路由——框架在 iframe 与地址栏之间架了一座双向同步的桥。
路由排查速记
刷新 404 → 查路由模式 (history 缺 fallback?) 与服务器配置; 子应用内跳转 404 → 查 basename 有没有设对; 跨子应用跳转失效 → 该走全局 history 却用了子应用 router; 前进后退跳两页 → popstate 监听没在 unmount 清理; iframe 系刷新丢状态 → 查虚拟路由的同步模式配置。
十三、选型建议与总结
把前面所有内容收拢成一套可落地的决策思路。可以把这几种方案类比成公司里不同的办公室隔断方式:
- single-spa: 只给你一张排班表, 谁进谁出自己管, 隔断要自己砌。灵活但活多, 适合有强定制需求、要兼容老浏览器的团队。
- qiankun: 国内最成熟、资料最多的标准装修方案。团队新手多、主要用 Webpack、不要求兼容 IE 多实例场景, 作为默认首选。
- Module Federation: 不是隔断, 而是共享工位和设备。真正诉求是多团队共享依赖和模块、都在用 Webpack 5 时选它, 但它不解决隔离。
- micro-app / wujie: 自带独立小房间 (iframe / ShadowDOM 级隔离)。追求接入简单、子应用零改造、要保活、构建工具自由 (尤其用 Vite), 且不用管老浏览器时选这两个, wujie 隔离最彻底但依赖也最重。
决策路径
从"跑起来"到"敢上生产"
Demo 能跑和生产可用之间, 隔着一整套工程能力。这几点是微前端在真实项目里翻车最多、也最能体现功力的地方:
- 子应用加载失败要有兜底。 子应用部署挂了、网络抖动、资源 404, 主应用不能跟着白屏。qiankun 用
loadError全局钩子、single-spa 用应用级errorBoundary、MF 用React.lazy的 Suspense + Error Boundary。生产里每个子应用都该包一层降级 UI ("该模块暂时不可用"), 而不是整站崩。 - 独立部署要解决版本一致性。 微前端的核心价值是子应用独立发版, 但主应用缓存的 entry 可能指向旧版本。子应用
index.html要设Cache-Control: no-cache(入口不缓存、带 hash 的资源永久缓存), 或用 MF 的 manifest + 版本协商, 否则会出现"发了新版但用户还在跑旧的"。 - 灰度与回滚以子应用为单位。 既然能独立部署, 就该能独立灰度: 通过网关按用户/比例把某个子应用的 entry 指向新版本, 出问题只回滚这一个子应用。这是微前端相对单体 SPA 的最大工程红利, 但需要 entry 地址可动态配置 (别写死在主应用构建产物里)。
- 监控要能定位到"是哪个子应用"。 前端错误上报、性能埋点要带上子应用标识, 否则一个报错分不清是主应用还是哪个子应用的。给每个子应用的
unmount补全清理 (定时器、监听、第三方实例), 否则长时间运行会内存泄漏——这在保活模式下尤其致命。 - 公共依赖策略要提前定。 是各打各的 (简单但体积大), 还是 externals + CDN 共享 (省体积但版本强耦合), 还是上 MF 做运行时共享 (最优但有构建约束)? 这个决定越晚改代价越大, 应在架构期就拍板。
组织比技术更难
微前端最大的挑战往往不是技术, 而是团队边界与治理: 谁定公共依赖版本、谁维护基座、跨子应用的 UI 一致性靠什么保证、通信契约如何演进不破坏下游。技术选型只是起点, 配套的规范 (依赖版本锁、通信契约文档、基座 SDK)才是微前端能长期跑下去的关键。
关键要点回顾
- 微前端解决的是协作和工程问题, 不是技术炫技。规模不大、技术栈统一、团队小, 就别硬上。
- 所有运行时框架都在回答同五个问题: 应用加载、路由分发、JS 隔离、样式隔离、应用通信。抓住这条主线, 学任何框架都事半功倍。
- JS 隔离有三条路: 快照沙箱 (老浏览器降级)、Proxy 沙箱 (现代主流)、iframe 沙箱 (最彻底)。
- 兼容性要分三层看: 浏览器、构建工具、技术栈。隔离越好、接入越简单的新框架, 对浏览器要求越高, 这是无法回避的取舍。
- qiankun 接入的三板斧: 暴露生命周期、注入动态 public-path、打包成 umd; 坑点集中在资源路径、样式隔离、Vite 兼容三处。
微前端没有银弹, 只有匹配场景的取舍。理解了背后的原理, 无论出现什么新框架, 你都能一眼看穿它的本质, 并判断它是否适合自己的项目——这才是从入门到精通真正的分界线。
GitHub Pages 部署说明
本文样例部署在 GitHub Pages 上, 遇到了静态托管环境下 history 路由的经典问题: qiankun 和 single-spa 的导航用 <a href> 做全页跳转, 跳到前端路由路径 (如 /qiankun/app-vue) 时, GitHub Pages 找不到对应文件返回 404。
已修复方案: 在 gh-pages 根目录部署了一个全局 404.html, 内含智能重定向逻辑——当访问 /micro-frontends/{框架名}/{路由} 时, 自动跳转到 /micro-frontends/{框架名}/index.html?redirect={路由}, 主应用读取 redirect 参数并用 history.replaceState 恢复正确的 URL, 从而让子应用正常激活。这是纯静态托管环境(GitHub Pages / CDN)下 history 路由的标准 fallback 方案, 也是 12.3 节"刷新 404 解法二"的实战应用。
wujie、micro-app、Module Federation 的导航用 JS 控制 (无全页跳转), 不受此问题影响。
全部样例 · 边看边跑
本文五个框架都配了可运行样例(每套 1 主应用 + Vue 3 与 React 两个异构子应用), 均按官方权威方式接入、支持一键部署到 GitHub Pages。
- 样例总导航: https://stoicatom.github.io/demos/micro-frontends/
- 全部源码: https://github.com/lorainwings/demos/tree/master/micro-frontends
建议对照文章逐个跑一遍——切换子应用、打开控制台看通信日志、用开发者工具观察样式隔离, 比读十遍更有体感。