Skip to content

微前端从入门到精通

作者:Atom
字数统计:28.2k 字
阅读时长:101 分钟

本文面向已经能独立开发单页应用 (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 的库。它的好处是: 子应用怎么写就怎么打包, 接入方几乎无感, 天然支持子应用自己的资源分片。

js
// 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), 通常是路由前缀。主应用劫持路由变化 (监听 popstatehashchange, 并重写 pushState / replaceState), 每次 URL 变化时遍历所有子应用的激活规则:

  • 规则命中、但子应用还没挂载 -> 调用它的 mount;
  • 规则不再命中、但子应用还挂着 -> 调用它的 unmount

这套机制和 Nginx 按 location 前缀把请求转发到不同后端, 思路完全一致——只不过转发发生在浏览器里, 转发的目标是"渲染哪个子应用"而不是"请求哪台服务器"。

3.3 JS 隔离: 沙箱机制

这是微前端最核心也最难的部分。多个子应用跑在同一个 window 上下文里, 如果子应用 A 往 window.axios 挂了一个版本, 子应用 B 又挂了另一个版本, 或者 A 卸载后留下的定时器还在跑, 就会互相污染。沙箱 (Sandbox) 的作用就是给每个子应用一个"看起来独立"的全局环境, 卸载时能干净回滚。业界有三种实现。

快照沙箱 (Snapshot Sandbox)

原理很朴素: 子应用挂载前, 把整个 window 上的属性拍一张快照存起来; 子应用卸载时, 对比当前 window 与快照的差异, 把子应用运行期间新增/修改的属性还原回去。

js
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, 还能支持多个子应用同时挂载。

js
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.bodyappendChild 一个弹窗, 或第三方 SDK 直接改 document.title, 这些副作用沙箱管不到, 需要框架额外做 DOM 劫持 (记录子应用运行期间新增的 DOM/事件/定时器, 卸载时统一清理), 或由开发者在 unmount 里手动清理。

上面那段 createProxySandbox 只是最小骨架。真正生产级的 Proxy 沙箱 (以 qiankun 的 ProxySandbox 为例), 还要处理三类棘手问题, 这也是"看懂原理"与"能自己写一个"的分水岭:

其一, document / BOM 也要代理, 不只是 window 子应用里的 document.querySelector 应该查到自己那棵子树而不是整个主文档, window.location 的读写要受控。qiankun 用一套 patchDocumentdocument.createElementdocument.querySelector 等重定向到子应用容器; 更彻底的 wujie/micro-app 干脆借 iframe 拿到独立的 document

其二, 有些属性必须"穿透"到真实 window, 不能拦。 例如子应用调用 window.location.href = ... 要真的跳转、window.history 要能改地址栏、document/window 本身的引用不能被替换。沙箱内部维护一份"逃逸白名单", 对这些属性直接读写真实 window。名单划错, 轻则功能失灵, 重则死循环。

其三, 副作用要挂账, 卸载时统一清算。 子应用运行期间的 setTimeout/setIntervaladdEventListener、动态 appendChild<script>/<style>, 沙箱都会劫持并记进一个 effect 列表, unmount 时逐一 clearTimeout / removeEventListener / 移除节点。这就是为什么 qiankun 卸载子应用后, 它注册的定时器会自动停——不是魔法, 是记账。

js
// 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 有自己独立的 windowdocument、全局作用域。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 塞回去。原理骨架:

js
// 把 ".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 前缀限定的是"容器内的元素", 而弹窗默认 appendChilddocument.body (容器外), 前缀选择器自然选不中——这就是下面要讲的弹窗逃逸。

Shadow DOM 的弹窗逃逸: 隔离太彻底反而成了坑。 Shadow DOM 把样式关进影子树, 可 Element Plus 的 Dialog、Ant Design 的 Message 默认把 DOM 挂到 document.body——影子树外面。样式在罩子里、元素在罩子外, 弹窗就没样式。补法不是改隔离方案, 而是把弹窗容器指回子应用内部, 各 UI 库都留了这个口子:

js
// 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.dispatchEventCustomEvent, 或框架内置的事件中心广播消息。
  • URL 参数: 把状态放到路由 query 上, 简单但只适合少量、可公开的数据。

通信要克制

微前端的初衷是解耦, 如果子应用之间通信过于频繁、耦合过深, 说明业务边界可能没划分好。通信应该只用于传递少量共享的全局态 (登录信息、权限、主题), 而不是把它当成跨应用的万能数据总线。

五个框架的通信 API 放在一起对比, 能看出它们的设计取向。 同样是"传数据", 有的给你响应式 store, 有的只给你事件, 有的干脆什么都不给:

js
// 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 共享 storewujie bus、micro-app dispatch、CustomEvent

一句话: 持续态用 store (带初值、可回放), 一次性动作用事件 (轻、无残留)。别用事件同步持续态 (后加入的子应用收不到历史事件, 会拿到空值)。

通信契约: 微前端最容易腐化的地方

通信一旦跨了应用边界, 就是跨团队的接口契约, 和后端 API 一样需要治理, 否则半年后没人敢改那个全局 store。三条底线:

  1. 约定 payload 结构并显式版本化。 全局态和事件的数据结构应写成 TypeScript 类型 (或 JSON Schema) 放在共享包里, 主子应用都从这个包 import 类型。{ type: 'user/login', version: 1, payload: {...} } 带上 version, 结构演进时老子应用能识别并降级。
  2. 事件命名带命名空间, 别用裸字符串。 bus.$emit('refresh') 这种全局裸事件, 多个子应用都监听就会误触发。用 订单中心/order-created 这样的命名空间前缀, 从名字就能追溯归属。
  3. 向后兼容, 只增不改。 给 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 监听路由变化, 在合适的时机调用对应子应用的生命周期。

js
// 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) 只需加载一份

js
// 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 标签来用:

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-spaqiankunModule Federationmicro-appwujie
接入成本高 (手写胶水层)中 (改 entry + 生命周期)中 (改 webpack 配置)低 (一个标签)低 (一个标签)
JS 隔离无, 自行实现Proxy / 快照沙箱无 (共享上下文)Proxy 类 iframe 沙箱iframe 原生沙箱
CSS 隔离ShadowDOM / scopedShadowDOMiframe / ShadowDOM
技术栈无关支持支持支持支持支持
多实例并存支持Proxy 沙箱支持支持支持支持
依赖共享手动手动 / externals原生强项手动手动
子应用保活不支持不支持 (切换即销毁)不适用支持 keep-alive天然保活
预加载不支持支持部分支持支持支持
社区活跃度高 (国内事实标准)中高中高

5.2 兼容性对比 (选型时最容易踩的坑)

兼容性必须拆成三层看, 实际选型翻车几乎都出在这里。

第一层: 浏览器兼容性

框架关键 API 依赖IE11老旧浏览器表现
single-spa无强依赖可支持最好, 取决于你自己的代码
qiankunProxy (沙箱)降级为快照沙箱, 只能单实例支持 Proxy 则正常, 否则功能受限
Module FederationES2015+ 动态导入需大量 polyfill, 较麻烦现代浏览器良好
micro-appCustomElement v1 + Proxy不支持不支持 WebComponent 的浏览器直接不可用
wujieProxy + 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 / qiankunHTML/JS Entry, 运行时 fetch + 解析子应用资源子应用首次进入有网络往返 + 解析开销默认各打一份, 靠 externals/预加载缓解
Module Federation构建时约定 shared, 运行时按需拉 chunk首屏只加载 host + 用到的 remote 分片原生强项, 公共依赖只下一份
wujieiframe 承载 + 可预加载 + 保活首次起 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。

js
// 基座 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 样式隔离
})
html
<!-- 基座页面里留一个容器给子应用挂载 -->
<div id="subapp-container"></div>

