微前端架构实践:qiankun、Module Federation 与工程治理

引言 微前端不是一个新的框架,而是一套把单体前端拆分为多个可独立开发、独立部署、独立运行的子应用的架构模式。它解决的核心痛点是大型团队的协作边界与发布耦合。本文从动机出发,拆解 qiankun(single-spa)的加载与沙箱机制、webpack 5 Module Federation 的运行时共享原理、样式/路由/状态隔离方案,并对两种主流路线的取舍给出工程化建议。

引言

微前端不是一个新的框架,而是一套把单体前端拆分为多个可独立开发、独立部署、独立运行的子应用的架构模式。它解决的核心痛点是大型团队的协作边界与发布耦合。本文从动机出发,拆解 qiankun(single-spa)的加载与沙箱机制、webpack 5 Module Federation 的运行时共享原理、样式/路由/状态隔离方案,并对两种主流路线的取舍给出工程化建议。文中涉及的 qiankun、single-spa、Module Federation 均为真实存在的生产级方案。


一、微前端的动机与收益

1.1 单体前端的组织困境

当产品演进到一定规模,单体前端会暴露三类问题:

  • 发布耦合:任何一个模块的改动都需要整站回归、整体发布,小步快跑变成奢望。
  • 技术栈锁定:老模块用 AngularJS,新团队想上 Vue 3,但在单体内无法共存,只能重写或继续妥协。
  • 团队边界模糊:多团队在同一代码库内频繁冲突,代码所有权难以界定。

1.2 微前端的核心收益

微前端将整个应用拆成主应用(基座)+ 若干子应用。每个子应用可以拥有独立的仓库、技术栈、发布节奏和团队。主应用负责统一壳层(Shell):顶部导航、登录态、全局样式、路由分发。

收益说明代价
独立部署子应用可单独发版、回滚版本兼容矩阵管理
技术栈解耦各子应用可自由选型共享能力需要标准接口
团队自治代码所有权清晰基建与规范仍需统一
渐进迁移老系统可逐步被替换双轨期通信与体验一致性

微前端的本质是组织架构问题的技术映射:它把"一个大型应用"重新组织为"一个平台 + 多个产品"。


二、qiankun 机制:single-spa 之上的开箱即用

2.1 qiankun 与 single-spa 的关系

qiankun 是基于 single-spa 的上层封装。single-spa 负责最核心的应用注册与生命周期调度,但它要求开发者自行处理 HTML 入口解析、JS 沙箱、样式隔离,这些恰恰是最容易出错的环节。qiankun 把这些能力内置,提供开箱即用的接入体验。

主应用通过 registerMicroApps 注册子应用,配置其入口 HTML 地址与路由激活规则:

import { registerMicroApps, start } from 'qiankun';

registerMicroApps([
  {
    name: 'react-app',
    entry: '//localhost:7101',
    container: '#micro-app-container',
    activeRule: '/app/react',
  },
  {
    name: 'vue-app',
    entry: '//localhost:7102',
    container: '#micro-app-container',
    activeRule: '/app/vue',
  },
]);

start();

2.2 子应用生命周期

qiankun 要求子应用暴露三个生命周期函数,并在入口文件中通过 qiankun 的全局接口导出:

// 子应用入口 main.js
import { bootstrap, mount, unmount } from './app';
import { renderWithQiankun, qiankunWindow } from 'vite-plugin-qiankun';

renderWithQiankun({
  bootstrap() {},
  async mount(props) {
    // props 中包含主应用传入的通信数据、路由等
    render(props);
  },
  async unmount() {
    // 卸载:清理事件监听、销毁实例
  },
});

// 非 qiankun 环境(独立运行时)也需要可用的导出
if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
  render({});
}

mount 与 unmount 是沙箱生命周期中最重要的两个钩子——前者完成子应用挂载,后者必须干净地清理副作用,否则子应用切换后会产生事件泄漏与内存残留。

2.3 加载机制:从 HTML Entry 到资源解析

qiankun 支持两种入口方式:JS Entry 与 HTML Entry。实践中推荐 HTML Entry——qiankun 会 fetch 入口 HTML,解析其中的 <script> 与 <link>,把脚本逐一执行到主应用的沙箱环境中,样式标签也一并接管。这样子应用无需感知 qiankun 存在,理论上可以零改造接入。

