Module Federation 实战:基于 React 18 Suspense 的服务端渲染(SSR)示例解析
Module Federation 实战:基于 React 18 Suspense 的服务端渲染(SSR)示例解析
导读
本文围绕开源仓库 module-federation-examples 中的 react-18-ssr 示例,完整讲解如何用 Module Federation 实现 React 18 服务端渲染(SSR):一个 shell 宿主应用在 Node.js 服务端拉取并渲染来自远端应用(remote)的组件,同时借助 React.lazy + React.Suspense 在服务端与客户端两侧无缝衔接。读完本文,你将掌握 @module-federation/node 的 UniversalFederationPlugin 与 @module-federation/enhanced 双端(client/server)构建配置、renderToPipeableStream 流式 SSR 渲染链路,以及嵌套远程模块在 SSR 场景下的实际落地方案。
示例整体架构:shell、remote1、remote2 三角色
本示例由三个相互独立、又可以组合协作的应用组成,对应仓库中的三个目录:
| 应用 | 目录 | 角色与职责 |
|---|---|---|
shell |
react-18-ssr/shell | 宿主(host)应用,内置 SSR 服务器,负责渲染整页 HTML 并加载远端组件 |
remote1 |
react-18-ssr/remote1 | 独立应用,暴露 Content 组件,同时作为消费者引入 remote2 的 Image 组件 |
remote2 |
react-18-ssr/remote2 | 独立应用,暴露 Image 组件,处于依赖链的最底层 |
三个应用之间形成了清晰的远程依赖链:shell → remote1 → remote2。也就是说,shell 服务端渲染时加载的是 remote1 的 Content,而 Content 内部又通过 React.lazy(() => import('remote2/Image')) 嵌套加载 remote2 的 Image 组件——这正是在服务端渲染场景下演示“嵌套联邦模块(nested federated modules)”的核心用例。
运行 Demo:安装依赖与一键启动
原文档给出的运行方式非常简洁,本仓库在此基础上补充具体细节。
第一步:安装依赖。 在仓库根目录执行:
yarn
第二步:构建并启动全部应用。 执行:
pnpm run start
该命令定义在 react-18-ssr/package.json 中:
{
"scripts": {
"start": "pnpm --parallel --filter react-ssr_* start",
"build": "pnpm --parallel --filter react-ssr_* build",
"prestart": "node ../scripts/track-demo-run.js"
}
}
pnpm --parallel --filter react-ssr_* start 会并行启动所有 react-ssr_ 前缀的 workspace 应用。每个应用的 start 脚本(见各应用 package.json)实际执行的是:
"start": "pnpm build && pnpm serve"
即先构建(build),再启动服务(serve)。其中 shell 的 serve 是 node dist/server/main.js,直接用 Node 运行打包产物;remote1 / remote2 的 serve 则用 npx serve dist/ -p <端口> 提供静态资源服务。
启动完成后,各应用的端口映射如下(继承自原文档):
localhost:3000—— SHELL(宿主 SSR 应用)localhost:3001—— STANDALONE REMOTE1(独立运行的 remote1)localhost:3002—— STANDALONE REMOTE2(独立运行的 remote2)
打开 localhost:3000 即可看到 shell 服务端渲染出的完整页面。值得一提的是,shell 的 App 组件 中特意提示“You can try to disable JavaScript and reload the page.”——关闭浏览器 JavaScript 后刷新页面,你依然能看到由服务端直接渲染出的 HTML 内容,这正是 SSR 的价值所在;而 remote2 的 Image 组件 内嵌了一个“Click me to test i'm interactive!”按钮,用于验证水合(hydration)之后客户端交互依然正常。
双端构建体系:client 与 server 两套 Webpack 配置
SSR 场景下,每个应用都需要产出两套产物:浏览器端产物(供 hydration 与静态资源访问)与 Node 服务端产物(供服务端渲染)。本示例统一采用「共享配置 + 双入口配置」的组织方式,每个应用下都有独立的 config 目录:
webpack.shared.js—— 共享的 loader 与 resolve 规则webpack.client.js—— 客户端构建配置webpack.server.js—— 服务端构建配置module-federation.js—— 联邦插件配置(同时导出 client / server 两套插件实例)
以 shell/config/webpack.shared.js 为例,共享配置统一通过 babel-loader 处理 .js/.jsx/.ts/.tsx 文件,并开启 type: 'javascript/auto' 以便解析 .mjs 模块:
module.exports = {
resolve: {
extensions: ['.tsx', '.ts', '.jsx', '.js', '.json'],
},
module: {
rules: [
{
test: /\.m?js$/,
type: 'javascript/auto',
resolve: { fullySpecified: false },
},
{
test: /\.(js|ts)x?$/,
loader: 'babel-loader',
exclude: /node_modules/,
},
],
},
};
客户端构建配置(shell/config/webpack.client.js)的关键点:
const webpackConfig = {
name: 'client',
target: 'web',
entry: ['@babel/polyfill', path.resolve(__dirname, '../src/client/index')],
mode: 'production',
devtool: 'source-map',
output: {
path: path.resolve(__dirname, '../dist/client'),
filename: '[name].js',
chunkFilename: '[name].js',
publicPath: 'http://localhost:3000/static/',
},
plugins: [moduleFederationPlugin.client],
};
target: 'web',入口指向src/client/index,产物输出到dist/client;publicPath指向http://localhost:3000/static/,与服务端静态资源挂载路径(见下文middleware.ts)严格对应;- 插件只挂载
moduleFederationPlugin.client。
服务端构建配置(shell/config/webpack.server.js):
const webpackConfig = {
name: 'server',
target: false,
entry: ['@babel/polyfill', path.resolve(__dirname, '../src/server/index')],
output: {
path: path.resolve(__dirname, '../dist/server'),
filename: '[name].js',
libraryTarget: 'commonjs-module',
},
mode: 'production',
plugins: [...moduleFederationPlugin.server],
};
target: false表示不面向浏览器环境,产物供 Node 使用;libraryTarget: 'commonjs-module'将产物打包为 CommonJS 模块,便于node dist/server/main.js直接启动;- 插件挂载
moduleFederationPlugin.server(数组形式)。
remote1 / remote2 的 webpack.config.js 则把两份配置合并导出为数组,一次构建同时产出双端产物:
const client = require('./config/webpack.client');
const server = require('./config/webpack.server');
module.exports = [client, server];
联邦插件配置:ModuleFederationPlugin 与 UniversalFederationPlugin 双剑合璧
每个应用的 module-federation.js 同时引入两个插件:
const deps = require('../package.json').dependencies;
const { ModuleFederationPlugin } = require('@module-federation/enhanced/webpack');
const { UniversalFederationPlugin } = require('@module-federation/node');
- 客户端:使用
@module-federation/enhanced提供的ModuleFederationPlugin; - 服务端:使用
@module-federation/node提供的UniversalFederationPlugin。
shell:只做 host,不暴露任何组件
shell/config/module-federation.js 中的完整配置:
module.exports = {
client: new ModuleFederationPlugin({
experiments: { asyncStartup: true },
name: 'shell',
filename: 'container.js',
remotes: {
remote1: 'remote1@http://localhost:3001/client/remoteEntry.js',
},
shared: [{ react: deps.react, 'react-dom': deps['react-dom'] }],
}),
server: [
new UniversalFederationPlugin({
remoteType: 'script',
name: 'shell',
isServer: true,
library: { type: 'commonjs-module' },
filename: 'remoteEntry.js',
remotes: {
remote1: 'remote1@http://localhost:3001/server/remoteEntry.js',
},
shared: [{ react: deps.react, 'react-dom': deps['react-dom'] }],
}),
],
};
关键参数解读:
experiments: { asyncStartup: true }:启用异步启动,使远程模块的加载可以与应用的初始化并行,配合React.lazy在 SSR 下正常工作;name:联邦应用名称,shell;filename:客户端产物名为container.js(host 无需对外暴露模块,容器文件仅内部使用),服务端为remoteEntry.js;remotes:声明远端来源。注意 client 与 server 引用的 remoteEntry 地址不同——客户端从http://localhost:3001/client/remoteEntry.js加载,服务端从http://localhost:3001/server/remoteEntry.js加载,这正是“双端 remoteEntry”设计的体现;remoteType: 'script':服务端以 script 方式加载远端;library: { type: 'commonjs-module' }:服务端产物以 CommonJS 模块形式暴露;isServer: true:标识该插件运行于 Node 服务端环境;shared:共享react与react-dom,版本取自各应用package.json的dependencies(本示例统一为 React 18.3.1,见 shell/package.json)。
remote1:既暴露 Content,又消费 remote2 的 Image
remote1/config/module-federation.js 展示了“既是 remote 又是 host”的双重身份:
module.exports = {
client: new ModuleFederationPlugin({
experiments: { asyncStartup: true },
name: 'remote1',
filename: 'remoteEntry.js',
remotes: {
remote2: 'remote2@http://localhost:3002/client/remoteEntry.js',
},
exposes: {
'./Content': './src/Content',
},
shared: {
...deps,
react: { singleton: true, requiredVersion: deps.react },
'react-dom': { singleton: true, requiredVersion: deps['react-dom'] },
},
}),
server: [
new UniversalFederationPlugin({
remoteType: 'script',
name: 'remote1',
filename: 'remoteEntry.js',
library: { type: 'commonjs-module' },
isServer: true,
remotes: {
remote2: 'remote2@http://localhost:3002/server/remoteEntry.js',
},
exposes: {
'./Content': './src/Content',
},
shared: {
...deps,
react: { singleton: true, requiredVersion: deps.react },
'react-dom': { singleton: true, requiredVersion: deps['react-dom'] },
},
}),
],
};
与 shell 配置相比,多出两个要点:
exposes:把./src/Content暴露为远程模块./Content,供 shell 消费;shared的 singleton 策略:react与react-dom声明为singleton: true并指定requiredVersion。在 SSR 场景下这是必须的——服务端进程内只能有一份 React 实例,否则多个 React 副本会导致渲染状态错乱。客户端同样保持 singleton,确保水合时两端 React 版本一致。
remote2:纯 remote,暴露 Image 组件
remote2/config/module-federation.js 结构最简:remotes 为空对象,仅通过 exposes 暴露 ./Image,共享依赖策略与 remote1 相同,同样同时产出 client / server 两套 remoteEntry。
SSR 渲染链路源码剖析:从 Express 到流式输出
理解了三应用的联邦配置后,再来追踪 shell 服务端渲染的完整调用链。相关源码均位于 react-18-ssr/shell/src/server。
① 入口 index.ts:创建 Express 应用并监听 3000 端口:
const app = express();
const PORT = 3000;
const done = () => {
app.listen(PORT, () => {
console.info(`[${new Date().toISOString()}]`, `Shell App is running: 🌎 http://localhost:${PORT}`);
});
};
initMiddleware(express, app, done);
② 中间件 middleware.ts:挂载静态资源目录并注册渲染路由:
function middleware(express, app, done) {
// static path where files such as images and js will be served from
app.use('/static', express.static('./dist/client'));
const renderThunk = require('./server-entry').default;
const serverRender = renderThunk();
app.get('/*', serverRender);
done();
}
/static 路径对应 dist/client 目录,与客户端构建的 publicPath: 'http://localhost:3000/static/' 一一对应,保证浏览器能加载到打包后的 main.js 等资源。
③ 懒加载渲染器 server-entry.ts:通过动态 import('./render') 间接加载渲染模块,为渲染器代码提供独立的 chunk:
export default () => async (req, res, next) => {
const renderer = (await import('./render')).default;
return renderer(req, res, next);
};
④ 核心渲染器 render.tsx:使用 React 18 的 renderToPipeableStream 实现流式 SSR:
const stream = renderToPipeableStream(<App />, {
onAllReady() {
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-type', 'text/html');
res.write(`<!DOCTYPE html`);
res.write(`<html ${helmet.htmlAttributes.toString()}>
<head>
${helmet.title.toString()}
${helmet.meta.toString()}
${helmet.link.toString()}
</head>
<body>`);
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() {
res.statusCode = 500;
res.send(`<h1>An error occurred</h1>`);
},
onError(err) {
didError = true;
console.error(err);
},
});
要点说明:
- 选择
onAllReady回调而非onShellReady,表示等待全部(包括 Suspense 包裹的远程组件)渲染完成后再 flush 完整 HTML,确保远端模块内容进入首屏 HTML; - 使用
react-helmet管理<head>中的 title / meta / link; - 渲染完成后在
<div id="root">之后注入<script async data-chunk="main" src=".../static/main.js"></script>,供客户端 hydration 使用; onError回调把错误状态记为didError,若 shell 渲染失败则返回 500 状态码。
客户端水合与远程组件加载
客户端入口 shell/src/client/index.tsx 使用 React 18 的 createRoot + hydrateRoot:
const render = App => {
const root = document.getElementById('root');
ReactDOMClient.hydrateRoot(root, <App />);
};
render(App);
而 shell 的 App 组件 中,远程组件通过 React.lazy 加载:
const Content = React.lazy(
() => import('remote1/Content') as Promise<{ default: React.FC<ContentProps> }>,
);
// ...
<React.Suspense fallback={<h1>Loading....</h1>}>
<Content content={state} />
</React.Suspense>
同理,remote1 的 Content 组件 内部再以 React.lazy(() => import('remote2/Image')) 嵌套加载 remote2 的 Image。这样一条“shell → remote1 → remote2”的懒加载链,在服务端 onAllReady 时会等待所有嵌套层级全部就绪;在客户端则由 Suspense 提供加载占位,水合后恢复交互。
值得注意的细节:shell 的 App 是一个受控组件——输入框的值 state 通过 content prop 传入远程 Content,并最终显示为“Dynamic content: xxx”。这验证了 SSR 下 host 向 remote 传递 props、远程组件响应式更新渲染内容的可行性。
独立运行与 TypeScript 类型声明
独立运行 remote
每个 remote 都是可以独立打开的应用:localhost:3001 直接访问 remote1(渲染 Content 组件)、localhost:3002 直接访问 remote2(渲染 Image 组件),入口分别为 remote1/src/index.ts 和 remote2/src/index.ts,两者都通过 import('./App') 动态挂载,并用 html-webpack-plugin(见 remote1/config/webpack.client.js)基于各自的 index.html 生成页面。独立运行的价值在于:remote 可以脱离 host 单独开发、单独部署、单独验证,这正是联邦微前端的部署灵活性。
TypeScript 远程模块类型声明
由于 import('remote1/Content') 这类远程导入在编译期没有实际模块,项目通过 .d.ts 声明文件补充类型信息。根级 tsconfig.base.json 用路径别名映射源码:
"paths": {
"@remote1/*": ["./remote1/src/*"],
"@remote2/*": ["./remote2/src/*"],
"@shell/*": ["./shell/src/*"]
}
配合 shell/index.d.ts:
declare module 'remote1/Content' {
export * from '@remote1/Content';
}
declare module 'remote2/Image' {
export * from '@remote2/Image';
}
这样 TypeScript 便能从真实源码中推导出 ContentProps 等类型(ContentProps 定义于 Content.tsx,包含可选的 content?: string 属性),在保留运行时动态远程加载的同时获得完整的静态类型检查。
关键依赖版本与仓库横向对照
本示例各应用 package.json 中的核心依赖版本(可直接复现):
react/react-dom:18.3.1express:4.19.2(仅 shell)react-helmet:6.1.0(仅 shell)@module-federation/node:2.7.32(提供UniversalFederationPlugin)@module-federation/enhanced:2.0.1(remote1 / remote2 提供客户端ModuleFederationPlugin;shell 的客户端插件同样来自 enhanced)webpack:5.105.3、webpack-cli:5.1.4、webpack-merge:6.0.1- Babel 工具链:
@babel/preset-env/@babel/preset-react/@babel/preset-typescript/babel-loader等
如果希望进一步研究「服务端到服务端」的联邦通信模式(本示例的 shell 只向 remote1 单向拉取),仓库中还提供了进阶示例 react-18-server-2-server,其 app1 / app2 均同时具备 exposes 与 remotes,构建配置同样采用 webpack.client.js + webpack.server.js 双端拆分(见 app1/config/webpack.client.js 与 app1/config/webpack.server.js),可作为双向 SSR 联邦的对照参考。
小结
通过 react-18-ssr 示例可以看到,Module Federation 的 SSR 落地依赖三条核心设计:
- 双端构建:每个应用同时产出 client 产物(供浏览器 hydration)与 server 产物(供 Node 渲染),remoteEntry 按
/client/与/server/分别暴露; - 双插件协作:客户端用
ModuleFederationPlugin,服务端用UniversalFederationPlugin,且shared中的react/react-dom必须声明为singleton,保证服务端单实例渲染; - React 18 流式渲染:
renderToPipeableStream+onAllReady等待 Suspense 嵌套远程组件全部就绪后输出完整 HTML,客户端再以hydrateRoot无缝接管。
按照文档步骤(安装依赖 → pnpm run start)即可在 localhost:3000 直观验证:禁用 JavaScript 后页面内容依然完整呈现,而启用 JavaScript 后远程组件的按钮交互依然可用——这就是“服务端渲染 + 模块联邦”组合方案最直接的演示。