项目代号 rea,是我给一套微前端改造方案随手起的名字,全称可以理解成 React Enterprise Application。为什么要搞这个东西?最直接的原因是:一个中后台项目在持续迭代三年后,前端仓库已经明显失控。业务模块互相依赖,发布要全链路回归,新同学接手时甚至不敢动公共组件。如果你也维护过这种“巨石应用”,应该能体会到那种启动越慢、改哪都怕的无力感。这篇文章不聊虚的,就把我这次用 rea 做改造的真实思路、代码细节和踩坑过程完整写出来,适合正在考虑拆分前端项目、又不想引入太重成本的团队参考。
1. 为什么要做 rea:巨石应用拆分的现实困境
1.1 项目膨胀的三个典型症状
先说症状。这个项目早期其实很清爽,React 16 + 自定义脚手架,业务模块也就四五个,前端团队三个人,任何人改动都能很快定位。问题是业务增长得比预期快,两年后代码量翻了接近五倍,几个典型的坑开始密集出现。
第一个症状是编译慢。开发环境冷启动要等三分钟左右,每次保存热更新以后,页面刷新还要再等十几秒。到了第三年,我每天最怕的就是启动 dev server,那种空白等待的过程非常磨人。第二个症状是发布回归成本太高。前端只有一个大仓库,任何一次发布都要把整个项目重新构建,哪怕只是改了一个按钮文案,也要全量走一遍测试回归。第三个症状是团队协作互相阻塞。A 小组要上线订单模块,B 小组正在改公共组件,两边一合并就是一堆冲突。后加入的同事根本分不清哪些公共模块是核心的、哪些是可以独立拆走的,代码长期处于“能跑但不敢动”的状态。
这三个症状凑齐之后,团队里开始频繁出现一个声音:要不要把项目拆了重写。但我当时心里很清楚,拆了重写听起来解气,成本却极高。项目积累了大量的业务逻辑和边界条件,完全推倒重来大概率会在半年内陷入另一个泥潭。更合理的做法,是把现有应用从内部结构上解耦,让每个业务域可以独立开发、独立测试、独立部署。
1.2 为什么 iframe 和拆仓库都不是最优解
很多人想到的第一个方案是 iframe。iframe 实现起来确实简单,主应用套一个壳,每个业务系统用独立的 URL 嵌入进来,互不干扰。但我实际试过以后发现,iframe 带来的问题甚至比巨石应用还多。最明显的是体验割裂:子页面自带滚动条、弹窗不能覆盖到主应用上方、页面刷新后主框架的导航状态会丢失。跨应用通信也只能走 postMessage,写起来非常别扭,而且没有类型保障,时间一长消息协议就成了一笔糊涂账。最致命的一点是,用户登录态和权限信息在两个 iframe 之间是隔离的,每次切换都要重新同步,这套逻辑写下来并不比解决前端耦合更轻松。
硬拆多仓库的方案我也考虑过。把订单、用户、报表分别拆成三个独立仓库,听起来很美好,但实际操作起来会发现公共部分很难处理。公共组件和工具函数到底是放 npm 包还是复制一份?放 npm 包意味着每次改公共代码都要发版升级,子应用之间版本漂移会比想象中更快;复制一份则意味着同一段逻辑多处维护,改了一个漏了另一个的情况几乎是必然的。更重要的是,拆成多仓库只是改变了代码的存放位置,并没有解决运行时组合的问题。用户从订单模块跳转到用户模块时,仍然要经历一次全新的页面加载,状态照样没法在各个业务之间共享。
基于这些失败预期,我最终把方案定在了运行时模块共享上。核心思路是:各个业务应用保持独立开发和构建,但在浏览器端由主应用统一加载和组装,公共依赖可以在运行时只加载一份。这套思路下,每个业务域依然是一个可独立部署的应用,但用户体验上又是一个整体。rea 就是围绕这个思路设计的。
2. rea 的整体架构与核心设计
2.1 主应用只保留壳、路由与登录态
rea 里最核心的设计原则就是主应用不要写业务页面。很多微前端方案失败,不是因为技术选型错了,而是主应用里还堆着一堆业务代码,最后主应用自己也成了一个巨石应用。我在 rea 里做了严格的纪律约束:主应用只负责四件事,一是登录态与全局鉴权,二是整体布局和导航菜单,三是路由表管理,四是公共依赖的统一提供。
这个分工带来两个明显好处。第一,主应用变成了一个几乎不用频繁发版的壳,它的构建产物在很长一段时间内保持稳定。第二,业务模块被彻底推到子应用内部后,每家业务团队可以按自己的节奏迭代,不再受到主应用发布周期的牵连。比如订单子应用有紧急修复,只需要单独构建订单应用,然后把新的 remoteEntry 地址切过去就行。主应用甚至不需要重新打包。
主应用在路由层做的事情也不复杂,只是把导航配置和远程模块地址做一个映射。用户点菜单时,主应用根据配置去加载对应的远程模块,再把它渲染到内容区。对于使用者来说,整个系统的页面结构没有变化,但背后的工程边界已经完全不同。
2.2 子应用通过模块联邦暴露业务模块
模块联邦是 webpack 5 内置的能力,也是 rea 的技术底座。它解决的正是我前面提到的运行时共享问题,不需要像传统微前端那样引入额外的运行时框架,也不需要给子应用强行套一层隔离容器。
在 rea 里,每个子应用通过 ModuleFederationPlugin 暴露自己的模块入口。插件配置里有一个 exposes 字段,它相当于一份公开接口清单,告诉外部这个应用可以提供哪些模块。另一个关键的产物是 remoteEntry.js,这个文件记录了子应用所有公开模块的加载地址。主应用只需要拿到 remoteEntry.js 的地址,就能在运行时按需拉取对应的业务代码。
从主应用角度看,远程模块就像是一个懒加载的本地模块。代码里直接使用 React.lazy 包一层,再配合 Suspense 做加载占位,即可实现模块的异步加载。这个设计让子应用之间的耦合降到了最低。子应用不需要知道主应用的内部结构,主应用也不需要预先把所有子应用代码打包进来,两边唯一需要对齐的,就是共享的公共依赖版本策略。
2.3 共享依赖的版本策略
模块联邦的 shared 配置是整个 rea 设计里最容易出错也最关键的一环。在不做任何共享配置的情况下,主应用和每个子应用都会各自打包一份 React。三个应用加载完成后,页面上可能同时存在三份完整的 React 实例,这会导致 hooks 运行时的状态错乱。
我最初搭建示例时没有配置 shared,结果页面一打开就报 Invalid hook call,错误的调用提示非常典型。原因就是多个 React 实例同时存在,useState 和 useEffect 找不到自己在哪个 Fiber 节点上工作。
解决方案是在主应用和子应用里都声明共享 react 和 react-dom,并设置 singleton: true。singleton 的含义是全局只保留一份实例,当多个应用都声明需要共享 React 时,webpack 会保证最终运行时的 React 是同一份。这里有一个细节需要注意:共享库的版本不能差异太大。如果主应用用的是 React 18,子应用还在用 React 16,共享时会在运行时告警,甚至出现无法预料的兼容问题。rea 的做法是把所有子应用的 React 版本统一锁到同一个大版本,并且在 CI 里加入版本检查,出现不一致就直接构建失败。
3. 从零手写一个 rea 最小示例
3.1 初始化三个前端应用
看再多理论,不如把示例跑起来。这里我准备了一个最小可运行的 rea 模板,包含三个应用:一个是主应用 host,负责壳和路由;两个是子应用 order 和 user,分别模拟订单模块和用户模块。三个应用全部基于 webpack 5 + React 18 + react-router-dom 6。
先搭目录结构,为了方便演示,三个应用放在同一个仓库下:
rea-demo/ ├── host/ # 主应用,端口 3000 ├── order/ # 订单子应用,端口 3001 └── user/ # 用户子应用,端口 3002每个应用实际上都是一个独立的 webpack 项目。为了精简,我直接用 webpack-cli 初始化,并安装 React、ReactDOM、react-router-dom:
npm install react react-dom react-router-dom npm install -D webpack webpack-cli webpack-dev-server html-webpack-plugin @babel/core babel-loader @babel/preset-reactBabel 配置里只需要启用 React 的 preset,其余文件结构按照常规的 src 目录组织。关键是每个应用的 webpack 配置里都要添加 ModuleFederationPlugin,这是整个 rea 示例的核心。
3.2 在子应用里暴露模块
order 子应用的 webpack 配置大致如下:
const { ModuleFederationPlugin } = require('webpack'); module.exports = { mode: 'development', devServer: { port: 3001, headers: { 'Access-Control-Allow-Origin': '*', }, }, output: { publicPath: 'auto', }, plugins: [ new ModuleFederationPlugin({ name: 'order', filename: 'remoteEntry.js', exposes: { './OrderModule': './src/OrderModule.jsx', }, shared: { react: { singleton: true, requiredVersion: '^18.2.0' }, 'react-dom': { singleton: true, requiredVersion: '^18.2.0' }, }, }), ], };这里有几处值得解释。devServer 里的跨域头是必须的,因为主应用运行在 3000 端口,它在浏览器里请求 3001 的 remoteEntry.js 属于跨域请求,不加这个头模块根本加载不进来。output 的 publicPath 设置成 auto,可以避免子应用资源相对主应用域名解析带来的路径错误,后面我会单独展开这个问题。
order 子应用里的业务模块,在 src/OrderModule.jsx 中导出一个普通组件即可:
export default function OrderModule() { return ( <div> <h2>订单子应用</h2> <p>这里展示订单列表、订单详情等业务内容。</p> </div> ); }user 子应用的配置和 order 几乎一样,区别只是 name 换成 user,暴露的模块换成 UserModule,端口改成 3002。
3.3 在主应用里加载远程模块
主应用 host 的 webpack 配置里,不需要 exposes,只需要声明 remotes:
const { ModuleFederationPlugin } = require('webpack'); module.exports = { mode: 'development', devServer: { port: 3000, }, plugins: [ new ModuleFederationPlugin({ name: 'host', remotes: { order: 'order@http://localhost:3001/remoteEntry.js', user: 'user@http://localhost:3002/remoteEntry.js', }, shared: { react: { singleton: true, requiredVersion: '^18.2.0' }, 'react-dom': { singleton: true, requiredVersion: '^18.2.0' }, }, }), ], };remotes 的 key 是主应用内部使用远程模块时的别名,value 由子应用的 name 和 remoteEntry 地址拼接而成,这个格式一定不能写错。配置完成后,主应用代码里就可以用 import('order/OrderModule') 这种方式来引用远程模块了。
在 React 组件里,配合 React.lazy 和 Suspense 实现异步加载:
import { Suspense, lazy } from 'react'; import { Routes, Route, Link } from 'react-router-dom'; const OrderModule = lazy(() => import('order/OrderModule')); const UserModule = lazy(() => import('user/UserModule')); function App() { return ( <div> <nav> <Link to="/order">订单</Link> <Link to="/user">用户</Link> </nav> <Suspense fallback={<div>模块加载中...</div>}> <Routes> <Route path="/order/*" element={<OrderModule />} /> <Route path="/user/*" element={<UserModule />} /> </Routes> </Suspense> </div> ); } export default App;这个文件其实就是 rea 主应用的全部业务代码。路由和模块的对应关系非常直观,新增一个子应用,只需要在 remotes 里加一条地址,再在路由表里加一个 Route 即可。
3.4 用环境变量区分开发与上环境
示例里 remotes 的地址是写死的,实际项目中绝对不能这么干。开发环境的子应用地址和线上环境肯定不同,甚至测试环境和预发环境也会不一样。每次切换环境都去改 webpack 配置,不仅笨拙而且很容易漏掉。
我用 dotenv 来做环境变量管理。主应用里创建 .env 文件,把远程模块地址抽出来:
ORDER_REMOTE=http://localhost:3001/remoteEntry.js USER_REMOTE=http://localhost:3002/remoteEntry.js然后 webpack 配置通过 process.env 读取:
require('dotenv').config(); const REMOTE_MAP = { order: `order@${process.env.ORDER_REMOTE}`, user: `user@${process.env.USER_REMOTE}`, };这样打包主应用时,只需要在不同环境下维护对应的 .env 文件,就可以实现远程模块地址的动态切换。部署的时候,线上环境把 .env 里的地址改成 CDN 上的 remoteEntry 地址即可。
还有一个更灵活的方式,是把 remoteEntry 地址放到运行时配置里,主应用启动时再动态加载。这种方式适合子应用地址经常变动的场景,但复杂度会增加不少,rea 示例暂时没有采用。对于中后台项目来说,编译期环境变量已经足够满足大多数需求。
4. 联调部署中的问题记录与排查方法
4.1 静态资源相对路径导致的样式 404
我第一次跑通主应用和子应用联调时,页面能渲染出来,但子应用里的样式全部丢了。打开控制台看到一堆 css 文件请求的是主应用域名下的路径,例如 http://localhost:3000/static/css/main.css。原因是子应用构建时,output.publicPath 没有被正确设置,webpack 生成的资源引用地址以相对路径输出,子应用模块嵌入主应用后,浏览器按主应用当前的 URL 去解析这些相对路径,自然就找不到文件。
解决方案是子应用把 output.publicPath 设为完整地址,或者统一设为 auto。auto 模式下,webpack 会根据模块脚本的 src 前缀自动推导资源地址,基本能做到开箱即用。我在 order 和 user 两个子应用里都用的是 auto,这个配置在开发环境很省心。但要注意,部署到生产环境时,如果子应用资源放在独立 CDN 域名下,auto 未必能猜对域名,最好还是显式指定 CDN 完整地址。
4.2 共享库被加载成多份实例
前面提到我最早没配置 shared,导致 React 双实例的问题。实际工程里,即使配了 shared,也还是可能在控制台看到 Duplicate React 或 Invalid hook call 的报错。常见原因有两个,一是子应用之间的 React 版本不一致,二是某个子应用没有配置 shared,导致它把 React 直接打包进了自己的产物。
排查方法比较简单。先打开浏览器的 Network 面板,搜索 react 相关的 vendor chunk,看看页面上一共加载了几份 React 构建产物;如果只有一份,说明 shared 生效了。如果看到两份以上,就需要逐个检查子应用的 ModuleFederationPlugin 配置,确保 shared 字段里都声明了 react 和 react-dom。版本不一致的情况,能升级就升级,不能升级就把 requiredVersion 写宽松一些,但这只是临时方案,长期来看统一 React 版本才是最干净的。
4.3 子应用路由 base 不对,页面刷新 404
当子应用内部使用 react-router-dom 时,会遇到一个非常隐蔽的路由问题。主应用的路由是 /order/*,子应用内部如果还有一个 /detail/:id 这样的二级路由,直接刷新 /order/detail/123 页面会 404。因为主应用在 devServer 层面并没有真正为子应用内部的路径配置 fallback,更根本的原因是主应用路由表和子应用内部路由表没有对齐。
我的实际经验是不建议让子应用内部维护一套完整路由。rea 的做法是,子应用只暴露模块入口组件,路由规则全部由主应用统一维护。如果子应用确实有二级页面,就把二级路径放进主应用的路由表里,或者在子应用入口组件里接收一个 basename 参数,所有的内部链接都拼接这个前缀。前者更适合中后台项目,因为导航和权限都集中在主应用,业务团队的认知负担也更小。
4.4 几个容易忽略的实操细节
最后分享几个我在实操中踩过的小坑,它们不致命,但遇到了会浪费不少时间。
第一个是跨域头。如果子应用不在主应用的同一个域名下,devServer 里一定要配置 Access-Control-Allow-Origin。我见过有人本地联调一切正常,部署到测试环境后突然加载不出子应用,就是因为网关或 CDN 层没有透传跨域头。第二个是缓存的坑。remoteEntry.js 是模块联邦的入口文件,如果浏览器或 CDN 对它设置了强缓存,子应用发版后主应用可能仍然加载旧的模块,页面表现就是“改了但没生效”。解决方案是打包时给 remoteEntry.js 带上内容 hash,或者在请求时加时间戳参数。第三个是 Suspense 的加载体验。远程模块首次加载通常要拉取多个 JS 文件,若 fallback 只是一个空 div,用户会以为页面卡死了。可以在 fallback 里加一个基础的 loading 文案或骨架屏,体验会好很多。
另外,模块联邦并不适合把页面上的每一个小组件都拆出来。我见过有人把 Button、Table 这种基础组件也暴露成 remote,结果主应用首屏要并发加载几十个远程模块,性能和体验双双下降。合理的拆分粒度是业务域,如果一个模块的代码量少于几千行,就没有必要单独做子应用。rea 最终只拆了订单、用户、报表这类完整业务模块,公共组件仍然放在主应用或独立的 npm 包里统一维护。
我在实际落地过程中,最深的体会是微前端改造的难点不在技术,而在边界划分。rea 能帮我把技术边界理清楚,但业务边界还是得自己拿主意。拆得太粗,巨石应用的问题还在;拆得太细,又会陷入模块联邦的运维泥潭。如果你正在做类似的改造,建议先从一条完整的业务链路开始试点,跑通之后再逐步扩展,别想着一步到位。这套方案把一个失控的大项目重新拉回到了可维护的轨道上,代价是前期要花一些时间在设计边界和共享策略上,但从长远来看非常值得。