微前端架构实战:Module Federation、qiankun 与样式/JS 沙箱隔离

单体前端一旦膨胀到几十万行、十几个团队协作,技术栈升级要全员陪跑、一次发版全站停机、一个模块出错拖垮整站——这些痛点的根因是”构建时被强行打包成一个应用”。微前端的核心思路是:把一个应用拆成多个可独立开发、独立部署、运行时再组合的子应用。本文不聊某个框架的内部响应式(那部分 Vue3/React 已经写过),只聊”多应用如何共存”这件事。

一、为什么需要微前端

传统 SPA 在规模变大后会出现三类典型困境:

  • 巨石仓库:所有页面、所有团队挤在一个仓库里,依赖版本被强行统一,任何改动都要全量回归。
  • 发布耦合:改一行也要走全站发版流程,无法做到”哪个团队改了哪个模块,就只发哪个模块”。
  • 技术栈锁死:想从 Vue2 升级到 Vue3,或者引入 React,几乎不可能渐进式迁移,只能一次性重写。

微前端(Micro-Frontends)把”应用”拆成多个子应用,每个子应用拥有独立的技术栈、独立仓库、独立 CI/CD,在运行时由”基座(主应用)”把它们拼回一个完整的站点。

微前端 ≠ 组件库,也 ≠ 模块化打包。组件库解决的是”同一个应用内的复用”,微前端解决的是”不同团队、不同技术栈、不同生命周期的应用如何共存于一个页面”。

二、微前端必须解决的四个核心问题

核心问题 含义 不解决的后果
技术栈无关 子应用可用 Vue/React/NG/原生任意栈 又回到统一技术栈的死胡同
独立开发部署 子应用单独构建、单独发版 发版依旧全员耦合
运行时集成 在浏览器里拼装,而非构建时打包 无法独立加载、按需加载
样式与 JS 隔离 子应用之间互不影响全局环境 样式串台、全局变量互相覆盖

其中,运行时集成和隔离是工程上最难的两块,也是后文的重点。

三、主流集成方案对比

方案 隔离性 通信成本 独立部署 适用场景
iframe 天然最强(完全隔离) 高(postMessage) 强 弱耦合、第三方嵌入
Web Components 中(Shadow DOM 隔离样式) 中(自定义事件) 强 标准优先、组件级嵌入
Module Federation 弱(同运行时) 低(直接 import) 强 同技术栈拆包、模块共享
qiankun / 基座模式 中(沙箱模拟隔离) 低(全局状态) 强 多技术栈存量系统整合

3.1 iframe:最朴素的隔离

iframe 最大的优点是完全隔离——每个子应用跑在自己的 browsing context 里,JS 全局对象、DOM、样式互不可见,天然不会样式串台。缺点是:

  • 通信只能走 postMessage,开发体验差;
  • 路由同步、弹窗遮罩(position: fixed 被限制在 iframe 内)、浏览器前进后退都很难处理;
  • 性能开销大,多个 iframe 各加载一套运行时。

适合”嵌入一个相对独立的外部系统”,不适合做精细的页面拼装。

3.2 Module Federation:构建时的”模块共享”

Webpack 5 的 Module Federation(MF)让不同构建产物之间可以互相 import 远程模块,是”同技术栈拆包”的利器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 子应用 webpack.config.js(暴露方)
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "subAppCart",
filename: "remoteEntry.js",
exposes: {
"./CartPage": "./src/CartPage.jsx",
},
shared: ["react", "react-dom"], // 共享依赖,避免重复加载
}),
],
};
1
2
3
4
5
6
7
// 基座 webpack.config.js(消费方)
new ModuleFederationPlugin({
name: "hostApp",
remotes: {
subAppCart: "subAppCart@https://cart.example.com/remoteEntry.js",
},
});
1
2
// 基座里像导入本地模块一样用远程组件
const CartPage = React.lazy(() => import("subAppCart/CartPage"));

MF 的优点是没有沙箱开销、加载即模块,但前提是各应用技术栈相近(至少都要是 Webpack/Rspack 体系),且隔离靠约定而非强制。

3.3 qiankun:基座 + 子应用

qiankun 是对 single-spa 的封装,提供”基座注册子应用 + HTML Entry 加载 + JS/CSS 沙箱”的一体化方案,对存量多技术栈系统最友好:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 基座:注册子应用
import { registerMicroApps, start } from "qiankun";

registerMicroApps([
{
name: "sub-vue",
entry: "//sub-vue.example.com", // HTML Entry,直接给子应用地址
container: "#subapp-viewport",
activeRule: "/vue",
},
{
name: "sub-react",
entry: "//sub-react.example.com",
container: "#subapp-viewport",
activeRule: "/react",
},
]);

start();

四、应用隔离:微前端的命门

多应用共存,最怕两件事:样式串台和JS 全局变量互相覆盖。

4.1 CSS 隔离

方案 A:Shadow DOM(最彻底)
把子应用挂到 Shadow Root 下,内部样式天然不外泄、外部样式也进不来。缺点是对依赖全局样式的第三方组件库(如 Ant Design 的弹窗 getPopupContainer)支持很不友好,实际落地成本高。

方案 B:运行时样式改写(qiankun 采用)
qiankun 加载子应用时,会重写 <style> 内容,给每条选择器加 data-qiankun-xxx 作用域前缀,相当于运行时版 CSS Modules:

