微前端把微服务的思路带到了界面这一层。前端不再是一个庞大的单体应用,而是拆成若干更小的独立应用,各自开发、各自测试、各自部署。每个微前端拥有一块明确的业务领域,可以由不同的团队、用不同的技术、按不同的发布节奏来构建。
这种架构源自当年催生服务端微服务的同一批压力。随着 Web 应用日益复杂,单体前端变得难以维护、构建缓慢、发布风险高。代码中任何一处的改动都要求重新构建并部署整个应用,拖慢了团队,也让那些希望以不同速度前进的小组之间产生摩擦。
什么是微前端?
微前端架构把一个 Web 应用切分成若干功能切片,每一片归一个自治团队所有。团队要为自己这一片的所有层次负责:界面组件、业务逻辑、数据获取,以及与服务端的对接。用户看到的是一个统一而连贯的应用,但在幕后,它由若干在同一个浏览器窗口中运行的小应用组合而成。
这种拆分遵循领域驱动设计的原则。每个微前端对应一个限界上下文:围绕某项具体业务能力划出的逻辑边界。结账流程、商品目录、用户资料和搜索,可以是分属不同团队的不同微前端。
微前端与单纯把代码拆成模块的根本区别在于,微前端在构建和部署上是独立的。每一个都有自己的流水线、自己的仓库、自己的测试和自己的发布节奏。正是这种独立性带来了架构的优势——也同样制造了它的难题。
微前端既不是组件库,也不是一堆共享工具。它是一个自成一体的应用,在运行时被组合进一个更大的应用之中。要理解这种架构的长处与代价,这个区别至关重要。
Webpack 5 的 Module Federation
在 React 与 Angular 生态里,Module Federation 是流传最广的做法。它随 Webpack 5 出现,让一个 JavaScript 应用在运行时加载来自另一个应用的代码。被加载的代码在宿主应用的上下文中执行,并与宿主共享依赖,从而避免 React 或 Vue 这类库被重复加载。
其机制是:远程应用对外暴露若干模块,宿主把它们引入。远程声明有哪些模块可用,宿主则像引用本地导入一样去引用它们。异步加载、去除重复依赖,以及构建期的版本协商,都由 Webpack 负责。
依赖共享是最重要的能力。当宿主与远程使用同一个库时,Webpack 会确保浏览器里只加载一份副本。这样就避免了 React、Lodash 或其他大型库多份副本所带来的性能损耗。单例模式还保证共享状态——某个 Redux 仓库或 React 上下文——在各微前端之间是真正共享的。
// webpack.config.js - Remote application exposing a module
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "checkout",
filename: "remoteEntry.js",
exposes: {
"./CheckoutApp": "./src/bootstrap",
},
shared: {
react: { singleton: true, requiredVersion: "^18.0.0" },
"react-dom": { singleton: true, requiredVersion: "^18.0.0" },
},
}),
],
};
// webpack.config.js - Host application consuming the remote
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "shell",
remotes: {
checkout: "checkout@http://localhost:3001/remoteEntry.js",
},
shared: {
react: { singleton: true, requiredVersion: "^18.0.0" },
"react-dom": { singleton: true, requiredVersion: "^18.0.0" },
},
}),
],
};
// Lazy loading the remote module in the host
const CheckoutApp = React.lazy(() => import("checkout/CheckoutApp"));
function Shell() {
return (
<Suspense fallback={<Spinner />}>
<CheckoutApp />
</Suspense>
);
}一旦用户进入需要结账微前端的路由,宿主就会加载远程入口文件。这个文件是 Webpack 自动生成的一小段 JavaScript:其中记录了所有对外暴露的模块以及它们的位置。当宿主请求某个具体模块时,Webpack 会下载对应的代码块,并在共享依赖的上下文中执行它。
有一点很关键:宿主与远程必须就共享依赖的版本达成一致。如果远程需要 React 18.2,而宿主只有 React 18.0,那么除非两个版本彼此兼容,否则单例共享就会失败。requiredVersion 字段接受语义化版本区间,既留出余地,又能防止破坏性变更。
基于 iframe 的集成
在 Module Federation 和现代技术出现之前,iframe 是最初的做法。iframe 把一个完全独立的 HTML 文档嵌入页面。该文档有自己的 JavaScript 上下文、自己的 CSS 作用域和自己的 DOM 树。这种隔离既是该做法的长处,也是它的短处。
在所有技术中,iframe 提供的隔离保证最为牢固。不会有 CSS 冲突,因为各有各的文档。不会有 JavaScript 碰撞,因为各有各的全局作用域。任何微前端都无法误改另一个的状态,因为 DOM 树完全分离。其中一个的内存泄漏不会拖垮另一个。
这些保证代价高昂。iframe 很重。每一个都要加载一整份 HTML 文档,连同它全部的 CSS 与 JavaScript 资源。浏览器把每一个都当作独立的浏览上下文,这意味着更多内存、更多网络请求、更多绘制工作。如果你用 iframe 嵌入十个微前端,浏览器实际上加载了十一个页面。
iframe 与承载它的页面之间通信需要 postMessage,而它是异步的,且只能传递可序列化的数据。函数、类的实例或 DOM 引用都无法跨越边界。这就使 iframe 不适合那些需要紧密集成的微前端——比如一个需要读取宿主页面购物车状态的支付表单。
无障碍访问也会受损。屏幕阅读器和其他辅助技术通常难以应付嵌套的浏览上下文。跨 iframe 边界的键盘导航表现并不一致。页内查找无法跨越这些边界,而历史记录会把每一次内部跳转当成独立的会话。
iframe 主要用于嵌入第三方内容,那里隔离最重要而集成最少。要组合出一种连贯的体验——微前端之间需要共享状态、协调导航、构成统一的视觉界面——它就是个糟糕的选择。
single-spa 与其他编排框架
single-spa 是一个 JavaScript 框架,用来在同一个页面上编排多个微前端。它管理每一个的生命周期:用户进入需要它的路由时挂载,离开时卸载,并把不活跃的留在内存里以便快速重新挂载。它与框架无关,支持 React、Angular、Vue、Svelte 以及纯 JavaScript。
每个微前端都以应用的形式注册到 single-spa,并提供生命周期函数:bootstrap、mount、unmount,以及可选的 update。single-spa 的根节点掌管路由,依据 URL 模式判断哪些应用处于活跃状态。当 URL 与某个已注册应用的活跃函数匹配时,single-spa 就挂载它,并卸载那些不再活跃的。
single-spa 解决了最棘手的问题之一:框架共存。当两个微前端由同一框架的不同版本、甚至完全不同的框架构建时,single-spa 能让它们在同一页面上共处而不冲突。它的办法是介入生命周期钩子,并隔离每个应用的全局状态。
其他做法还包括 Piral,它采用扩展模型,微前端作为模块从分发服务加载;以及 Open Components,偏重服务端组合。还有 Web 组件这条路:把每个微前端包进一个自定义元素,并注册到浏览器相应的接口。Web 组件为 HTML、CSS 和 JavaScript 提供了原生的作用域划分,并能在 HTML 模板中以声明方式组合。
选择取决于你的技术栈、团队结构和性能要求。对已经在用 Webpack 的团队,Module Federation 是最佳选项。在框架各异的混合架构中,single-spa 就说得通。Web 组件适合希望强制划出与框架无关的边界的组织。iframe 只适合嵌入第三方内容。
共享组件库与设计系统
一个常见的担忧是视觉一致性的丧失。如果每个团队各自为政地构建界面,用户在应用中走动时就会遇到不一样的按钮、字体和布局。答案是一套供所有微前端使用的共享组件库或设计系统。
共享库通常收纳展示型组件:按钮、输入框、对话框和导航元素。它们纯粹是视觉性的,不含业务逻辑。它们接受用于样式变体、文案和事件处理的属性,但不获取数据、不管理状态、不实现领域特有的行为。这种分离让库保持稳定,也避免它成为拖慢团队速度的瓶颈。
库的版本管理需要纪律。作为 npm 包发布后,每个微前端锁定一个版本,并按自己的节奏升级。这带来了自主权,但也可能造成版本漂移:同一个组件在不同微前端里长相不同。给库配一套视觉回归测试,可以在上线前抓出这些差异。
另一种选择是把设计系统作为运行时依赖提供。库以独立应用的形式发布,每个微前端在启动时加载它。这样所有人始终用的是每个组件的最新版本,漂移随之消失。代价是:升级系统现在需要一次部署,而任何破坏性变更都会一次性波及所有微前端。
设计令牌作为最小公分母
设计令牌——为颜色、间距、字体和阴影取的具名值——是比完整组件库更轻的替代方案。每个微前端自行实现组件,但引用同一套令牌。这样既保留了实现上的自由,又维持了视觉一致性。令牌可以以 CSS 自定义属性的形式分发,既易于覆盖,又完全不需要构建工具。
微前端之间的通信
微前端之间需要对话。结账微前端需要知道用户从目录里往购物车放了什么。页头微前端需要在通知微前端收到新消息时更新角标。登录微前端需要在用户登入或登出时通知其余所有微前端。
最简单的机制是共享事件总线。每个微前端向全局通道发布事件,并订阅其他微前端的事件。总线通常实现为一个事件发射器,挂在 window 对象上,或由外壳注入。事件携带类型和载荷;订阅方按类型筛选。
// Shared event bus - shell application
// Each micro frontend receives this bus as part of its initialization.
type BusEvent = {
type: string;
payload: unknown;
};
type Listener = (event: BusEvent) => void;
class EventBus {
private listeners: Map<string, Listener[]> = new Map();
on(type: string, listener: Listener): () => void {
if (!this.listeners.has(type)) {
this.listeners.set(type, []);
}
this.listeners.get(type)!.push(listener);
return () => this.off(type, listener);
}
off(type: string, listener: Listener): void {
const listeners = this.listeners.get(type);
if (listeners) {
this.listeners.set(
type,
listeners.filter((l) => l !== listener)
);
}
}
emit(type: string, payload: unknown): void {
this.listeners.get(type)?.forEach((listener) => {
listener({ type, payload });
});
}
}
// Usage in a micro frontend
export function init(bus: EventBus) {
bus.on("item:added-to-cart", (event) => {
updateCartBadge(event.payload.quantity);
});
bus.emit("user:logged-in", { userId: "abc-123", name: "Jane" });
}面对更复杂的需求,共享状态仓库通常优于事件总线。托管在外壳中的 Redux 或 Zustand 仓库可以注入到每个微前端。各自从中读取跨领域的状态,把本领域专有的状态写进自己的本地仓库。耦合仍然松散,同时共享数据又有唯一的真相来源:当前用户、购物车内容、当前导航。
通信层必须显式设计并写成文档。团队之间要就事件名称、载荷格式,以及共享状态与本地状态的边界达成一致。没有这份约定,微前端就会对彼此的内部状态形成隐式依赖,最终造出一个分布式单体——微前端的复杂性一样不少,好处一个没有。
路由策略
路由要回答两个问题:当前路由属于哪个微前端?以及它们之间如何跳转?答案取决于你是在外壳里用集中式路由器、采用分布式做法,还是走混合策略。
在集中式路由中,外壳应用拥有第一级路由器。它定义路由表,判断每个 URL 模式该挂载哪个微前端,并把参数传给它。每个微前端有自己的内部路由器,用于在本领域内跳转。外壳负责跨领域的切换,微前端负责内部的路径。
在分布式路由中,路由归各个微前端所有。外壳仍然管理 URL 与微前端之间的总体匹配,但每个微前端自行管理自己的子路由和内部跳转。外壳中的一个导航库负责协调历史记录的变化,确保地址栏反映的是整个应用的状态,而不只是当前活跃微前端的状态。
在基于事件的路由中,由总线来协调跳转。当某个微前端需要前往属于另一个微前端的路由时,它发出一个导航事件。外壳监听到后执行跳转,目标微前端随之挂载。这样路由相关的问题就从微前端里移了出去,逻辑集中到外壳。
- 集中式路由:外壳拥有路由表,微前端只负责内部跳转。适合小团队和简单边界。
- 分布式路由:每个微前端管理自己的子路由。适合领域划分清晰的大团队。
- 基于事件的路由:跳转由共享总线协调。适合框架各异的混合架构。
- 混合式路由:外壳负责第一级的领域路由,微前端负责子路由。适合大多数生产应用。
部署与版本管理
部署的独立性是采用微前端的首要动机。每个团队都应当能够部署自己的那一份,既不必与其他团队协调,也不会危及整体的稳定。要做到这一点,需要精心设计流水线、托管方案和版本机制。
最简单的模型是分开托管。每个微前端部署到自己的地址或自己的存储空间。外壳在运行时从那里加载它。这种模型给予的独立性最大,因为每个团队都掌控自己的基础设施、流水线和回滚策略。微前端更新时外壳无需重新部署,因为它会动态加载最新版本。
分开托管带来了一个新难题:为保持向后兼容而协调部署。如果结账微前端期望外壳传来某种属性格式,而外壳的下一次部署改动了这个格式,那么结账微前端在自己更新之前就会崩坏。出路是给集成契约——通常就是外壳传递的那些属性——做版本管理,并用语义化版本标示破坏性变更。
在这里,渐进式发布和面向小部分用户的试验会更容易,因为每个微前端都是独立部署的。一个团队可以把新版本先发给百分之五的用户,观察错误率和性能指标,一切顺利就逐步扩大。若出现问题,它只回滚自己的微前端,不动其余部分。
从外壳锁定版本是应急出口。如果某次部署引入了测试没能拦住的破坏性变更,外壳可以锁定上一个版本、恢复稳定,同时让团队去处理问题。这个机制通常是一个配置文件或环境变量,把每个微前端对应到一个已发布的版本。
单仓库还是多仓库
仓库结构在前端社区里是个有争议的话题。支持单仓库的理由和支持多仓库的理由都有其道理,正确的选择取决于团队规模、组织成熟度和工具偏好。
单仓库把所有微前端放在同一处,按目录组织。每一个都有自己的 package.json、自己的构建配置和自己的目录。这种做法借助 Turborepo、Nx、Lerna 或 pnpm 工作区这类工具来管理依赖、执行构建,并在各微前端之间编排任务。
- 工具的共享配置很简单:所有人共用一份 ESLint、一份 TypeScript、一份 Prettier。
- 跨微前端的重构更容易,因为所有人的代码都在同一处。
- 依赖管理是集中的,降低了版本错配的风险。
- 当一次改动涉及多个领域时,跨微前端的原子提交成为可能。
多仓库的做法是把每个微前端放进各自的仓库,配上各自的流水线和配置。团队对技术栈和发布流程保有完全的自主权。一个想从 React 迁到 Preact 的团队可以自行其是,无需协调,只要它的微前端在外壳中仍能正确显示即可。
- 团队对自己的工作流和工具拥有完全自主权。
- 仓库保持小巧,克隆快速,流水线高效。
- 不需要专门的单仓库工具:常规的 Git 流程和 npm 仓库就够了。
- 一个微前端的破坏性变更不会拖垮另一个的构建。
行业趋势偏向单仓库,尤其是在共享设计系统或共享工具集的场景中。跨多个仓库协调改动的成本,通常压过自主权带来的收益,对于本就联系紧密、技术栈相近的团队更是如此。不过,对于团队分散、使用不同框架的组织,多仓库仍是更好的选择。
性能方面的考量
相比单体应用,微前端要付出性能代价。每一个都要加载自己的 JavaScript 包、自己的 CSS,可能还有自己的框架运行时。浏览器需要下载、解析并执行更多代码。传输字节数增加,首次加载时页面变得可用所需的时间也变长。
Module Federation 的依赖共享能缓和这一代价,因为它保证某个库只加载一次。但应用代码——各微前端的组件、工具和业务逻辑——并不共享。如果用户在一次会话中走过三个不同的微前端,他就会下载这三个的代码,哪怕他只与其中一个打过交道。
在每个微前端内部做代码分割是必不可少的。每一个都应当惰性加载自己的路由、笨重的组件和第三方库。一个包含图表库的管理后台微前端,不该在用户到达真正绘制图表的页面之前就加载它。这与单体应用里的做法一样,只是在每条边界之内分别施行。
预加载是一项有价值的优化。当用户进入结账微前端的某个路由时,外壳可以预判他接下来会走向订单确认,并在后台开始获取其资源。这一策略应当建立在真实的使用数据之上,而不是对导航习惯的臆测。
关键渲染路径必须受到保护。外壳出现的速度应当与单体应用一样快,这意味着它必须轻:少量 JavaScript、少量 CSS、少量依赖。首次绘制由它负责,缓慢的微前端不应拖慢它。每个微前端都应异步加载,并在就绪之前显示加载状态。
性能预算应当针对每个微前端设定,而不只是针对整个应用。每个团队都要清楚自己包体积的上限、可用时间的上限和内存占用的上限。一旦超出,就应当让流水线失败——正如在单体应用里那样。
测试微前端
这种架构的测试分三个层次:各微前端的隔离测试、它们之间的集成测试,以及整个组合应用的测试。每个层次都有各自的工具、目标和负责人。
各微前端内部的单元测试与组件测试,遵循与任何前端应用相同的模式。每个团队测试自己的那一份,测试在自己的流水线中运行。唯一的区别是:最好假定外壳与其他微前端并不可靠来编写测试——预先验证自己的微前端在外壳传来意外属性、或某个事件没有到达时,能够优雅地降级。
集成是最难的一层。测试需要同时加载外壳和多个微前端,重现跨边界的交互,并验证组合后的行为是否正确。Cypress 和 Playwright 在这里表现出色,因为它们在真实浏览器中运行,操纵的是完整的应用。
一种有效的策略,是在测试环境中维护一套近似生产的部署:每个微前端在各自的地址上,外壳配置为去加载它们。测试套件针对这套部署运行,走通跨越边界的真实用户旅程。这样能暴露隔离测试看不见的问题:某个微前端尚未完成初始化而另一个已经试图与它对话时的时序故障,或者某处 CSS 改动导致另一处布局错位的样式回归。
视觉回归测试在这里尤为重要。共享设计系统应当有自己的一套。每个微前端应当为自己的页面各有一套。组合应用则应当为关键旅程各有一套。Percy、Chromatic 或 Loki 可以接入流水线,自动发现这些差异。
何时不该使用微前端
微前端并非适合每个项目的架构。它们带来的复杂性——运维成本、性能成本、协调需求和排查难度——只有特定的组织需求才能证其正当。把它们套用在并不需要的项目上,只会制造摩擦、拖慢开发,而没有回报。
一个小团队做一个应用,不需要微前端。这种架构是为拥有多个团队、各自负责不同领域的组织设计的。如果你的整个前端三四个人就能做,那么模块组织良好的单体应用会更高效、构建更快、也更易维护。
领域之间高度耦合的应用是糟糕的候选者。如果每个功能都与其他所有功能纠缠,如果目录、购物车和结账盘根错节、无法干净地分开,那么硬把它们切开只会逼你去构建复杂的通信层,去模仿单体里原本直接的调用。这正是所谓分布式单体的反模式。
运维成熟度不足的组织会很难受。这种架构要求良好的运维实践、自动化测试、监控和故障响应。每个团队都得能自行部署并迅速回滚。如果这些基础还不具备,那更好的做法是先在单体应用的框架内投入建设它们。
对加载时间有严苛要求的性能关键型应用,也许承受不起这份代价。如果每一毫秒都要紧——比如实时交易面板或视频界面——那么额外的网络请求和多出来的 JavaScript 解析时间,可能会让你突破预算。
- 你的前端团队不到四个人。
- 应用中界限分明的领域少于五个。
- 团队没有微服务或分布式系统的经验。
- 组织没有自动化的持续集成与监控。
- 应用有严苛的性能预算,本就难以达成。
- 各项功能高度耦合,无法干净地划入领域。
微前端是一个以技术架构面貌呈现的组织决策。如果你并不需要它们提供的那种组织独立性——不同的团队、不同的节奏、不同的技术选择——那么技术上的代价就不值得付。
结语
对于需要在多个团队之间扩展前端开发规模的组织,微前端是一种有力的模式。这种架构带来实实在在的好处:独立部署、团队自主、技术自由,以及按不同节奏发布的可能。当架构用在对的问题上时,这些好处是具体而可衡量的。
成功的关键,是先把它们当作组织层面的方案,其次才是技术层面的方案。这种架构存在的意义是让团队变得独立,而不是产出优雅解耦的代码。每一个技术决定——Module Federation 还是 iframe、单仓库还是多仓库、事件总线还是共享仓库——都应当瞄准提升团队速度而不牺牲体验的连贯。
从小处着手。先拿一个领域边界清晰、又有团队愿意独立担纲的微前端起步。证明流水线跑得通、性能撑得住、团队交付得更快。然后再逐步扩大。这不是全有或全无的事:混合做法——在一个基本仍是单体的应用里放一两个微前端——往往是最好的起点。
最常见的失败是过早抽象。团队在尚未弄清自己的真实需求之前,就先建起精巧的编排层、共享状态系统和横跨各处的基础设施。请从最简单的集成开始——一个通过 script 标签或 Module Federation 加载微前端的外壳——只有当简单方案确实力不从心时,再增加复杂度。抽象的恰当程度,是在真实使用中显露出来的,不是在事先设计里。
Module Federation 让微前端比以往任何时候都更触手可及,但根本的问题依旧是组织层面的。你的团队需要独立部署吗?你的组织扛得住运维复杂度吗?你的领域能干净地分开吗?回答是肯定的人,会觉得它们是脱胎换骨的改变。回答是否定的人,只会觉得它们烦人。架构本身是中立的:它的价值完全取决于它被置于怎样的处境。