为什么需要前端微服务?
随着业务增长,单体前端应用逐渐变得臃肿:构建慢、部署耦合、技术栈难以升级。微前端(Micro Frontends)将前端拆分为多个可独立开发、测试、部署的微应用,而 Webpack 5 的 Module Federation 让这一架构变得前所未有的简单。
本文将以一个真实场景为例:一个电商后台,包含 主应用(容器)、商品微应用、订单微应用。我们将从零搭建,实现运行时模块共享、独立部署、跨应用通信。
> 注意:本文基于 Webpack 5.75+、React 18、React Router 6,所有代码均可直接运行。
1. 架构设计
- 主应用(host):负责布局、路由分发、共享依赖(React、ReactDOM)。
- 微应用(remote):独立开发部署,暴露
./App组件,可被主应用动态加载。 - 通信机制:通过自定义事件(CustomEvent)和共享状态库(如 Zustand)实现跨应用通信。
- 路由:主应用使用 React Router 的
*通配符挂载微应用,微应用内部使用MemoryRouter避免路由冲突。
2. 环境准备
创建三个独立项目(建议使用 pnpm workspace 管理):
mkdir micro-frontend-demo && cd micro-frontend-demo
pnpm init
pnpm create react-app host --template typescript
pnpm create react-app products --template typescript
pnpm create react-app orders --template typescript
安装 Webpack 5 及相关依赖(CRA 已内置 Webpack 5,但需覆盖配置)。推荐使用 @craco/craco 修改配置:
pnpm add -D @craco/craco
3. 配置微应用(Remote)
以 products 为例,创建 craco.config.js:
const { ModuleFederationPlugin } = require('webpack').container;
const deps = require('./package.json').dependencies;
module.exports = {
webpack: {
configure: (webpackConfig) => {
webpackConfig.plugins.push(
new ModuleFederationPlugin({
name: 'products',
filename: 'remoteEntry.js',
exposes: {
'./App': './src/App',
},
shared: {
react: { singleton: true, requiredVersion: deps.react },
'react-dom': { singleton: true, requiredVersion: deps['react-dom'] },
'react-router-dom': { singleton: true, requiredVersion: deps['react-router-dom'] },
},
})
);
// 允许跨域加载 remoteEntry.js
webpackConfig.devServer = {
...webpackConfig.devServer,
port: 3001,
headers: { 'Access-Control-Allow-Origin': '*' },
};
return webpackConfig;
},
},
};
修改 products/src/App.tsx,确保默认导出组件:
import React from 'react';
import { Routes, Route, Link } from 'react-router-dom';
const ProductList = () => <div>商品列表(微应用)</div>;
const ProductDetail = () => <div>商品详情(微应用)</div>;
const App = () => (
<div style={{ padding: 20, border: '2px solid blue' }}>
<h2>商品微应用</h2>
<nav>
<Link to="/">列表</Link> | <Link to="/detail">详情</Link>
</nav>
<Routes>
<Route path="/" element={<ProductList />} />
<Route path="/detail" element={<ProductDetail />} />
</Routes>
</div>
);
export default App;
启动微应用:pnpm start(端口 3001)。同理配置 orders,端口 3002,暴露 ./App。
4. 配置主应用(Host)
主应用的 craco.config.js:
const { ModuleFederationPlugin } = require('webpack').container;
const deps = require('./package.json').dependencies;
module.exports = {
webpack: {
configure: (webpackConfig) => {
webpackConfig.plugins.push(
new ModuleFederationPlugin({
name: 'host',
remotes: {
products: 'products@http://localhost:3001/remoteEntry.js',
orders: 'orders@http://localhost:3002/remoteEntry.js',
},
shared: {
react: { singleton: true, requiredVersion: deps.react },
'react-dom': { singleton: true, requiredVersion: deps['react-dom'] },
'react-router-dom': { singleton: true, requiredVersion: deps['react-router-dom'] },
},
})
);
webpackConfig.devServer = {
...webpackConfig.devServer,
port: 3000,
};
return webpackConfig;
},
},
};
4.1 动态加载远程组件
创建 src/components/RemoteApp.tsx,使用 React.lazy + Suspense 加载:
import React, { Suspense, lazy } from 'react';
const RemoteApp = ({ scope, module }: { scope: string; module: string }) => {
const Component = lazy(() => import(`${scope}/${module}`));
return (
<Suspense fallback={<div>加载中...</div>}>
<Component />
</Suspense>
);
};
export default RemoteApp;
> 注意:动态导入的路径必须是静态字符串,否则 Webpack 无法解析。这里通过变量拼接会失败,正确做法是使用 import(/ webpackIgnore: true / ...) 或更简单的显式导入:
const ProductsApp = lazy(() => import('products/App'));
const OrdersApp = lazy(() => import('orders/App'));
4.2 主应用路由与布局
// src/App.tsx
import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';
const ProductsApp = lazy(() => import('products/App'));
const OrdersApp = lazy(() => import('orders/App'));
const Home = () => <div>主应用首页</div>;
const App = () => (
<BrowserRouter>
<div style={{ display: 'flex', minHeight: '100vh' }}>
<aside style={{ width: 200, background: '#f0f0f0', padding: 20 }}>
<h3>主应用</h3>
<nav>
<div><Link to="/">首页</Link></div>
<div><Link to="/products">商品</Link></div>
<div><Link to="/orders">订单</Link></div>
</nav>
</aside>
<main style={{ flex: 1, padding: 20 }}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/products/*" element={
<Suspense fallback={<div>加载商品微应用...</div>}>
<ProductsApp />
</Suspense>
} />
<Route path="/orders/*" element={
<Suspense fallback={<div>加载订单微应用...</div>}>
<OrdersApp />
</Suspense>
} />
</Routes>
</main>
</div>
</BrowserRouter>
);
export default App;
启动主应用(端口 3000),访问 http://localhost:3000,点击「商品」即可看到蓝色边框的微应用。
5. 跨应用通信
微应用之间不应直接依赖,推荐使用 CustomEvent 或 共享状态库。这里演示基于 Zustand 的共享 store:
- 在主应用和微应用中都安装
zustand。 - 在主应用中创建
src/store/userStore.ts:
import { create } from 'zustand';
interface UserState {
username: string;
setUsername: (name: string) => void;
}
export const useUserStore = create<UserState>((set) => ({
username: '访客',
setUsername: (name) => set({ username: name }),
}));
- 在
ModuleFederationPlugin的shared中加入zustand,确保单例:
shared: {
// ...
zustand: { singleton: true, requiredVersion: deps.zustand },
}
- 在微应用(products)中直接
import { useUserStore } from 'host/store/userStore'?不行,因为微应用不应依赖主应用。正确做法:将 store 作为独立 npm 包发布,或通过exposes暴露给微应用。这里采用后者:主应用暴露./store:
// host craco.config.js
exposes: {
'./store': './src/store/userStore',
},
微应用通过 import { useUserStore } from 'host/store' 使用。但这样微应用就依赖了 host,形成双向依赖。更优雅的方案:使用自定义事件。
// 主应用触发
window.dispatchEvent(new CustomEvent('user-login', { detail: { username: '张三' } }));
// 微应用监听
useEffect(() => {
const handler = (e: CustomEvent) => setUser(e.detail.username);
window.addEventListener('user-login', handler as EventListener);
return () => window.removeEventListener('user-login', handler as EventListener);
}, []);
> 💡 最佳实践:通信尽量通过 props 或回调函数,若必须跨应用,优先使用事件总线或发布订阅,避免共享可变状态。
6. 独立部署与版本管理
生产环境需将 remoteEntry.js 部署到 CDN,并动态指定 URL。可在主应用中使用环境变量:
remotes: {
products: `products@${process.env.REACT_APP_PRODUCTS_URL}/remoteEntry.js`,
}
微应用独立部署后,主应用无需重新构建即可加载最新版本。但要注意 缓存问题:给 remoteEntry.js 加上 hash 或使用 Cache-Control: no-cache。
7. 常见坑与优化
- React 版本不一致:
shared中必须设置singleton: true,否则会出现多个 React 实例,导致 hooks 报错。 - 路由冲突:微应用内部应使用
MemoryRouter或BrowserRouter的basename,避免与主应用路由互相干扰。 - 样式隔离:推荐使用 CSS Modules 或 Shadow DOM。若使用全局样式,建议加命名空间前缀。
- 性能:预加载微应用资源,使用
React.lazy+Suspense实现按需加载;对于高频微应用,可在主应用空闲时预加载。 - 错误边界:每个微应用包裹
ErrorBoundary,避免一个微应用崩溃导致整个页面白屏。
8. 总结
本文从零实现了基于 Module Federation 的前端微服务架构,涵盖配置、路由、通信、部署全流程。核心要点:
- 主应用与微应用通过
remotes和exposes建立连接。 - 共享依赖必须声明为单例,尤其是 React 相关库。
- 通信推荐使用自定义事件或发布订阅,保持微应用自治。
- 独立部署时注意 CDN 路径和缓存策略。
下一步可探索:微前端框架 Qiankun、Single-SPA 的对比;基于 Web Components 的样式隔离;以及结合 CI/CD 实现自动化版本发布。
> 本文代码已放在 GitHub 示例仓库(模拟链接),欢迎 star 交流。