server-side-render-only 深度解析:纯服务端渲染下的 Module Federation 组件共享实战
server-side-render-only 深度解析:纯服务端渲染下的 Module Federation 组件共享实战
本指南以 module-federation-examples 仓库中的 server-side-render-only 示例 为蓝本,讲解在完全没有客户端渲染实现(no client side implementation)的前提下,如何通过 Module Federation 让两个独立部署的 Node 服务端(Host 与 Remote)共享同一个 React 组件,并实现"修改远端组件后,Host 无需重新构建即可同步生效"的热更新式协作。读完本文,你将掌握服务端模块联邦的核心 webpack 配置、动态导入规避启动崩溃的工程技巧,以及一套可复用的端到端验证方案。
示例整体架构:两个 Node 服务、一份共享组件
该示例由两个 Express 服务构成,两者都使用 react-dom/server 的 renderToString 在服务端把组件渲染为 HTML 字符串后直接返回,全程不加载任何客户端 bundle:
- Host Server(hostServer):监听
http://localhost:3000/,负责消费远程组件; - Remote Server(remoteServer):监听
http://localhost:3001/,负责暴露SharedComponent。
两个服务的 HTML 中会分别出现 Host Server 与 Remote Server 的 <h1> 标题,而 SharedComponent.js 渲染的 Shared Component2222 文本会同时出现在两个页面中——这正是 Host 成功在服务端拉取并渲染远端组件的最直观证明。
npm start
http://localhost:3000/ - Host Server
http://localhost:3001/ - Remote Server
一键启动:webpack 监听构建 + nodemon 热重启
示例根目录的 package.json 用 concurrently 把四条命令并行执行,构成完整开发闭环:
"scripts": {
"start": "concurrently \"npm run buildHost\" \"npm run startHost\" \"npm run buildRemote\" \"npm run startRemote\"",
"buildHost": "webpack --config=hostServer/webpack.config.js -w",
"startHost": "nodemon hostServer/public/server/server.js",
"buildRemote": "webpack --config=remoteServer/webpack.config.js -w",
"startRemote": "nodemon remoteServer/public/server/server.js",
"test:e2e": "pnpm exec playwright test"
}
其工作链路为:
webpack ... -w以 watch 模式把两个server.js入口分别编译到public/server/目录;nodemon监听编译产物并重启对应的 Node 进程;- 由于编译产物被持续监听,任何源文件变更都会自动触发重新构建与进程重启。
值得注意的依赖细节:@module-federation/enhanced(示例锁定的版本为 2.0.1)提供服务端联邦插件,react 与 react-dom 使用 ^16.13.0,express 负责 HTTP 响应。示例在 start 前还会执行 prestart 钩子 node ../scripts/track-demo-run.js,用于统计演示运行情况,不影响功能本身。
Host 端配置:服务端如何"消费"远程组件
Host 的完整配置位于 hostServer/webpack.config.js,关键点如下:
new ModuleFederationPlugin({
experiments: { asyncStartup: true },
name: 'website1',
shareStrategy: 'loaded-first',
library: { type: 'commonjs-module' },
filename: 'container.js',
remotes: {
website2: path.resolve(__dirname, '../remoteServer/public/server/container.js'),
},
//shared: ["react", "react-dom"],
})
experiments: { asyncStartup: true }:启用异步启动,允许联邦容器在运行时加载远程模块后再完成自身初始化,是服务端场景下避免启动阶段直接报错的关键开关;library: { type: 'commonjs-module' }:声明联邦容器以 CommonJS 模块形式导出,使其可以被 Node 端require/import消费;remotes中website2直接指向 Remote 构建产物../remoteServer/public/server/container.js的本地文件路径,而非网络 URL——这是纯服务端场景的常见做法,因为两个进程运行在同一台机器上;shareStrategy: 'loaded-first':指定共享模块的加载优先级策略;- 注释掉的
shared: ["react", "react-dom"]表明该示例刻意不共享 React 运行时,两个服务各自携带独立副本,从而将关注点完全收敛到"组件共享"本身。
与 Host 配套的 App.js 以常规方式引入远程组件:
import SharedComponent from 'website2/SharedComponent';
const App = () => (
<div>
<h1>Host Server</h1>
<SharedComponent />
</div>
);
关键工程细节:为何 Host 必须动态 import App.js
hostServer/server.js 中藏着一个重要的启动顺序问题,源码注释直接给出了解释:
app.get('/*', async (req, res, next) => {
// Don't statically import App.js, otherwise the Host app crashes on startup
// because there's no webpack module entry for "website2/SharedComponent" yet.
// The MF plugin will populate it during runtime, so dynamically import App.js to give it time
const App = (await import('./App')).default;
const html = renderToString(<App />);
res.send(html);
});
含义是:如果 Host 在进程启动时静态 import './App',由于此刻联邦插件还没有在运行时注入 website2/SharedComponent 这个模块条目,进程会直接崩溃。因此示例把 import 推迟到每次请求处理时动态执行,给模块联邦插件留出运行时填充远程模块的窗口。这一"请求时动态解析"的模式,是服务端模块联邦区别于浏览器端(后者通常依赖 <script> 注入时序)的核心差异点。
Remote 端配置:服务端如何"暴露"组件
Remote 的配置位于 remoteServer/webpack.config.js:
new ModuleFederationPlugin({
experiments: { asyncStartup: true },
name: 'website2',
shareStrategy: 'loaded-first',
library: { type: 'commonjs-module' },
filename: 'container.js',
exposes: {
'./SharedComponent': './remoteServer/SharedComponent',
},
//shared: ["react", "react-dom"],
})
与 Host 对称:name: 'website2' 定义了被引用方标识(Host 配置中的 website2 即指向它),exposes 把 SharedComponent.js 以 ./SharedComponent 为键暴露出去;filename: 'container.js' 是生成联邦容器的文件名,Host 的 remotes 路径正是解析到该产物。Remote 自身的 server.js 采用常规静态导入 import App from './App' 即可,因为 SharedComponent 就在它自己的模块作用域内,不存在远程解析问题。
"修改 Remote 组件、Host 无需重建"的原理与验证
README 的核心主张是:Changing "SharedComponent" will affect both servers, without rebuilding Host server(修改 SharedComponent 会同时影响两个服务,且无需重新构建 Host)。其成立依赖三重机制:
- Host 对 Remote 的引用是文件路径级的,每次请求时动态解析
website2/SharedComponent,实际读取的是 Remote 的构建产物; - Remote 处于
webpack -wwatch 模式,SharedComponent 变更会触发 Remote 侧重新构建; - Host 的
nodemon只需在 Remote 容器产物变化后重启,而 Host 自己的源码与构建产物并未改变——严格意义上不需要重新执行 Host 的构建步骤。
仓库内的端到端测试对这一点做了自动化实证:e2e/tests/checkServersideRenderOnlyApp.spec.ts 直接读写 remoteServer/SharedComponent.js 源文件:
beforeAll把组件内容写回原始文本Shared Component2222(对应 constants.ts 中serverSideRenderOnlyApp.contents.originalContent);- 在
Update Shared component file用例中,把文件改写为Updated Shared conponent in test,随后reloadWindow()刷新页面,断言两个服务都能看到更新后的文本; Check Shared component visibility in both apps after updating用例进一步交叉验证:在 3000 端口更新后,切到 3001 端口刷新同样能看到新内容;afterEach与afterAll把文件恢复为原始内容,保证测试可重复执行。
测试中两个 app 的数据结构(appsData)分别覆盖 Host Server(3000)与 Remote Server(3001)两个端口,验证逻辑完全一致,从测试层面确认了"更新远端组件、两端同步生效"这一核心能力。
端到端测试的运行方式
示例自带 playwright.config.ts,可通过 pnpm exec playwright test(或脚本 test:e2e)运行:
webServer字段以pnpm start启动整套服务,等待 3000 端口就绪,reuseExistingServer在非 CI 环境下允许复用已启动的服务;- 测试配置为单 worker 串行执行(
workers: 1、fullyParallel: false),这是因为测试会改写 SharedComponent 源文件,串行可避免并发互相污染; - 运行命令
pnpm start会先把两个 webpack 服务端构建产物输出到各自public/server/目录,再启动 Express 进程监听 3000/3001 端口。
关于 Playwright 基类(如 openLocalhost、reloadWindow、checkElementWithTextPresence 等)的约定与仓库级测试规范,可参考仓库根目录下的 playwright-e2e/README.md(该文档也是示例 README 中推荐阅读的进阶材料)。
小结:纯服务端模块联邦的适用边界
通过本示例可以提炼出一套"服务端-only"模块联邦的最小范式:Remote 以 CommonJS 联邦容器暴露组件,Host 通过本地文件路径引用该容器并借助 experiments.asyncStartup 在请求时动态解析远程模块,最后由 renderToString 直接输出 HTML。这种方案适合无需客户端交互、以服务端直出为主的场景(如 SEO 页面、邮件模板渲染等);由于示例刻意未配置 shared,React 运行时会分别打包进两个服务,若希望进一步复用依赖,可以参考仓库中其他 SSR 类示例(如 server-side-rendering、nextjs-ssr)中的共享配置写法。