Fastify SSR中加载Module Federation远程模块并提取JS/CSS块
Webpack Module Federation + Fastify SSR 模块加载与资源注入问题
背景
正在为大型应用构建原型,测试Webpack Module Federation(WMF)结合Server-Side Rendering(SSR)的方案,现有两个应用:
App 1 – 远程应用(React + Webpack)
基础Button1组件:
const Button1 = () => { return ( <button> Btn1 Remote </button> ); }; export default Button1;
Webpack配置:
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin'); ... devServer: { port: 3001, headers: { 'Access-Control-Allow-Origin': '*', }, }, ... plugins: [ new ModuleFederationPlugin({ name: 'remoteApp', filename: 'remoteEntry.js', exposes: { './Button1': './src/Button1', './Button2': './src/Button2', ... }, shared: ['react', 'react-dom'], }), ];
App 2 – 宿主应用(Fastify Server,SSR)
运行在3000端口,使用react-dom/server实现基础服务端渲染:
import { renderToString } from 'react-dom/server'; import cors from '@fastify/cors'; import Fastify from 'fastify'; const fastify = Fastify({ logger: true }); const Document = () => ( <html lang="ru"> <head></head> <body> <div id="react-app">hello</div> </body> </html> ); (async () => { await fastify.register(cors, { origin: '*', methods: ['GET', 'POST'], }); fastify.get('/', async (request, reply) => { const markup = renderToString(<Document />); return reply .code(200) .headers({ 'content-type': 'text/html', 'Cache-control': 'no-store', }) .send(`<!DOCTYPE html>${markup}`); }); fastify.listen({ port: 3000 }, err => { if (err) { fastify.log.error(err); process.exit(1); } }); })();
需求
在App 2(Fastify)的SSR过程中,需要:
- 加载App 1中暴露的任意模块(如Button1)
- 获取该模块对应的JS和CSS块
- 手动将这些块注入到SSR HTML中(避免浏览器先加载remoteEntry.js再请求资源,让浏览器初始加载时直接获取资源)
最终期望HTML示例:
<html lang="ru"> <head> <link rel="stylesheet" href="/remoteapp/assets/chunk1.css" crossorigin="anonymous" /> <link rel="stylesheet" href="/remoteapp/assets/chunk2.css" crossorigin="anonymous" /> </head> <body> <div id="react-app">hello</div> <script src="/remoteapp/assets/chunk1.js" crossorigin="anonymous"></script> <script src="/remoteapp/assets/chunk2.js" crossorigin="anonymous"></script> </body> </html>
⚠️ 操作必须在服务端SSR期间完成,而非客户端。
已尝试方案
使用@module-federation/enhanced/runtime,代码如下:
import { createInstance } from '@module-federation/enhanced/runtime'; (async () => { const mf = await createInstance({ name: 'hostApp', remotes: [ { name: 'remoteApp', entry: 'http://localhost:3001/remoteEntry.js', }, ], shared: { react: { singleton: true }, 'react-dom': { singleton: true }, }, }); const mod = await mf.loadRemote('remoteApp/Button1'); // 抛出错误 console.log('Loaded mod:', mod); })();
但持续出现运行时错误,无法正常工作。
问题
- 如何在Fastify服务端SSR期间正确加载远程模块(如Button1)?
- 若加载成功,如何提取该模块依赖的JS/CSS块,以便将其包含在SSR HTML中?
解决方案
1. 服务端正确加载远程模块
服务端加载MF远程模块需要解决Node环境下的模块解析和共享依赖的singleton处理,不建议直接使用客户端增强runtime,改用服务端适配方案:
步骤1:修改远程应用Webpack配置,适配服务端
在远程应用的Webpack配置中添加libraryTarget: 'commonjs2',同时优化共享依赖配置:
new ModuleFederationPlugin({ name: 'remoteApp', filename: 'remoteEntry.js', exposes: { './Button1': './src/Button1', './Button2': './src/Button2', }, shared: { react: { singleton: true, requiredVersion: '^18.0.0', eager: true, // 服务端需提前加载共享依赖 }, 'react-dom': { singleton: true, requiredVersion: '^18.0.0', eager: true, }, }, library: { type: 'commonjs2' }, // 新增,适配Node环境 });
步骤2:宿主服务端手动加载并解析远程模块
在Fastify服务中,通过fetch获取remoteEntry.js,用vm模块隔离执行环境,再初始化容器获取目标模块:
import { renderToString } from 'react-dom/server'; import cors from '@fastify/cors'; import Fastify from 'fastify'; import vm from 'vm'; import fetch from 'node-fetch'; const fastify = Fastify({ logger: true }); // 封装远程模块加载函数 async function loadRemoteModule(remoteUrl, modulePath) { // 1. 获取remoteEntry.js内容 const remoteEntryRes = await fetch(`${remoteUrl}/remoteEntry.js`); const remoteEntryCode = await remoteEntryRes.text(); // 2. 创建隔离上下文,模拟必要全局变量 const context = vm.createContext({ module: {}, exports: {}, require: require, __dirname: __dirname, __filename: __filename, window: {}, // 避免客户端代码报错 }); // 3. 执行remoteEntry代码 vm.runInContext(remoteEntryCode, context); // 4. 初始化远程容器,注入共享依赖 const container = context.module.exports; await container.init({ shareScope: { react: { get: () => Promise.resolve({ default: require('react') }), }, 'react-dom': { get: () => Promise.resolve({ default: require('react-dom') }), }, }, }); // 5. 获取并返回目标模块 const factory = await container.get(modulePath); return factory(); } const Document = ({ Button1 }) => ( <html lang="ru"> <head></head> <body> <div id="react-app">{Button1 ? <Button1 /> : 'hello'}</div> </body> </html> ); (async () => { await fastify.register(cors, { origin: '*', methods: ['GET', 'POST'], }); fastify.get('/', async (request, reply) => { try { // 加载远程Button1模块 const Button1 = await loadRemoteModule('http://localhost:3001', './Button1'); // 渲染包含远程组件的页面 const markup = renderToString(<Document Button1={Button1.default} />); return reply .code(200) .headers({ 'content-type': 'text/html', 'Cache-control': 'no-store', }) .send(`<!DOCTYPE html>${markup}`); } catch (err) { fastify.log.error(err); return reply.code(500).send('Failed to load remote module'); } }); fastify.listen({ port: 3000 }, err => { if (err) { fastify.log.error(err); process.exit(1); } }); })();
2. 提取并注入JS/CSS块
需要让远程应用生成模块- chunk映射文件,在构建时输出每个暴露模块对应的资源信息:
步骤1:远程应用添加自定义插件生成chunk映射
在远程应用的Webpack配置中添加自定义插件,输出chunk-mapping.json:
class ChunkMappingPlugin { apply(compiler) { compiler.hooks.emit.tapAsync('ChunkMappingPlugin', (compilation, callback) => { const chunkMapping = {}; // 获取MF插件的暴露模块配置 const mfPlugin = compiler.options.plugins.find(p => p instanceof ModuleFederationPlugin); const exposes = mfPlugin?.options.exposes || {}; Object.keys(exposes).forEach(exposedPath => { const originalPath = exposes[exposedPath]; // 找到对应模块所属的chunk const module = compilation.modules.find(m => m.resource?.includes(originalPath)); if (module) { const chunks = Array.from(module.chunks); chunkMapping[exposedPath] = { js: chunks.flatMap(chunk => chunk.files.filter(f => f.endsWith('.js'))).map(f => `/remoteapp/assets/${f}`), css: chunks.flatMap(chunk => chunk.files.filter(f => f.endsWith('.css'))).map(f => `/remoteapp/assets/${f}`), }; } }); // 将映射写入输出文件 compilation.assets['chunk-mapping.json'] = { source: () => JSON.stringify(chunkMapping), size: () => Buffer.byteLength(JSON.stringify(chunkMapping)), }; callback(); }); } } // 在plugins数组中添加该插件 plugins: [ new ModuleFederationPlugin({ ... }), new ChunkMappingPlugin(), ];
步骤2:宿主服务端获取映射并注入资源到HTML
修改Fastify路由逻辑,先获取chunk映射,再生成资源标签注入页面:
fastify.get('/', async (request, reply) => { try { // 1. 获取chunk映射文件 const chunkMapRes = await fetch('http://localhost:3001/chunk-mapping.json'); const chunkMap = await chunkMapRes.json(); const button1Chunks = chunkMap['./Button1'] || { js: [], css: [] }; // 2. 加载远程Button1模块 const Button1 = await loadRemoteModule('http://localhost:3001', './Button1'); // 3. 生成资源标签 const cssLinks = button1Chunks.css.map(href => `<link rel="stylesheet" href="${href}" crossorigin="anonymous" />`).join('\n'); const jsScripts = button1Chunks.js.map(src => `<script src="${src}" crossorigin="anonymous"></script>`).join('\n'); // 4. 渲染页面并注入资源 const componentMarkup = renderToString(<Button1.default />); const fullMarkup = ` <!DOCTYPE html> <html lang="ru"> <head> ${cssLinks} </head> <body> <div id="react-app">${componentMarkup}</div> ${jsScripts} </body> </html> `; return reply .code(200) .headers({ 'content-type': 'text/html', 'Cache-control': 'no-store', }) .send(fullMarkup); } catch (err) { fastify.log.error(err); return reply.code(500).send('Failed to load remote module'); } });
注意事项
- 确保共享依赖(react、react-dom)版本一致,启用
singleton避免重复加载 - 自定义ChunkMappingPlugin需根据项目实际模块路径规则调整匹配逻辑
- 生产环境建议缓存
chunk-mapping.json,减少远程请求次数
内容的提问来源于stack exchange,提问作者War han
相关产品推荐
相关产品推荐