入口方式优点缺点
JS Entry产物确定、静态分析容易子应用必须按约定改造
HTML Entry子应用可零改造需要处理外链资源、跨域 fetch

三、Module Federation:webpack 5 的运行时模块共享

3.1 从构建期共享到运行时共享

webpack 5 引入 Module Federation(MF),让多个独立构建的应用可以在运行时互相加载彼此的模块,而无需把依赖打包进每个应用。与 qiankun"加载整个子应用"不同,MF 的粒度更细——它可以只共享一个组件、一个工具库。

宿主应用(Host)在 webpack 配置中声明需要哪些远程模块:

// webpack.config.js(宿主应用)
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        remoteApp: 'remoteApp@http://localhost:7201/remoteEntry.js',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true },
      },
    }),
  ],
};

远程应用(Remote)暴露自己的组件:

// webpack.config.js(远程应用)
new ModuleFederationPlugin({
  name: 'remoteApp',
  exposes: {
    './ProductList': './src/components/ProductList.jsx',
    './PriceBadge': './src/components/PriceBadge.jsx',
  },
  shared: { react: { singleton: true } },
});

3.2 运行时加载远程组件

宿主应用通过动态 import 引用远程模块,webpack 在运行时解析 remoteEntry.js:

import { Suspense, lazy } from 'react';

const ProductList = lazy(() => import('remoteApp/ProductList'));

function App() {
  return (
    <Suspense fallback={<div>加载远程组件…</div>}>
      <ProductList />
    </Suspense>
  );
}

3.3 shared 与 singleton

shared 配置用于共享依赖,singleton: true 表示全应用只保留一份 React 实例。这能避免"两份 React 导致 hooks 失效"的经典问题——两个 React 实例并存时,useState 等 hooks 会因内部 dispatcher 冲突而报错。requiredVersion 则在共享失败时提供降级提示。

配置项作用常见坑
singleton全局唯一实例版本不一致时仍会各自加载
requiredVersion声明版本约束约束过严导致无法共享
shareScope共享作用域多 scope 时需显式指定

四、样式隔离与路由隔离

4.1 样式隔离方案

子应用之间的 CSS 互相污染是微前端最隐蔽的坑。qiankun 默认提供三种层级:主应用全局样式、子应用在激活时注入的样式、运行时动态样式。常见的隔离手段包括:

  • 前缀命名空间(BEM/约定):所有子应用类名强制加前缀,如 .react-app-product-。最可靠但依赖规范。
  • CSS Modules / scoped CSS:编译期给类名加 hash,天然隔离。
  • Shadow DOM:qiankun 支持 sandbox: { strictStyleIsolation: true } 把子应用包裹进 Shadow DOM,样式完全隔离,但需处理弹出层(弹窗挂载在 body)穿透的问题。
/* 子应用内的 scoped 样式,Vue 单文件组件示例 */
<style scoped>
.product-card {
  border: 1px solid var(--color-border);
  border-radius: 8px;
}
</style>

4.2 路由隔离与主子应用路由协调

每个子应用必须明确自己的路由前缀,主应用依据 activeRule 判断何时激活哪个子应用。React Router 子应用需要基于前缀创建 basename:

// 子应用 React Router 配置
import { BrowserRouter, Routes, Route } from 'react-router-dom';

const { qiankunWindow } = require('vite-plugin-qiankun');
const base = qiankunWindow.__POWERED_BY_QIANKUN__ ? '/app/react' : '/';

export function Root() {
  return (
    <BrowserRouter basename={base}>
      <Routes>
        <Route path="/products" element={<ProductList />} />
        <Route path="/products/:id" element={<ProductDetail />} />
      </Routes>
    </BrowserRouter>
  );
}

路由隔离的要点:子应用内部跳转使用相对路径或基于 basename;跨应用跳转通过主应用路由或通信机制,避免子应用直接操作 window.location 破坏激活判定。


五、状态隔离与沙箱

5.1 JavaScript 沙箱