6.2 子应用改造 (以 Vue3 为例)

子应用要做三件事: 注入动态 publicPath、暴露生命周期、调整打包配置。

第一步, 在入口最顶部 (所有 import 之前) 引入动态 publicPath:

js
// 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 里引入它并暴露生命周期:

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 为例):

js
// 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 两个异构子应用), 均按官方权威方式接入。

点击顶部导航切换 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 提供两种隔离开关, 根据浏览器要求二选一:

js
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 的 ModalMessage 等组件样式裸奔。

根因: 这些组件默认挂载到 document.body——也就是 Shadow DOM 罩子外面, 或 scoped 前缀作用域外面, 于是隔离样式作用不到它们。

补丁: 强制让弹窗类组件挂载到子应用容器内部:

js
// 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 里手动做彻底清理, 双保险:

js
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 插件:

js
// 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': '*' } }
}
js
// 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, 走发布订阅:

js
// 基座
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:

html
<!-- 基座 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>
js
// 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 把框架实例包装成这三个钩子:

js
// 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
js
// 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(里面带 namemountParcelsingleSpa)。跨应用共享状态是"官方推荐用 import map 共享一个工具模块", 至于用 RxJS 还是事件总线, 属于社区自由发挥。

别把社区方案当官方 API

网上很多 single-spa 通信教程用了各种 store, 那些都不是 single-spa 提供的。官方能保证的只有 props 下发。选型时若通信需求重, qiankun 的 initGlobalState 会省心很多。