1
2
3
4
5
6
start({
sandbox: {
experimentalStyleIsolation: true, // 给样式加 scoped 前缀
// strictStyleIsolation: true, // 改用 Shadow DOM 隔离(更严格但兼容性差)
},
});

方案 C:约定式命名空间
团队约定所有子应用样式加前缀(BEM 或 app-name-*),靠规范而非技术强制。成本低,但依赖人工纪律,容易漏。

4.2 JS 沙箱

子应用常会往 window 上挂全局变量、定时器、事件监听,若不做隔离,多个子应用之间会相互污染。

快照沙箱(legacy):子应用挂载前记录 window 快照,卸载时还原。实现简单,但不支持同时挂载多个子应用。

Proxy 沙箱(主流):为每个子应用创建一个假的 window(基于 Proxy),所有全局读写都落到这个假对象上,真实 window 毫发无损:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function createProxySandbox() {
const fakeWindow = {};
const proxy = new Proxy(fakeWindow, {
get(target, key) {
// 优先读沙箱自己的变量,没有再委托给真实 window
if (key in target) return target[key];
return window[key];
},
set(target, key, value) {
target[key] = value; // 写操作只落在沙箱内,不污染全局
return true;
},
});
return proxy;
}

qiankun 的 ProxySandbox 正是这个思路——单例模式下多个子应用共享一份 fakeWindow(串行运行),多例模式(qiankun 默认 strict 之外)下每个子应用独立 fakeWindow(可并行)。卸载子应用时把它的 fakeWindow、定时器、事件监听一并清理即可。

用 with(proxy) { eval(code) } 可以让子应用代码里的”裸写全局变量”也走 Proxy 拦截——这正是 qiankun 执行子应用入口脚本时做的事。

五、应用通信

方式 适用 缺点
props 下发 基座 → 子应用单向传参 反向需回调
initGlobalState 跨应用共享状态 需约定 state 结构
自定义事件 / 发布订阅 松耦合通知 易变成隐式依赖

qiankun 内置的全局状态最常用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 基座
import { initGlobalState } from "qiankun";
const actions = initGlobalState({ user: null, theme: "light" });
actions.onGlobalStateChange((state, prev) => {
console.log("state changed", state, prev);
});

// 子应用(通过 mount 生命周期拿到 actions)
export function mount(props) {
props.onGlobalStateChange((state) => {
/* 响应全局状态 */
});
props.setGlobalState({ user: { id: 1 } });
}

六、路由与加载

  • activeRule:基座根据 URL 前缀决定激活哪个子应用(如 /vue 激活 Vue 子应用)。
  • HTML Entry vs JS Entry:HTML Entry 直接给子应用线上地址,基座拉取 HTML 再解析出 JS/CSS,对子应用侵入最小;JS Entry 需要子应用额外导出生命周期,耦合更重。
  • 按需加载 + 预加载:非激活子应用可等路由命中再加载;对高频访问的子应用,基座空闲时用 requestIdleCallback 预取 remoteEntry,首屏切换更顺滑。

七、部署与版本治理

微前端真正的收益在”独立部署”:每个子应用构建产物独立发到 CDN,基座只改一份路由配置就能上下线子应用。但要注意:

  • 基座与子应用版本解耦:子应用发版不应要求基座同步发版;基座只依赖子应用暴露的契约(路由、生命周期、通信 state)。
  • 灰度与回滚:子应用按 entry 指向不同环境(灰度域名),出问题切回旧 remoteEntry 即可,不影响基座。
  • 依赖共享:MF 的 shared 要约定好主版本,避免 React 双实例导致 hooks 报错。

八、什么时候不该上微前端

微前端不是银弹,它带来显著的复杂度成本:

  • 团队 < 3 个、应用 < 5 个,单体 SPA + Monorepo 往往更省心;
  • 没有独立部署诉求,纯粹为了”炫技”上微前端,性价比极低;
  • 强实时协同、动画连贯性要求极高的场景,多运行时切换会有白屏/闪烁。

能用 Monorepo + 模块联邦解决”拆包”的,就别急着引入完整基座模式。

九、10 条生产避坑清单

  1. 子应用必须导出生命周期(bootstrap/mount/unmount),否则基座无法调度。
  2. 公共依赖走 shared / CDN 单例,避免 React/Vue 双实例导致 hooks 失效。
  3. JS 沙箱记得卸载清理定时器、window 事件、全局变量,否则内存泄漏与串台。
  4. 样式隔离优先用运行时改写,Shadow DOM 对第三方弹窗组件库不友好。
  5. 子应用路由用相对基路径(如 base: '/vue'),避免与基座路由冲突。
  6. 通信 state 结构要版本化,字段变更要考虑旧子应用兼容。
  7. 预加载要有节制的做,别在首屏阶段抢占带宽。
  8. 部署解耦:子应用发版不要求基座同步发版,否则失去核心收益。
  9. 统一错误边界,单个子应用崩溃不应让整站白屏(基座兜底)。
  10. 约定全局变量命名空间,即使有沙箱,也别在真实 window 上裸挂业务对象。

微前端把”团队协作的复杂度”从构建时转移到了运行时——它用一层基座和沙箱,换来了技术栈自由与独立部署。选型时记住:先问”要不要独立部署、要不要多技术栈”,再决定用 iframe / Module Federation / qiankun。能靠 Monorepo + 模块联邦解决的,就不必上完整基座。