Module Federation 服务端到服务端联邦实战:基于 react-18-server-2-server 示例的组件与路由跨服务共享
Module Federation 服务端到服务端联邦实战:基于 react-18-server-2-server 示例的组件与路由跨服务共享
导读
本篇文章以 module-federation-examples 仓库中的 react-18-server-2-server 示例为核心,讲解如何让两个独立的 Express 服务在 Node.js 服务端通过 Module Federation 联邦化地共享 React 组件与 HTTP 路由函数。读完本文,你将掌握 @module-federation/node 的 UniversalFederationPlugin 在 SSR 场景下的双配置写法(客户端/服务端各一份)、如何在服务端动态 import 远端路由并传入 req/res 对象、以及如何让远端组件通过 props 保持响应式。
一、示例整体架构:两个 Express 服务器互为 host 与 remote
react-18-server-2-server 目录下包含两个独立应用,二者都由 Express 启动,并且都既承担服务端渲染(SSR)又承担模块联邦角色:
- app1(端口
3000):一个带 Express 服务器的 React 应用。它作为 host 消费来自 app2 的联邦组件与联邦路由;自身还暴露了一个/api/userAPI 路由。 - app2(端口
3001):一个带 Express 服务器的 React 应用。它作为 remote 暴露一个名为Content的 React 组件,以及一个名为userRoute的 Express 路由函数。
两个应用的目录结构完全对称(app1 / app2),各自包含:
config/ # webpack 与模块联邦配置
module-federation.js # 联邦插件配置(client / server 双份)
webpack.client.js # 客户端构建配置
webpack.server.js # 服务端构建配置
webpack.shared.js # 公共 loader 配置
src/
client/ # 浏览器端 React 代码
server/ # Express 服务端代码
数据流的核心链路是:浏览器访问 http://localhost:3000/ → app1 服务端渲染页面 → 页面中 React.lazy 加载来自 app2 的 Content 组件;同时页面挂载后通过 fetch 请求 app1 自身的 /api/user → app1 服务端在运行时 await import('app2/userRoute') 加载 app2 暴露的路由函数 → 将 Express 的 req 与 res 传给该函数 → 由 app2 的代码生成 JSON 响应返回给浏览器。
二、运行示例:从安装依赖到双端口启动
示例的根目录 package.json 提供了统一入口脚本,其中 prestart/preserve 会调用仓库 scripts/track-demo-run.js 记录演示运行情况。
# 1. 在仓库根目录安装依赖(pnpm workspace)
pnpm install
# 2. 构建并启动所有应用(app1 与 app2 并行)
pnpm run start
start 脚本等价于对两个子应用并行执行 pnpm build && pnpm serve,即先构建再启动。启动完成后端口映射如下:
| 应用 | 地址 | 角色 |
|---|---|---|
| app1 | http://localhost:3000/ | Host(消费组件与路由) |
| app2 | http://localhost:3001/ | Remote(暴露组件与路由) |
每个子应用(如 app1/package.json)的构建脚本是:
"build:client": "rimraf dist/client && webpack --mode production --progress --config config/webpack.client.js",
"build:server": "rimraf dist/server && webpack --mode production --progress --config config/webpack.server.js",
"serve": "node dist/server/main.js"
先产出 dist/client(浏览器端资源,由 /static 静态目录对外提供),再产出 dist/server(Node.js 端 bundle,由 node dist/server/main.js 启动)。
三、核心配置:一份 Module Federation 配置,同时面向浏览器与 Node
服务端联邦与纯浏览器联邦最大的区别在于:同一份联邦配置需要分别作用于 web(客户端 bundle)与 node(服务端 bundle),且二者消费远端的方式完全不同。示例通过 app1/config/module-federation.js 展示了这一写法:
const deps = require('../package.json').dependencies;
const { ModuleFederationPlugin } = require('@module-federation/enhanced/webpack');
const { UniversalFederationPlugin } = require('@module-federation/node');
module.exports = {
client: new ModuleFederationPlugin({
experiments: { asyncStartup: true },
name: 'app1',
filename: 'remoteEntry.js',
remotes: {
app2: 'app2@http://localhost:3001/static/remoteEntry.js',
},
shared: [{ react: deps.react, 'react-dom': deps['react-dom'] }],
}),
server: [
new UniversalFederationPlugin({
remoteType: 'script',
isServer: true,
name: 'app1',
library: { type: 'commonjs-module' },
filename: 'remoteEntry.js',
remotes: {
app2: 'app2@http://localhost:3001/server/remoteEntry.js',
},
shared: [{ react: deps.react, 'react-dom': deps['react-dom'] }],
}),
],
};
关键点逐一拆解:
- client 配置:使用
@module-federation/enhanced/webpack提供的ModuleFederationPlugin,设置experiments: { asyncStartup: true }允许异步启动;远端地址指向app2的客户端 remoteEntry:http://localhost:3001/static/remoteEntry.js。 - server 配置:使用
@module-federation/node提供的UniversalFederationPlugin,并显式声明isServer: true、remoteType: 'script'、library: { type: 'commonjs-module' }——因为服务端 bundle 以 CommonJS 形式运行,需要以 Node 可加载的方式注册联邦模块。远端地址指向 app2 的服务端 remoteEntry:http://localhost:3001/server/remoteEntry.js。 - 共享依赖:
shared声明react与react-dom来自各自package.json的依赖版本(两个应用均固定使用 React18.3.1,见 app2/package.json),保证 SSR 时两端不会加载双份 React。
remote 端(app2)的配置在 app2/config/module-federation.js 中,它定义了暴露清单:
server: [
new UniversalFederationPlugin({
isServer: true,
name: 'app2',
library: { type: 'commonjs-module' },
remoteType: 'script',
filename: 'remoteEntry.js',
exposes: {
'./Content': './src/client/components/Content',
'./userRoute': './src/server/routes/user',
},
remotes: {},
shared: [{ react: deps.react, 'react-dom': deps['react-dom'] }],
}),
],
注意这里 ./userRoute 暴露的是 server 目录下的路由文件,而 ./Content 暴露的是 client 组件文件——服务端联邦允许把 Node 环境下的函数与组件统一纳入联邦远程加载体系。
webpack 双构建配置
webpack.client.js 以 target: 'web' 构建,输出到 dist/client,publicPath 为 http://localhost:3000/static/;webpack.server.js 以 target: false(交给 Node 环境处理)构建,libraryTarget: 'commonjs-module',输出到 dist/server,并挂载 moduleFederationPlugin.server 数组中的插件。两者通过 webpack.shared.js 共用 babel-loader 与 tsx/ts/jsx/js/json 解析规则。
四、路由联邦:把 Express 路由函数跨服务暴露
这是本示例最具特色的部分——联邦化的对象不是 UI 组件,而是一个 Express 路由处理函数。
remote 端:定义并暴露路由函数
app2 在 app2/src/server/routes/user.ts 中定义了一个接收 Express Request/Response 的函数:
import { Request, Response } from 'express';
export interface UserDTO {
id: number;
name: string;
company: string;
}
export async function userRoute(req: Request, res: Response) {
const response: UserDTO = {
id: 1337,
name: 'John Doe',
company: 'Acme Inc.',
};
res.json(response);
}
export default userRoute;
host 端:运行时动态 import 并转发请求
app1 在 app1/src/server/index.ts 中注册了 /api/user 路由,使用 await import('app2/userRoute') 在服务端运行时从远端加载该函数,再把当前请求的 req 与 res 原样传入:
app.get('/api/user', async (req, res) => {
try {
// loads the userRoute route function from another app2
const federatedUserRoute = (await import('app2/userRoute')).userRoute;
// calls the userRoute function
await federatedUserRoute(req, res);
} catch (e) {
console.error(e);
res.status(500).send('Internal Server Error');
}
});
// Or in a more direct way
// app.get('/api/user', async (...args) => (await import('app2/userRoute')).userRoute(args[0], args[1]));
这段代码揭示了服务端联邦的典型用法:import('app2/userRoute') 在构建期会被 UniversalFederationPlugin 改写,运行时从 app2 的服务端 remoteEntry 加载对应模块;加载成功后,请求处理逻辑完全托管给远端函数,本地仅做错误兜底(500 + 日志)。示例还注释了一条更简洁的等价写法,直接以剩余参数形式转发 req/res。
类型安全:通过本地 .d.ts 声明远端模块
由于 app2/userRoute 与 app2/Content 是运行时动态模块,TypeScript 无法直接解析。示例在 app1/index.d.ts 中手动补齐类型声明,将远端模块映射到本地源码路径:
declare module 'app2/Content' {
export * from '@app2/client/components/Content';
}
declare module 'app2/userRoute' {
export * from '@app2/server/routes/user';
}
这样在 app1/src/client/components/App.tsx 中就能以类型安全的方式引用 import type { ContentProps } from 'app2/Content' 与 import type { UserDTO } from 'app2/userRoute'。
五、组件联邦:SSR 下远端组件的懒加载与响应式
remote 端:暴露带 props 的组件
app2 的 app2/src/client/components/Content.tsx 暴露了一个接收 content?: string props 的组件,并把内容渲染在带有 data-e2e="APP_2_CONTENT_BLOCK" 标记的容器中。该组件同时被 app2 的 client 与 server 两份联邦配置暴露,因此浏览器端与服务端都能加载它。
host 端:React.lazy + Suspense 消费远端组件
app1 的 App.tsx 使用 React.lazy 加载远端组件,并用 React.Suspense 提供加载态:
const Content = React.lazy(
() => import('app2/Content') as Promise<{ default: React.FC<ContentProps> }>,
);
// ...
const [state, setState] = React.useState<string>('');
// ...
<React.Suspense fallback={<h1>Loading....</h1>}>
<Content content={state} />
</React.Suspense>
响应性验证:Content 接收的 content prop 直接绑定本地输入框的 state,用户每输入一个字符,远端组件的 strong 标签就会同步更新。这证明了联邦组件在 SSR 场景下依然是“真组件”,props 更新、状态传递与本地组件行为完全一致。
同页数据联动:浏览器 fetch → 服务端联邦路由
App.tsx 还通过 useEffect 发起 fetch('/api/user') 请求 app1 自己的联邦化 API 路由,把返回的 UserDTO(id / name / company)渲染在页面上:
const fetchUserData = async () => {
const response = await fetch('/api/user');
return response.json();
};
这条链路形成了完整闭环:浏览器 → host 服务端(app1)→ remote 服务端(app2)→ 浏览器,直观展示了“服务端到服务端”联邦的价值——数据生产方与渲染方完全解耦。
六、SSR 渲染与客户端水合
两个应用使用相同的服务端渲染链路:index.ts 启动 Express → middleware.ts 注册静态资源与渲染兜底 → server-entry.ts 延迟加载 renderer → render.tsx 执行渲染。
render.tsx 使用 React 18 的 renderToPipeableStream 输出 HTML 流:
const stream = renderToPipeableStream(<App />, {
onAllReady() {
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-type', 'text/html');
res.write(`<!DOCTYPE html>`);
// ... 注入 Helmet 管理的 title/meta/link
res.write(`<div id="root">`);
stream.pipe(res);
res.write(`</div>`);
res.write(
`<script async data-chunk="main" src="http://localhost:3000/static/main.js"></script>`,
);
res.write(`</body></html>`);
},
onShellError() { /* 500 */ },
onError(err) { didError = true; },
});
配套地,app1/src/client/index.tsx 使用 ReactDOMClient.hydrateRoot 对服务端输出的 #root 进行水合,实现 SSR 与 CSR 的平滑衔接(app1 页面甚至提示用户可以禁用 JavaScript 后刷新验证服务端渲染的完整性)。react-helmet 用于在服务端同步管理 <title>、<meta> 等头部标签。
七、关键注意事项与运行限制
- 服务端文件暴露警告:
middleware.ts中app.use('/server', express.static(path.join(process.cwd(), 'dist/server')))会把dist/server目录整体作为静态资源暴露,源码注释明确标注了 WARNING:"ATTENTION this will expose ALL server files"。该写法仅为本地示例演示服务端 remoteEntry 加载,生产环境必须收敛静态目录范围或改用其他下发机制。 - 端口固定:配置中硬编码了
3000/3001端口及 remote 的绝对地址(http://localhost:3001/...),启动顺序上 app2(remote)需先行可达,否则 app1 运行时加载远端模块会报错并落入 500 兜底分支。 - 依赖版本:示例基于 React
18.3.1、Express4.19.2、@module-federation/enhanced2.0.1与@module-federation/node2.7.32(见 app1/package.json),这些是当前仓库锁定的实际版本,升级联邦相关包时需重新验证兼容性。 - shared 依赖:两端需共享同一份
react/react-dom(此处均为 18.3.1),避免 SSR 场景出现重复 React 实例。
八、端到端验证:Playwright 测试如何兜底
仓库为该示例配备了完整的 Playwright 端到端测试 e2e/checkReact18Server2ServerApps.spec.ts,覆盖两个关键行为:
- 初始渲染:分别在
3000(app1)与3001(app2)验证 H1/H2/H3 标题、输入框以及 ID/Name/Company 用户信息字段可见。 - 远端内容响应式:向输入框填充固定短语(
STANDARD_PHRASE)后,断言远端APP_2_CONTENT_BLOCK容器内的标题、描述段落以及<strong>中回显的自定义文本与输入一致。
该测试从用户视角闭环验证了“组件联邦 + 路由联邦 + 输入响应”三条核心能力,是复现本文所述行为最直接的证据。运行方式为在 react-18-server-2-server 目录执行 pnpm e2e:ci(对应脚本 pnpm exec playwright test,配置见 playwright.config.ts)。
结语
react-18-server-2-server 是理解 Server 2 Server Module Federation 的最佳入门示例:它以最小化的双应用结构,同时演示了 React 组件的跨服务懒加载、Express 路由函数的跨服务动态调用,以及 React 18 SSR 流式渲染与客户端水合的组合用法。核心要点可以概括为:UniversalFederationPlugin 双配置(web + node)决定两端各自的联邦边界,server 配置用 commonjs-module 形态承载 Node 端 remoteEntry,host 端以 await import() 统一消费远端组件与路由函数。后续若要在生产环境落地,可在此基础上重点改造静态目录暴露策略与远端地址的运行时下发方式。