Module Federation 实战:基于 React 18 Suspense 的服务端渲染(SSR)示例解析

原创2026-10-14 13:58:511,602 阅读
文章标签:示例工程微前端前端

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 配置相比,多出两个要点:

  1. exposes:把 ./src/Content 暴露为远程模块 ./Content,供 shell 消费;
  2. 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';
}

以及 remote1/index.d.ts:

declare module 'remote2/Image' {
  export * from '@remote2/Image';
}

这样 TypeScript 便能从真实源码中推导出 ContentProps 等类型(ContentProps 定义于 Content.tsx,包含可选的 content?: string 属性),在保留运行时动态远程加载的同时获得完整的静态类型检查。

关键依赖版本与仓库横向对照

本示例各应用 package.json 中的核心依赖版本(可直接复现):

  • react / react-dom:18.3.1
  • express: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 落地依赖三条核心设计:

  1. 双端构建:每个应用同时产出 client 产物(供浏览器 hydration)与 server 产物(供 Node 渲染),remoteEntry 按 /client/ 与 /server/ 分别暴露;
  2. 双插件协作:客户端用 ModuleFederationPlugin,服务端用 UniversalFederationPlugin,且 shared 中的 react / react-dom 必须声明为 singleton,保证服务端单实例渲染;
  3. React 18 流式渲染:renderToPipeableStream + onAllReady 等待 Suspense 嵌套远程组件全部就绪后输出完整 HTML,客户端再以 hydrateRoot 无缝接管。

按照文档步骤(安装依赖 → pnpm run start)即可在 localhost:3000 直观验证:禁用 JavaScript 后页面内容依然完整呈现,而启用 JavaScript 后远程组件的按钮交互依然可用——这就是“服务端渲染 + 模块联邦”组合方案最直接的演示。

登录后查看全文
module-federation-examples