使用 React 的微前端:从基础到高级的完整指南
Source: Dev.to
什么是微前端?
可以这么想——与其让所有人共同提交到一个庞大的 React 应用,不如把它拆分成多个独立的 React 应用,它们各自存在并相互通信。
经典示例:
- 由平台团队构建的 header 组件
- 由商务团队构建的 product listing
- 由结账团队构建的 cart
全部独立部署,且同时在同一页面上运行。这就是微前端的设置。
为什么我开始使用它们(真实原因)
我在一个拥有大约五个不同团队的电商平台上工作,而我们的主应用已经变成了噩梦:
- 仅仅用于供应商仪表盘的 200 KB 包,90 % 的用户根本看不到。
- 对页眉的一个改动就意味着要重新构建所有内容。
- 部署冲突每天都会发生。
微前端看起来像是 “好吧,每个团队拥有自己的领域,他们可以独立发布代码,不再有冲突”。
这部分确实有效——前提是你把它设置得正确。
Source: …
模块联邦实际工作原理
Webpack 5 引入了 Module Federation,一种在运行时从不同域/端口加载代码的方式,而不是预先将所有内容打包。
Header 应用(运行在 localhost:3001)
// webpack.config.js
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
mode: "production",
entry: "./src/index",
output: {
path: __dirname + "/dist",
filename: "[name].[contenthash].js",
},
devServer: {
port: 3001,
headers: {
"Access-Control-Allow-Origin": "*",
},
},
plugins: [
new ModuleFederationPlugin({
name: "header",
filename: "remoteEntry.js",
exposes: {
"./Header": "./src/Header",
},
shared: {
react: { singleton: true, requiredVersion: "^18.0.0" },
"react-dom": { singleton: true, requiredVersion: "^18.0.0" },
},
}),
],
};
关键点
| 属性 | 含义 |
|---|---|
name | 告诉其他应用 “我是 Header 应用”。 |
exposes | 声明此应用共享的内容(Header 组件)。 |
shared(配合 singleton) | “如果你没有 React,就使用 我的 React 版本;不要加载两次 React”。 |
主壳应用(运行在 localhost:3000)
new ModuleFederationPlugin({
name: "mainApp",
remotes: {
header: "header@http://localhost:3001/remoteEntry.js",
},
shared: {
react: { singleton: true, requiredVersion: "^18.0.0" },
"react-dom": { singleton: true, requiredVersion: "^18.0.0" },
},
});
使用远程组件
import React, { lazy, Suspense } from "react";
const Header = lazy(() => import("header/Header"));
export default function App() {
return (
<Suspense fallback={<div>loading header…</div>}>
<Header />
{/* rest of app */}
</Suspense>
);
}
这出乎意料地好用。Header 应用独立加载,React 只加载一次,所有东西都能很好地协同工作。
状态管理:让我吃亏的那一块
这就是我最初搞砸的地方。我曾以为“我可以在各模块之间直接使用 Context API!”
并不是这样。 Context 只能在同一个 React 树内部工作。当你从不同的 webpack 包中懒加载一个组件时,它实际上是一个不同的 React 根。你的 context 提供者位于外壳(shell)中,但远程模块拥有自己的 React 实例。我花了大约三天时间才弄清楚这点。
实际可行的方案
选项 1 – URL + localStorage(简单做法)
// 当用户在 auth 模块登录时
const user = { id: 123, name: "Alice" };
localStorage.setItem("user", JSON.stringify(user));
window.location.hash = "#user-logged-in";
// 在其他模块中,监听 storage 事件
useEffect(() => {
const handleStorageChange = (e) => {
if (e.key === "user") {
const newUser = JSON.parse(e.newValue);
setUser(newUser);
}
};
window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange);
}, []);
说实话? 这对简单的场景(用户登录、偏好设置、基础状态)有效。对于复杂状态就不太适合了。
选项 2 – Window 事件(更好)
// Auth 模块抛出事件
function handleLogin(user) {
const event = new CustomEvent("app:user-login", { detail: user });
window.dispatchEvent(event);
}
// Header 模块监听
useEffect(() => {
function handleUserLogin(e) {
setUser(e.detail);
}
window.addEventListener("app:user-login", handleUserLogin);
return () => window.removeEventListener("app:user-login", handleUserLogin);
}, []);
每个模块保持独立,通过事件进行通信。如果某个模块崩溃,其他模块仍能继续运行。
选项 3 – 共享状态服务(我们现在的做法)
// shared-state-service.js – 两边都要导入的轻量模块
class StateService {
constructor() {
this.listeners = new Map();
this.state = {
user: null,
theme: "light",
cart: [],
};
}
subscribe(key, callback) {
if (!this.listeners.has(key)) {
this.listeners.set(key, []);
}
this.listeners.get(key).push(callback);
// 返回一个取消订阅的函数
return () => {
const cbs = this.listeners.get(key);
const idx = cbs.indexOf(callback);
if (idx > -1) cbs.splice(idx, 1);
};
}
setState(key, value) {
this.state[key] = value;
const cbs = this.listeners.get(key) || [];
cbs.forEach((cb) => cb(value));
}
getState(key) {
return this.state[key];
}
}
// 导出单例
export const stateService = new StateService();
在任意微前端中的使用方式
import { stateService } from "./shared-state-service";
// 更新状态
stateService.setState("user", { id: 1, name: "Bob" });
// 订阅状态
useEffect(() => {
const unsubscribe = stateService.subscribe("user", setUser);
return unsubscribe;
}, []);
这为我们提供了一种轻量、与框架无关的状态共享方式,而无需引入完整的 store。
Source: …
TL;DR
- 微前端让独立团队能够在不相互干扰的情况下交付代码,但它们会增加运行时的复杂度。
- Webpack 5 的 模块联邦(Module Federation) 使得可以在运行时动态加载独立的 bundle,同时共享同一个 React 实例。
- 状态共享 不能仅依赖 React Context;请选择适合你应用复杂度的策略(localStorage + 事件、自定义 window 事件,或一个小型共享状态服务)。
如果配置得当,收益巨大:构建更快、部署独立、代码库更健康。只需做好额外的管道工作。祝你联邦化愉快!
// shared/state-service.js
class StateService {
constructor() {
this.state = {};
this.listeners = new Map();
}
setState(key, value) {
this.state[key] = value;
if (this.listeners.has(key)) {
this.listeners.get(key).forEach((cb) => cb(value));
}
}
getState(key) {
return this.state[key];
}
subscribe(key, cb) {
if (!this.listeners.has(key)) {
this.listeners.set(key, new Set());
}
this.listeners.get(key).add(cb);
// Return an unsubscribe function
return () => this.listeners.get(key).delete(cb);
}
}
export default new StateService();
在模块中使用该服务
Auth 模块
// Auth module
import stateService from "shared/state-service";
function handleLogin(user) {
stateService.setState("user", user);
}
Header 模块
// Header module
import stateService from "shared/state-service";
import { useEffect, useState } from "react";
export default function Header() {
const [user, setUser] = useState(stateService.getState("user"));
useEffect(() => {
// Subscribe to changes and clean up on unmount
return stateService.subscribe("user", setUser);
}, []);
return <>Hello {user?.name}</>;
}
这就是我们现在使用的方式。体积小,可靠,每个模块保持独立。
Source: …
没人谈论的事
1. 版本冲突是真实存在的
你会遇到模块 A 需要 lodash@4,而模块 B 需要 lodash@3 的情况。它们都在 shared 中,结果生产环境中出现了两个版本,包体积会突然增加约 200 KB。
我们的做法: 在 shell 级别锁定版本。所有远程仓库 必须 使用我们指定的版本。
2. 错误处理很痛苦
const Header = lazy(() =>
import("header/Header").catch((err) => {
console.error("header load failed", err);
// Return fallback component
return { default: () => <>Header unavailable</> };
})
);
如果生产环境中 header 应用宕机,整个页面不会崩溃——但你必须显式地处理这种情况。
3. 本地开发变得混乱
在 3001 端口运行 header,3002 端口运行 sidebar,3000 端口运行主应用?你需要一个脚本同时启动这三个服务。DevTools 会变得令人困惑,调试也很烦人,因为代码分散在多个标签页中。
我们现在使用 docker‑compose 进行本地开发。
当微前端真正有意义时
- Multiple independent teams 在不同的时间表上部署
- Large apps 中不同的领域几乎不相互交流
- Different performance requirements(例如,购物车对性能至关重要,管理后台则不是)
当它们显得过度时
- 小团队,单一应用
- 大量跨域状态共享
- 当你真的 喜欢 你的单体应用
老实说?我们现在使用它们是因为不得不使用,而且它能工作。如果可以回到过去,我可能会保持主应用的单体结构,只在真正独立的功能上使用微前端(比如把管理后台作为一个独立的应用)。
我遇到的真实坑
- CSS 冲突 – 模块 A 设置
body { font-size: 16px },模块 B 期望18px。使用 CSS 模块或 Shadow DOM。 - 捆绑重复 – 忘记将 React 标记为共享,导致加载了三次。页面体积膨胀到 500 KB,而不是 200 KB。
- CORS 问题 –
remoteEntry.js没有使用正确的响应头提供;所有内容都静默失败。 - 状态不同步 – 一个模块缓存了用户数据,另一个模块获取了新数据 → 到处都是混乱。
我会不同的做法
- 保持单体架构,直到真正需要微前端。
- 只从 2–3 个 remote 开始,而不是 10 个。
- 从第一天起就拥有一个用于状态服务的共享库。
- 使用 feature flags 逐步推出每个模块。
- 指定 ONE 个人负责 shell/orchestration 代码。
实际有帮助的资源
- Module Federation 文档还不错。
- Zack Jackson 的演讲非常有价值。
- 老实说,你自己的代码是最好的老师。
微前端并不是适合所有人的未来。它们是解决特定问题的利器。不要仅仅因为听起来酷就使用它们。这个教训我吃了不少苦头。