7AA7 博客

微前端架构:qiankun、Module Federation 与 iframe

陈砚舟 UID:106
2026-09-10 20:55
89 4 2

微前端是把后端微服务的组织思路搬到前端。它解决的是组织问题,不是技术问题——这个定位决定了什么时候该用、什么时候不该用。

它真正解决什么

三个真实诉求:

  1. 独立部署:A 团队改了订单页,不需要等 B 团队的发布窗口,也不需要整站回归;
  2. 技术栈异构:老系统停在旧框架版本,新模块想用新版本或新框架,各自演进;
  3. 团队边界清晰:代码库按业务域拆分,权限与责任对应得上。

反过来,下面这些情况不该上微前端:

  • 只有三五个页面、两三个人的项目——单体加路由拆分就够了,微前端带来的通信成本远大于收益;
  • 团队没有独立部署诉求,所有需求统一排期统一发布——拆分只会增加联调成本;
  • 期望它解决”首屏慢””包体积大”——那是构建优化和代码分割的活,微前端通常让体积更大(重复的运行时与依赖);
  • 跨应用共享大量状态和复杂交互——共享越重,边界越模糊,最后得到一个”分布式单体”。

判断标准可以简化成一句:如果拆分后的各块不能独立发布,那就不要拆。

iframe:最彻底的隔离

iframe 提供浏览器原生的硬隔离:独立的 window、document、样式作用域、JS 执行环境。主子应用之间除了 postMessage 几乎零耦合。

// 父 → 子
iframe.contentWindow.postMessage({ type: 'route', path: '/order/1' }, origin);
// 子 → 父
window.addEventListener('message', e => {
  if (e.origin !== TRUSTED_ORIGIN) return;  // 必须校验来源
  handle(e.data);
});

代价同样明确:

  • URL 不同步:子应用内部路由变化不会反映到主窗口地址栏,刷新丢失状态,需要手动同步;
  • 布局割裂:子应用的弹窗、下拉无法溢出 iframe 边界,全屏弹层必须跨 iframe 通信让主应用渲染;
  • 资源重复加载:每个 iframe 都要重新下载一份框架运行时和样式;
  • 加载白屏:iframe 创建到内容渲染之间有明显空窗;
  • 性能:每个 iframe 是独立的文档环境,内存和渲染开销都高于同文档内的 DOM。

适合用 iframe 的场景:嵌入完全第三方的内容、对隔离要求极高(如支付、合同签署这类不允许被宿主脚本影响的功能)、或者作为渐进迁移的临时容器。

JS 沙箱:Proxy 劫持 window

single-spa 系方案(qiankun 等)让多个应用跑在同一个 document 里,于是必须自己造隔离。

沙箱的思路是给每个子应用一份”假的 window”:

function createSandbox() {
  const rawWindow = window;
  const fakeWindow = {};
  const updated = new Map();   // 子应用写入的值
  const proxy = new Proxy(fakeWindow, {
    get(t, k) {
      if (updated.has(k)) return updated.get(k);
      const v = rawWindow[k];
      return typeof v === 'function' ? v.bind(rawWindow) : v;  // 修正 this
    },
    set(t, k, v) { updated.set(k, v); return true; },
    has(t, k) { return k in updated || k in rawWindow; },
  });
  return {
    proxy,
    activate() { /* 恢复上一次的 updated 到 fakeWindow */ },
    deactivate() { /* 把对 rawWindow 的修改回滚 */ },
  };
}

子应用的代码被包在函数里执行,window、document、self 通过参数注入为 proxy:

eval(`(function(window, self, globalThis){ ${code} })`)(proxy, proxy, proxy);

多实例场景用上面的 Proxy 沙箱;单实例场景还有一种快照沙箱(进入时记录 diff、离开时还原),实现更简单但不支持同时挂载多个应用。

沙箱的漏点是必须知道的:

  • 只劫持 window 属性,劫持不了原型链上的全局能力。子应用改写 Array.prototype.push、Promise.prototype.then 会污染全局;
  • DOM / BOM 原生 API 无法完全隔离。子应用 document.body.appendChild 挂在真实 body 上,监听 window 的事件需要显式解绑;
  • 通过闭包逃逸的引用:子应用如果在初始化时把 window.fetch 存进模块级变量,沙箱切换后它仍指向原始函数,无法被拦截;
  • eval 执行的性能与 CSP:eval 在某些 CSP 策略下被禁用,需要放开 unsafe-eval,这本身是安全上的妥协;
  • 动态插入的脚本:子应用运行时 document.createElement('script') 插入的代码跑在真实 window 下,不在沙箱内。这需要通过劫持 appendChild 或脚本重写来处理,是各家实现的差异点。

样式隔离

三种常见手段:

1. Scoped CSS / 选择器前缀:构建时给子应用所有选择器加前缀([data-app="order"] .btn)。实现简单,但对运行时动态插入的样式无效,且需要构建插件配合。

2. Shadow DOM:把子应用挂载到 attachShadow({ mode: 'open' }) 下,样式天然隔离。

const root = container.attachShadow({ mode: 'open' });
root.innerHTML = `<style>${appCss}</style><div id="app"></div>`;