8.5 深入原理: 生命周期状态机与 reroute

会用 registerApplication 只是入门, 真正读懂 single-spa 要看它的两个核心: 一台状态机 + 一个调度中枢。这也是排查"子应用为什么没挂载 / 挂了又消失 / 坏了之后再也不加载"的钥匙。

每个应用是一台状态机。 single-spa 内部给每个应用维护一个 status, 共 12 个状态:

text
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(), 它按"当前状态 + 是否应该激活"把所有应用分成四桶: appsToLoadappsToMountappsToUnmountappsToUnload, 然后编排:

  • 卸载与加载并发, 但挂载必须等所有卸载完成(mountunmountAllPromise)——保证同一时刻旧应用的 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 的骨架。能写出这个, 才算真正"精通"它——你会发现所谓微前端框架, 内核不过是"注册表 + 路由劫持 + 状态机调度"三件事。

js
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 并发护栏——并做成了真实可运行的样例:

点击"切到 /vue / /react / /(都不激活)"按钮, 在日志里观察状态机从 NOT_LOADED → BOOTSTRAPPING → NOT_MOUNTED → MOUNTING → MOUNTED → UNMOUNTING 的完整流转。

运行时效果

样例基座用 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 标记。

js
// 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 的模块名逐字一致:

html
<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 functionsdoes 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.jsvalidLifecycleFn 校验失败即标记 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 不是一回事, 别混):

js
// 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。若两个应用需要频繁传状态, 官方的建议是——考虑把它们合并。另: urlRerouteOnlystart(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(暴露方)

js
// 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 动态注册, 地址在运行时按当前环境推导:

js
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: trueerror。这就是"版本对不上但页面照跑、控制台一堆 Version X does not satisfy... 警告"的来源——那些 warning 正是协商在提示你版本有风险。
  • 选版本的策略可配: version-first(默认, 纯选最高)vs loaded-first(优先已加载的版本, 避免重复实例化, SSR/性能敏感场景更优)。

container 的 init/get 握手。 每个 remote 的 remoteEntry.js 暴露一个 container 对象, 只有两个方法, 但顺序不能乱:

js
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-shake process.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, 二者正交。

运行时效果

样例用 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 两边都要拆:

js
// 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:

js
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/runtimeregisterRemotes(注意从 enhanced 的 /runtime 出口引, 以和构建插件共用同一实例), entry 一般指向 mf-manifest.json:

js
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 语法匹配。

补丁:

js
{ 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 exportsRUNTIME-003 Failed to get manifestRUNTIME-004 Failed to locate remoteRUNTIME-008 Failed to load script resources。按错误码定位比按文案搜索更可靠——001/003/008 查 remote 地址/CORS/publicPath(坑 3), 004 查 remotesregisterRemotes(坑 4)是否注册。

十、wujie 接入实战

wujie(无界, 腾讯)的思路: 用 iframe 做 JS 沙箱, 用 WebComponent 做渲染容器, 兼顾 iframe 的彻底隔离与正常的页面布局。

10.1 主应用: 组件式加载

Vue 主应用用 wujie-vue3 提供的 <WujieVue> 组件:

js
import WujieVue from 'wujie-vue3'
app.use(WujieVue)
html
<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
js
// 单例模式才需要;保活模式跳过这段
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:

js
// 主应用
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.bodydocument.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、体验又像单页"。

js
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 执行——并做成了真实可运行的样例:

点击"加载子应用到双容器"后再点"验证 JS 在 iframe、DOM 在 ShadowRoot", 日志会确认 iframe.__SUB_APP_RAN__=true 而主 window 上该变量为 undefined, ShadowRoot 里能查到 #sub-root 而主文档查不到——双容器隔离一览无余。

运行时效果

样例用 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 跳登录页), 用 startAppfetch 配置设 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:

js
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.tsbase: './'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 主应用: 一个自定义元素

js
// 入口
import microApp from '@micro-zoe/micro-app'
microApp.start()
html
<micro-app name="sub-vue" url="/micro-app/sub-vue/" baseroute="/sub-vue"></micro-app>

Vue 3 主应用需让编译器识别这个自定义元素, 否则报错:

js
// vite.config.js 的 @vitejs/plugin-vue
vue({ template: { compilerOptions: { isCustomElement: (tag) => tag === 'micro-app' } } })

11.2 子应用改造

micro-app 不强制导出生命周期(区别于 qiankun), 但推荐:

js
// 子应用: 卸载函数(卸载时自动执行)
window.unmount = () => app.unmount()
js
// 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, 且主子绑定(主应用只能发给指定子应用), 避免多子应用互相污染:

js
// 主应用发数据给指定子应用
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 默认沙箱把子应用代码包成这样:

js
;(function (proxyWindow) {
  with (proxyWindow.__MICRO_APP_WINDOW__) {
    // 子应用代码, 裸写的全局标识符先在 fakeWindow 上找
  }
})(proxyWindow)

with(fakeWindow) 让代码里裸写的 xxx 优先在 fakeWindow(一个空对象 + Proxy)上解析。Proxy 的 get 是隔离关键: 子应用自定义的全局变量、locationhistory 隔离在 fakeWindow; 而内置对象(document、DOM 构造函数、定时器)穿透到真实 window(其中 document 再被 proxyDocument 二次代理限定到子应用容器)。unmount 时把 injectedKeys 记录的新增全局变量删掉, 完成清理。

名词: 为什么 Vite 子应用非用 iframe 沙箱不可

with 语句无法作用于 ES module: ESM 有独立词法作用域、顶层 thisundefinedimport/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)地址可分享、刷新可还原
statehistory.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 + 副作用记账三件事, 五十行还原:

js
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 策略——并做成了真实可运行的样例:

点击"在沙箱里运行子应用代码"后再点"检查真实 window 是否被污染", 日志会确认真实 window.pollutant = undefined——with + Proxy 把污染挡在了 fakeWindow 里。再点"卸载沙箱"可验证 injectedKeys 清算后 microWindow 也恢复干净。

版本说明

micro-app 的 npm latest1.0.0-rc.32(名义上仍是 RC, 但官方文档已全面转向 1.0, 组织也从 micro-zoe 迁到 jd-opensource)。若要严格的正式版号, 最后的 0.x 稳定版是 0.8.11。本文样例用 1.0.0-rc.32。

运行时效果

样例用 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 = '...' 等代理:

html
<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, 入口最顶部引入:

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.xxxundefined, 依赖全局挂载的库找不到变量。

补丁: DllPlugin 设 output.library.type = 'window'; Module Federation 设 library: { type: 'window', name: 'app1' }; 或用 micro-app 插件系统 plugins.modulesloader(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)chainWebpackisCustomElement: tag => /^micro-app/.test(tag); Vite+Vue3 在 @vitejs/plugin-vuetemplate.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:

js
// 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 回退到入口。

nginx
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:

js
// 给每个子应用目录复制一份 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:

js
// 子应用 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。

建议对照文章逐个跑一遍——切换子应用、打开控制台看通信日志、用开发者工具观察样式隔离, 比读十遍更有体感。