qiankun 的沙箱通过在 window 上做代理 + 快照实现。激活子应用时,把子应用对 window 的读写代理到一个独立对象上;子应用卸载后,把修改过的属性快照存起来,恢复主应用环境,下次激活时再恢复快照。这样即使子应用代码直接修改全局变量,也不会污染主应用或其他子应用。

qiankun 提供 sandbox: true(默认)与更严格的 strictStyleIsolation。从 v2.x 起支持 legacySandbox 与新的基于 Proxy 的实现,后者性能更好。对不支持 Proxy 的旧浏览器可退化为快照式沙箱。

沙箱保护的是主应用环境的完整,而不是子应用之间的数据隔离。跨子应用共享登录态与用户信息仍需要显式的通信契约,不要指望沙箱替你解决状态共享。

5.2 状态隔离的建议

需要澄清的是:沙箱隔离的是全局环境,而不是状态共享。微前端架构里,子应用之间、子应用与主应用之间仍然需要共享登录态、用户信息等。状态隔离的正确姿势是"共享最小化 + 接口标准化":

  • 登录态、用户信息由主应用统一持有,通过初始化 props 或全局事件广播给子应用。
  • 子应用内部的业务状态彼此独立,不共享全局 Store。
  • 通信数据尽量通过主应用转发,避免子应用直接耦合。
// 主应用向子应用注入 props
registerMicroApps([
  {
    name: 'react-app',
    entry: '//localhost:7101',
    container: '#app',
    activeRule: '/app/react',
    props: {
      userInfo: { id: 1, name: 'Leeting' },
      token: 'xxx',
      onNavigate: (path) => history.push(path),
    },
  },
]);

六、公共依赖与共享策略

6.1 共享的粒度决策

公共依赖共享需要回答三个问题:共享什么(react、UI 库、工具函数)、以什么方式共享(构建期 externals、运行时 MF、CDN)、谁来治理版本(主应用统一、子应用自治)。qiankun 生态常用 externals 把公共依赖排除出子应用产物,运行时从主应用全局变量取用:

// 子应用 vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      external: ['react', 'react-dom'],
      output: {
        globals: {
          react: 'React',
          'react-dom': 'ReactDOM',
        },
      },
    },
  },
});

6.2 双 React 问题的根因

“子应用用了自己的 React,主应用也有一份 React"会造成 hooks 报错、事件系统重复。根治手段:

  1. 统一运行时:所有子应用通过 externals/MF 共享主应用提供的同一份 React。
  2. 或者各子应用完全自包含:不共享 React,但代价是包体积翻倍、内存多一份实例。
  3. 折中:共享 react/react-dom,UI 组件库如 antd 保持 singleton。
共享方式复杂度版本一致性包体积
externals(qiankun)低主应用统一最优
Module Federation shared中运行时协商次优
完全自包含无各自独立冗余

七、通信方案:从事件总线到消息契约

7.1 常用通信机制

微前端的通信是"既要又要"的难题:既要松耦合,又要有可靠的消息契约。主流机制有三类:

  • props 透传(qiankun 初始化注入):适合一次性配置数据(登录态、基础配置)。
  • 全局事件总线(mitt 等):适合低频广播,但事件名无类型约束,容易失控。
  • 共享 Store / URL:适合跨应用同步的筛选条件、导航状态。
// 用 mitt 实现跨子应用事件广播(主应用持有)
import mitt from 'mitt';

export const globalBus = mitt();

// 子应用 A 发布
globalBus.emit('user:logout', { reason: 'session-expired' });

// 子应用 B 订阅
globalBus.on('user:logout', ({ reason }) => {
  // 清理本应用内的会话缓存
  console.log(`登出原因:${reason}`);
});

7.2 通信的最佳实践

实践中最可靠的不是"任意事件随意广播”,而是建立消息契约层——主应用维护一张消息清单,定义事件名、载荷类型、方向(主→子、子→主、子→子)。可以把契约写进一个共享的 npm 包,或通过主应用 props 注入一份只读的契约对象。对 TypeScript 团队,建议用类型化的事件映射定义:

type GlobalEvents = {
  'user:logout': { reason: string; at: number };
  'route:change': { path: string };
  'theme:update': { theme: 'light' | 'dark' };
};

这样广播与订阅都能获得编译期类型检查,大幅降低通信事故率。


八、部署与版本治理

8.1 独立部署的产物形态

