微前端实战:Module Federation 与 Single-SPA
微前端架构:从概念到落地,用 Module Federation 与 Single-SPA 构建企业级应用
引言:为什么需要微前端
当单体前端项目膨胀到一定程度,构建时间变长、协作冲突频繁、技术栈锁定——这正是微前端试图解决的问题。它借鉴了微服务的思想,将前端应用拆分为多个更小、更松散耦合的“微应用”,每个微应用可独立开发、测试、部署,最终在主应用中无缝聚合。
本教程面向对微前端感兴趣的初中级开发者,通过 Module Federation 和 Single-SPA 的实战演示,帮助你从理解核心原理到动手搭建一个可运行的微前端架构。
第一部分:微前端核心概念
在动手之前,先建立统一认知。
什么是微前端
- 技术定义:一种将 Web 界面拆分为独立、可由不同团队端到端负责的应用单元,并组合成一个统一产品的架构风格。
- 核心价值:
- 独立部署:每个微应用有独立的 CI/CD 管道。
- 技术栈无关:允许 Angular、React、Vue 等不同框架共存。
- 团队自治:各团队可按自己的节奏迭代。
- 与传统 SPA 的对比:
- 单体 SPA:单个仓库、统一构建、单一发布。
- 微前端:多仓库、独立构建、独立发布,运行时组合。
关键设计原则
- 技术不可知:主框架不应限制子应用使用的技术栈。
- 隔离性:JavaScript 和 CSS 需作用域隔离,避免冲突。
- 通信标准:通过明确的接口或事件总线进行跨应用通讯。
- 渐进式提升:可从简单外壳逐步迁移现有项目。
第二部分:技术选型与方案对比
实现微前端的方式有很多,这里聚焦业界两大主流方案,并直接对比其优势与适用场景。
| 特性 | Module Federation (Webpack 5) | Single-SPA |
|---|---|---|
| 工作方式 | 运行时动态加载远程模块,构建时确定依赖关系 | 路由驱动,加载并挂载/卸载整个应用的生命周期 |
| 共享依赖 | 原生支持,可配置共享库防重复加载 | 需自行处理(或在根配置中手动引入公共依赖) |
| 技术栈混合 | 更擅长在同一框架不同版本间共享,跨框架需额外封装 | 天然支持任意框架,因为每个微应用独立运行 |
| 入门复杂度 | 中等,需理解 webpack 配置 | 较低,API 简洁,文档齐全 |
| 最佳场景 | 同一技术栈(如都是 React),需极致共享模块和依赖 | 需集成不同框架的遗留系统,或严格按子域拆分 |
本教程采用组合方案:用 Single-SPA 作为整个微前端的组织者(根配置),负责路由分发和应用生命周期管理;用 Module Federation 实现微应用之间的模块动态共享与加载。这样兼顾了路由灵活性和资源复用能力。
第三部分:环境搭建与目录结构
开始实战前,确保环境:
- Node.js >= 16
- npm 或 yarn
- 现代浏览器
我们将创建三个子项目:
root-config:Single-SPA 根配置,负责路由分发。app1:一个 React 微应用,通过 Module Federation 暴露组件。app2:一个 Vue 3 微应用,消费 app1 暴露的组件,同时也被根配置加载。
最终目录结构:
micro-frontend-demo/
├── root-config/ # Single-SPA 根配置
│ ├── src/
│ │ └── index.ejs
│ │ └── microfrontend-layout.html
│ ├── webpack.config.js
│ └── package.json
├── app1/ # React 微应用(提供商)
│ ├── src/
│ ├── webpack.config.js
│ └── package.json
└── app2/ # Vue 3 微应用(消费者 + 独立应用)
├── src/
├── webpack.config.js
└── package.json
第四部分:构建 React 微应用(app1)—— 使用 Module Federation 暴露模块
初始化项目与依赖
mkdir app1 && cd app1
npm init -y
npm install react react-dom react-router-dom single-spa-react
npm install -D webpack webpack-cli webpack-dev-server html-webpack-plugin @babel/core babel-loader @babel/preset-react
webpack 配置:启用 Module Federation
创建 webpack.config.js:
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;
const path = require('path');
module.exports = {
entry: './src/index.js',
mode: 'development',
devServer: { port: 3001 },
output: { publicPath: 'auto' },
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
use: { loader: 'babel-loader', options: { presets: ['@babel/preset-react'] } }
}
]
},
plugins: [
new ModuleFederationPlugin({
name: 'app1',
filename: 'remoteEntry.js', // 对外暴露的入口文件
exposes: {
'./Header': './src/Header', // 暴露一个 React 组件
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true },
},
}),
new HtmlWebpackPlugin({ template: './public/index.html' })
],
};
组件与入口
创建 src/Header.js:
import React from 'react';
const Header = ({ title }) => {
return <header style={{ background: '#6200ee', color: 'white', padding: '10px' }}>
<h2>{title || 'Micro App1 Header'}</h2>
</header>;
};
export default Header;
创建 src/App.js:
import React from 'react';
import { BrowserRouter, Link } from 'react-router-dom';
import Header from './Header';
const App = () => (
<BrowserRouter>
<Header title="App1" />
<p>This is App1 content.</p>
</BrowserRouter>
);
// Single-SPA 需要 lifecycle 导出
export const { bootstrap, mount, unmount } = singleSpaReact({
React,
ReactDOM: require('react-dom'),
rootComponent: App,
errorBoundary: (err) => <div>Error</div>,
});
注意:Single-SPA 生命周期集成稍后会在打包时一并处理,此处暂简化,实际可使用
single-spa-react帮助工具。
将应用作为 Single-SPA 的 parcel 运行,但也可以独立启动。为演示 MF 暴露,我们直接通过开发服务器提供 remoteEntry。
启动 npm start,此后 http://localhost:3001/remoteEntry.js 即可访问暴露的模块。
第五部分:构建 Vue 微应用(app2)—— 消费远程模块,并注册到 Single-SPA
项目初始化
mkdir app2 && cd app2
npm init -y
npm install vue@3 vue-router@4 single-spa-vue
npm install -D webpack webpack-cli webpack-dev-server vue-loader@next @vue/compiler-sfc
webpack 配置:作为消费者加载远程模块
const { ModuleFederationPlugin } = require('webpack').container;
const { VueLoaderPlugin } = require('vue-loader');
module.exports = {
entry: './src/main.js',
mode: 'development',
devServer: { port: 3002 },
output: { publicPath: 'auto' },
resolve: { extensions: ['.js', '.vue'] },
module: {
rules: [
{ test: /\.vue$/, loader: 'vue-loader' },
{ test: /\.js$/, loader: 'babel-loader' }
]
},
plugins: [
new ModuleFederationPlugin({
name: 'app2',
remotes: {
// 远程模块名称: 格式为「名称@地址/remoteEntry.js」
app1: 'app1@http://localhost:3001/remoteEntry.js',
},
shared: {} // 可共享 vue,但为了避免版本冲突先用空
}),
new VueLoaderPlugin(),
new HtmlWebpackPlugin({ template: './public/index.html' })
],
};
在 Vue 组件中使用远程的 Header
创建 src/App.vue:
<template>
<div>
<RemoteHeader :title="dynamicTitle" />
<p>App2 (Vue) consuming React component!</p>
</div>
</template>
<script>
import { defineAsyncComponent, ref } from 'vue';
export default {
components: {
RemoteHeader: defineAsyncComponent(() => import('app1/Header')),
},
setup() {
const dynamicTitle = ref('Header rendered by Vue');
return { dynamicTitle };
}
};
</script>
远程模块通过 import('app1/Header') 动态加载,Vue 的 defineAsyncComponent 将其包装为异步组件。
Single-SPA 生命周期集成
在 src/main.js 中,使用 single-spa-vue 创建生命周期:
import { createApp, h } from 'vue';
import singleSpaVue from 'single-spa-vue';
import App from './App.vue';
const vueLifecycles = singleSpaVue({
createApp,
appOptions: {
render() { return h(App); }
},
// 可传递 VueRouter 实例等
});
export const { bootstrap, mount, unmount } = vueLifecycles;
为了让 Single-SPA 找到这个应用,我们需要将入口打包为 SystemJS 格式?实际上 Single-SPA 推荐微应用打包成 SystemJS 或直接使用 esm。这里我们适配 Module Federation 的异步加载,根配置会以动态 import 方式加载 app2 的入口。所以需配置 output.libraryTarget: 'system'?Module Federation 产生异步块,在 Single-SPA 上下文中可直接使用动态导入。
更简单的方法:根配置中使用 System.import 或直接 import()。我们让 app2 构建时产出 main.js 作为入口,并在根配置通过 url 加载。
修改 webpack output:
output: {
// ... 其余不变
library: { type: 'system' }, // 生成 System.register 模块供 Single-SPA 使用
publicPath: 'auto',
}
然后需要新增配置使微应用的 chunk 能被正确加载。但 Module Federation 与 System 格式混合可能带来复杂度。这里推荐使用 Single-SPA 官方的 @single-spa/welcome 插件?不,我们采用另一种干净方案:
替代方案:使用 single-spa 本身的 import-maps 或直接 import()。Module Federation 的 remote 加载是异步的,可以直接在根配置中写 import('app2@http://localhost:3002/main.js')。通过将 app2 的入口配置成输出一个包含 lifecycle 的模块即可,无需 system 格式。设置:
output: {
filename: 'main.js',
library: { type: 'module' }, // 实验性,需要 script type module
publicPath: 'auto',
},
experiments: { outputModule: true },
浏览器支持模块类型后,可以直接 import。但为了兼容,我们使用 Single-SPA 推荐的 "in-browser module" 方式:打包成 ES module 或 System。这里为简化,我们沿用 System 格式,并关闭 Module Federation 的 shared scope 冲突(使用 library.type: 'system' 确实会与 Module Federation 的动态加载冲突,因为 System 格式不能同时使用异步块)。因此调研发现:Module Federation 和 System 格式不能直接混用。
因此,我们调整策略:app2 不采用自身作为一个完整的 Single-SPA 应用,而是作为独立的微应用,通过 Module Federation 加载 app1 的组件,但在根配置 app2 的入口文件依然需要暴露 lifecycle。我们可以将 Vue 应用单独打包,但不使用 System 格式,而是将生命周期导出为一个全局变量,并让根配置通过动态 script 加载后调用。但更现实的做法是:根配置(root-config)也使用 Module Federation,这样所有应用都通过 MF 连接,而 Single-SPA 只作为路由框架。
这就是常见的 “MF + Single-SPA” 组合:根配置本身也是一个 webpack 项目,配置 Module Federation 的 remotes 指向所有子应用,然后每个子应用暴露入口(bootstrap/mount/unmount),根配置通过 import 动态加载这些入口并调用 Single-SPA 注册。这样所有子应用都无需 System 构建,直接使用标准 ES 模块,并且共享依赖可以由 MF 管理。
下面我们就按此模式完成 root-config。
第六部分:根配置(root-config)—— Single-SPA 组织者
创建根配置项目
mkdir root-config && cd root-config
npm init -y
npm install single-spa
npm install -D webpack webpack-cli webpack-dev-server html-webpack-plugin
webpack 配置:作为远程消费者的容器
const { ModuleFederationPlugin } = require('webpack').container;
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
entry: './src/index.js',
mode: 'development',
devServer: { port: 9000, historyApiFallback: true },
output: { publicPath: '/' },
plugins: [
new ModuleFederationPlugin({
name: 'root',
remotes: {
app1: 'app1@http://localhost:3001/remoteEntry.js',
app2: 'app2@http://localhost:3002/remoteEntry.js',
},
shared: {},
}),
new HtmlWebpackPlugin({ template: './public/index.html' }),
],
};
注意,app2 现在也需要一个 remoteEntry.js 来暴露自己的生命周期,所以改造 app2 的 webpack 配置,增加 exposes:
exposes: {
'./App2Lifecycle': './src/main', // 暴露生命周期对象
}
相应地,app2 的 main.js 导出 bootstrap, mount, unmount。
根配置的 Single-SPA 注册与路由
src/index.js:
import { registerApplication, start } from 'single-spa';
// 动态导入生命周期函数
registerApplication({
name: '@org/app1',
app: () => import('app1/App1Lifecycle'), // app1 也需暴露 lifecycle
activeWhen: ['/app1']
});
registerApplication({
name: '@org/app2',
app: () => import('app2/App2Lifecycle'),
activeWhen: ['/app2']
});
start({
urlRerouteOnly: true,
});
app1 同样需要在自己项目里增加 exposes: { './App1Lifecycle': './src/root.component' } 并导出生命周期。
现在所有应用都通过 Module Federation 连接,根配置负责路由。
共享依赖优化
在 MF 配置中合理使用 shared 避免加载多次 React、Vue。在 app1 中 shared 配置 react 和 react-dom;app2 中 shared vue;根配置可共享 routing 相关库。
第七部分:实践中的常见问题与解决方案
1. 样式隔离
- 使用 CSS Modules 或 CSS-in-JS。
- 通过 Shadow DOM 封装(Single-SPA 提供
mountParcel模式可配合)。 - 命名约定:给每个微应用的根元素添加唯一类名前缀。
2. 跨应用通信
- Props 传递:通过 Single-SPA 的
mount函数传递自定义 props。 - Custom Events:
window.dispatchEvent发射全局事件,其他微应用监听。 - 共享 Store:不推荐直接共享状态,但可通过共享的事件总线或独立的状态管理模块(如 Redux 动态注册)实现。
- Module Federation 共享模块:可以将共用的工具函数、API 客户端作为 MF 的
shared模块。
3. 性能优化
- 预加载远程入口:在根配置中提前触发
import('app1/..')。 - 设置共享依赖的
eager: true避免请求瀑布流。 - 使用 HTTP/2 和多路复用加速远程模块下载。
4. 版本兼容与降级
- 使用
requiredVersion限制共享库版本。 - 在
shared中配置strictVersion: false允许不匹配时的回退。 - 通过
singleton: true保证只加载一个实例。
第八部分:生产环境部署考量
- 独立构建与持续部署:每个微应用单独构建并上传到 CDN,
remoteEntry.js地址指向生产 CDN。 - 环境变量管理:通过 Webpack 的
DefinePlugin为每个微应用注入不同 API 地址。 - 异常隔离:实现 error boundary,防止单个应用崩溃影响整个页面。
- 监控:集成 Sentry 等错误收集,并标记是哪个微应用报错。
- 缓存策略:
remoteEntry.js设置短期缓存或不缓存,确保版本更新实时;子应用 chunk 使用 contenthash 文件名长期缓存。
总结
通过本教程,你已掌握使用 Module Federation 和 Single-SPA 构建微前端架构的完整步骤:从单一组件暴露、跨技术栈消费,到根配置的路由组织。这套组合充分发挥了 MF 的模块共享优势和 Single-SPA 的路由灵活性。
下一步建议:动手实现一个包含三个不同技术栈微应用的示例工程,并尝试加入共享状态管理,逐步深化对微前端工程化的理解。记住,微前端不是银弹,它带来独立部署便利的同时也引入了运行时复杂度,始终以团队和项目的实际需求为出发点进行选型。