问题在于:UI 组件库的弹窗通常 appendChild 到 document.body,脱离 shadow root 后样式全部失效;字体、CSS 变量需要显式透传;document.querySelector 查不到 shadow 内的节点,一些依赖全局查询的库会失效。

3. 运行时样式快照:进入子应用时记录 <style>/<link> 列表,离开时移除新增的。能防止残留,但不解决同时挂载时的冲突。

实际工程里通常是”前缀 + 快照”组合,Shadow DOM 用在隔离要求高且弹窗可控的子应用上。

Module Federation 的共享机制

MF 是构建层面的方案(Webpack 5 / Rspack / Vite 插件均有实现)。它让一个应用在运行时从另一个应用加载模块,并且能共享依赖。

// 宿主配置
new ModuleFederationPlugin({
  name: 'host',
  remotes: {
    order: 'order@https://cdn.example.com/order/remoteEntry.js',
  },
  shared: {
    react: { singleton: true, requiredVersion: '^18.0.0' },
    'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
  },
});

// 子应用暴露
new ModuleFederationPlugin({
  name: 'order',
  filename: 'remoteEntry.js',
  exposes: { './App': './src/App.tsx' },
  shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
});

关键点在 shared + singleton: true:React 只加载一份,多个应用共用同一个实例。这对 React 是必需的——多份 React 会导致 hooks 报错、Context 不互通。

版本协商的机制是:各方声明 requiredVersion,MF 取兼容范围内的最高版本;singleton 强制单例;eager: true 则不做异步协商,直接打进主包(会增加体积)。版本不兼容又设了 singleton,会在控制台发出警告并可能运行时出错。

// 运行时动态加载
const OrderApp = React.lazy(() => import('order/App'));

MF 与 qiankun 这类方案的差别:MF 是构建期约定 + 运行时模块共享,不提供 JS 沙箱和样式隔离,子应用之间共享同一个 window。它的强项是依赖共享与渐进集成(把远程模块当普通组件用),弱项是隔离靠自觉。

主子应用通信

几种方式各有适用面:

方式 实现 优点 缺点
Props 注入 挂载时传参 类型安全、显式 只能单向、初始化时确定
全局事件总线 共享的 EventEmitter 简单、解耦 无类型、难追踪、易泄漏
共享状态库 主应用提供 store,子应用读写 支持响应式 耦合加深,边界模糊
CustomEvent window.dispatchEvent 原生、跨 iframe 也可用 字符串协议,无校验
URL / 路由 路由参数传递 可分享、可刷新 只适合少量标量状态
postMessage iframe 间通信 跨源安全 序列化开销、需校验 origin

选型建议:优先 URL 路由 + props 注入,这两者覆盖了大部分场景且语义清晰。通信频繁、状态复杂时才引入共享 store,但要为它划定明确的命名空间和读写权限,否则几年后会变成一个谁都不敢动的全局对象。

// 一个收敛的通信契约,好过随意的事件名
const bus = {
  emit(type, payload) { window.dispatchEvent(new CustomEvent(`app:${type}`, { detail: payload })); },
  on(type, fn) { window.addEventListener(`app:${type}`, e => fn(e.detail)); },
  off(type, fn) { window.removeEventListener(`app:${type}`, fn); },
};

无论用哪种方式,卸载时必须清理监听。子应用反复挂载卸载而监听器没解绑,是这类架构最常见的内存泄漏源。

部署与版本兼容的麻烦

现实中最耗精力的往往不是代码,是这几件事:

  • 远程入口的可用性。MF 的 remoteEntry.js 是运行时拉取的,它挂了宿主页面就崩。需要有降级(加载失败时展示兜底 UI)和多版本并存;
  • 版本契约。子应用升级后如果改了暴露组件的 props,宿主还按老契约调用就出错。需要契约测试或版本号协商;
  • 共享依赖升级。react 从 18 升到 19,所有子应用必须同步验证,singleton 意味着没有”各用各的”这个退路;
  • 灰度一致性。主应用灰度到 10% 流量,子应用也要对应灰度,否则会出现新旧组合;
  • 公共资源的重复加载。字体、图标库、UI 库在各子应用各加载一份,需要统一抽取到宿主或用 shared 收敛;
  • 本地开发的联调。开发子应用时要能连到远程宿主,或本地同时起多个应用,这需要一套代理配置和 mock 数据约定。

一个务实的推进顺序:先按业务域做代码库与构建产物的拆分 + 独立部署(哪怕还是同一个框架、同一套路由),等真的出现了”两个团队必须用不同技术栈”或”某个模块发布频率远高于其他”的强诉求,再引入运行时隔离方案。跳过这一步直接上沙箱,通常是过度设计。

1.5k浏览量
89赞
4评论
4参与用户
13 分钟阅读时间

评论(4)

  • Async 赵 · 2026-09-10 23:03

    顺手改了自己项目里的一段老代码,删了 60 行,爽。

    回复
  • 硅屿 · 2026-09-11 08:41

    看到一半去开了个会,回来忘了看到哪,重看一遍。

    回复
  • 叶南 · 2026-09-11 17:09

    评论区有没有大佬总结一下重点,我懒。

    回复
  • Schema 李 · 2026-09-12 02:05

    我承认我没看完,但前两节已经值这个赞了。

    回复