子应用独立部署意味着每个子应用有自己的域名路径或 CDN 前缀。qiankun 场景下主应用通过 entry 指向子应用的 HTML;MF 场景下宿主应用通过 remoteEntry.js 指向远程模块清单。关键约束是部署顺序:主应用不应在子应用未上线时引用不存在的地址,否则整站白屏。

推荐的产物布局:

# CDN 上的目录结构
/apps/
  main/        # 主应用
    index.html
  react-app/   # 子应用 A
    index.html
    assets/
  vue-app/     # 子应用 B
    index.html
    assets/

8.2 版本兼容矩阵

子应用独立发布后,主应用与各子应用之间形成隐式契约:主应用传给子应用的 props 结构、子应用暴露的公共模块接口,都必须向前兼容。治理手段:

  • 对外接口变化遵循语义化版本,破坏性变更前发布兼容期。
  • 主应用建立"已支持子应用版本"清单,激活前做版本校验。
  • 灰度发布:先让部分流量命中新版本子应用,观察错误率再全量。
治理工具解决什么局限
接口版本号契约兼容性需要子应用配合
灰度发布降低变更风险需要流量基础设施
主应用版本清单明确支持范围手动维护成本高

九、qiankun vs Module Federation 对比与常见坑

9.1 两大路线对比矩阵

维度qiankun(single-spa)Module Federation
抽象层级应用级模块级
核心机制应用注册 + 生命周期 + 沙箱运行时远程模块加载
隔离能力JS 沙箱 + 样式隔离内置需自行实现沙箱
技术栈约束几乎不约束绑定 webpack 5(Rspack 兼容)
接入改造子应用需暴露生命周期构建配置声明
典型场景老系统渐进改造、团队自治微组件共享、依赖复用
社区成熟度国内生态成熟海外生态活跃

9.2 高频踩坑清单

  1. 双 React 实例:hooks 报错 Invalid hook call。排查方式:检查子应用产物里是否内联了 react。
  2. 沙箱下定时器/事件泄漏:unmount 中未清除 setInterval 与 DOM 事件监听,导致切换应用后主应用卡顿。
  3. 样式互相污染:全局 reset 被子应用覆盖。应对:命名前缀 + scoped + 必要时 Shadow DOM。
  4. 路由激活错误:子应用 hash 路由与主应用 history 路由混用导致不激活。统一路由模式是关键。
  5. MF 远程地址写死:本地指向 localhost 的 remoteEntry.js 部署后失效。应使用环境变量注入地址。
  6. 依赖共享版本冲突:requiredVersion 配置过严导致运行时告警。应放宽为 ^ 范围或完全 singleton。

9.3 何时不要用微前端

微前端并非银弹。团队小于 20 人、应用规模中等、技术栈统一的情况下,Monorepo + 单体应用往往更简单高效。微前端的收益来自"组织规模"而非"技术规模"——没有真正的多团队独立发布诉求时,引入微前端只会徒增沙箱、通信、部署三层复杂度。

一个可靠的判断信号:如果你说不出「哪些团队需要独立发布」的具体清单,那你很可能不需要微前端。

# 一个典型的主应用启动命令(开发环境)
npm run dev:main
# 子应用 A 单独启动
cd apps/react-app && npm run dev:7101

结语

微前端是组织架构问题的技术回应。qiankun 通过 single-spa 的应用生命周期 + 沙箱体系,让多团队应用共存成为可能;Module Federation 则把共享粒度下沉到模块级,适合依赖复用与渐进整合。两者的共同前提是:明确主应用与子应用的边界、建立消息契约、治理版本兼容矩阵。

如果你正处在要不要上微前端的决策点,我的建议是:先回答"是否有多个团队需要独立部署",再回答"存量系统是否需要渐进迁移"。两个答案都是"是"时才值得投入;否则,一个治理良好的 Monorepo 单体应用,往往比一套勉强运转的微前端系统更可靠。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「frontend」更多文章

  1. CSS 架构与样式方案:从方法论到现代 CSS 新特性
  2. 可访问性与国际化:WCAG 2.2、ARIA 与 i18n 工程实践
  3. SSR/SSG 渲染模式全景:Next.js App Router、流式渲染与岛屿架构