微前端源码精讲
第10章 Webpack 5 Module Federation 源码
第10章 Webpack 5 Module Federation 源码
真正理解 Module Federation,不是学会写配置——是读懂那些配置背后,Webpack 在编译期和运行时各做了什么。
本章要点
- 深入 ContainerPlugin 源码,理解远程模块如何被暴露为一个独立入口
- 剖析 ContainerReferencePlugin 与 RemoteModule,理解消费端如何透明地加载远程模块
- 解读 SharePlugin 与 ConsumeSharedPlugin 的版本协商机制,理解共享依赖如何在多个应用间去重
- 完整追踪运行时加载流程:从 remoteEntry.js 的加载到模块工厂的实例化
- 理解 Chunk 分割与依赖去重如何在 Module Federation 架构下协同工作
本章引用的 webpack 原生 MF 源码位于 webpack 5 的 lib/container/、lib/sharing/ 目录,核查基准是 webpack v5.109.2;enhanced 侧的对照基准是 module-federation/core 的 commit 80e9597 中 packages/enhanced/src/lib/{container,sharing}——它是这两个目录的直接分叉(文件头仍署名 Tobias Koppers 与 Zack Jackson),类名、钩子与选项名一致,最主要的差别在于 enhanced 把运行时换成了 @module-federation/runtime(__webpack_require__.federation.bundlerRuntime)。下文的运行时代码按 webpack 原生产物写,涉及 enhanced 的差异会单独注明。
如果你曾经配置过 Module Federation,你一定写过这样的代码:
// webpack.config.js - 远程应用
new ModuleFederationPlugin({
name: 'remoteApp',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/components/Button',
},
shared: ['react', 'react-dom'],
});
然后在宿主应用里:
// webpack.config.js - 宿主应用
new ModuleFederationPlugin({
name: 'hostApp',
remotes: {
remoteApp: 'remoteApp@http://localhost:3001/remoteEntry.js',
},
shared: ['react', 'react-dom'],
});
配置不难。五分钟能跑通 Demo。但当你遇到以下问题时——
- 为什么
remoteEntry.js里有一个init和一个get方法?它们分别做了什么? - 共享依赖的版本协商到底是编译时决定的,还是运行时决定的?
- 当两个远程应用都提供了共享的
react@18.2.0,运行时如何决定用哪个? - Chunk 分割和 Module Federation 如何协作?为什么有时候加载一个远程组件会触发多个网络请求?
——你会发现,仅靠配置层面的理解远远不够。
这一章,我们将打开 Webpack 5 的 lib/container/ 目录,一行一行地追踪 Module Federation 从编译到运行的完整链路。你会看到四个核心插件如何分工协作,以及运行时那段精妙的异步加载逻辑是如何被编译器"种"进最终产物的。
10.1 ContainerPlugin:如何将模块暴露为远程入口
10.1.1 ModuleFederationPlugin 的分解
ModuleFederationPlugin 是一个门面(Facade):使用者只配置一次,插件内部把配置拆给 ContainerPlugin、ContainerReferencePlugin、SharePlugin 分别处理。@module-federation/enhanced 后来在同一个门面里又多分发了 FederationRuntimePlugin、TreeShakingSharedPlugin、StatsPlugin(manifest)等插件,对使用者是透明的。
Module Federation 的用户入口是 ModuleFederationPlugin,但它本身几乎不做任何实质工作——它只是一个"编排者",将配置分发给三个底层插件:
// webpack-5.109.2/lib/container/ModuleFederationPlugin.js:40(简化)
class ModuleFederationPlugin {
apply(compiler) {
// ...compiler.hooks.validate 校验 schema(:56-69)
const { options } = this; // 注意是 this.options,不是早期版本的 this._options
// remoteEntry 挂到全局变量:默认 library = { type: 'var', name }
const library = options.library || { type: 'var', name: options.name };
// remotes 的外部模块类型:默认 'script'(即 name@url 形式,用 <script> 加载)
const remoteType =
options.remoteType ||
(options.library && isValidExternalsType(options.library.type)
? options.library.type
: 'script');
if (library && !compiler.options.output.enabledLibraryTypes.includes(library.type)) {
compiler.options.output.enabledLibraryTypes.push(library.type);
}
// 三个底层插件都在 afterPlugins 钩子里注册
compiler.hooks.afterPlugins.tap('ModuleFederationPlugin', () => {
// 1. 如果有 exposes —— 注册 ContainerPlugin
if (
options.exposes &&
(Array.isArray(options.exposes)
? options.exposes.length > 0
: Object.keys(options.exposes).length > 0)
) {
new ContainerPlugin({
name: options.name,
library,
filename: options.filename,
runtime: options.runtime,
shareScope: options.shareScope,
exposes: options.exposes,
}).apply(compiler);
}
// 2. 如果有 remotes —— 注册 ContainerReferencePlugin
if (
options.remotes &&
(Array.isArray(options.remotes)
? options.remotes.length > 0
: Object.keys(options.remotes).length > 0)
) {
new ContainerReferencePlugin({
remoteType,
shareScope: options.shareScope,
remotes: options.remotes,
}).apply(compiler);
}
// 3. 如果有 shared —— 注册 SharePlugin
if (options.shared) {
new SharePlugin({
shared: options.shared,
shareScope: options.shareScope,
}).apply(compiler);
}
// 4. 无条件注册 HoistContainerReferences(:118,v5.96.0 起):在 splitChunks 之后
// 把容器入口同步引用的模块和远程 external 挪回运行时 chunk(见 10.5.2)
new HoistContainerReferences().apply(compiler);
});
}
}
这个分解设计意味着:一个应用可以同时是提供者和消费者。你可以暴露模块给别人,同时消费别人暴露的模块。三个插件各自独立运作,通过 shareScope 这个命名空间在运行时汇合。
下图展示了 ModuleFederationPlugin 如何将配置分发给三个底层插件,以及各插件在编译期和运行时的职责分工:
flowchart TB
MFP["ModuleFederationPlugin\n用户配置入口"] --> |"exposes 配置"| CP["ContainerPlugin\n生成 remoteEntry.js"]
MFP --> |"remotes 配置"| CRP["ContainerReferencePlugin\n透明加载远程模块"]
MFP --> |"shared 配置"| SP["SharePlugin\n依赖共享与版本协商"]
subgraph CompileTime["编译期"]
CP --> CED["ContainerEntryDependency\n创建容器入口 Chunk"]
CP --> CEM["ContainerEntryModule\n生成 init() + get() 代码"]
CRP --> RM["RemoteModule\n将 import 替换为运行时加载"]
SP --> CSP["ConsumeSharedPlugin\n注入共享依赖异步加载逻辑"]
SP --> PSP["ProvideSharedPlugin\n注册本地依赖到共享域"]
end
subgraph Runtime["运行时"]
CEM --> RE["remoteEntry.js\nmoduleMap + init + get"]
RM --> Load["动态 script 加载\n+ container.get()"]
CSP --> Negotiate["版本协商\n__webpack_require__.S"]
end
style MFP fill:#e3f2fd,stroke:#1565c0
style CompileTime fill:#fffde7,stroke:#f9a825
style Runtime fill:#e8f5e9,stroke:#2e7d32
10.1.2 ContainerPlugin 的核心逻辑
ContainerPlugin 的职责很明确:为当前构建生成一个"容器入口"(即 remoteEntry.js),让外部消费者可以通过这个入口获取被暴露的模块。
// webpack-5.109.2/lib/container/ContainerPlugin.js:24(核心流程)
class ContainerPlugin {
// 构造函数只存原始 options(:29-32),解析全在 apply 里
apply(compiler) {
// ...compiler.hooks.validate 校验 schema(:40-54)
// library 默认 { type: 'var', name: options.name }(:55-58)
const library = this.options.library || {
type: 'var', // 挂载到全局变量
name: this.options.name,
};
// ...把 library.type 加进 output.enabledLibraryTypes(:60-62)
// exposes 同样在 apply 里 parseOptions(:64-76)
const exposes = parseOptions(this.options.exposes, /* ... */);
const shareScope = this.options.shareScope || 'default'; // :78
compiler.hooks.make.tapAsync(
PLUGIN_NAME, // 源码用的是模块级常量,不是字面量 'ContainerPlugin'
(compilation, callback) => {
const hooks = getModuleFederationPlugin().getCompilationHooks(compilation);
const dep = new ContainerEntryDependency(
this.options.name, exposes, shareScope,
);
// 设置入口的 loc 信息,用于调试和错误追踪
dep.loc = { name: this.options.name };
compilation.addEntry(
compilation.options.context,
dep,
{
name: this.options.name,
filename: this.options.filename, // 通常是 'remoteEntry.js'
runtime: this.options.runtime,
library,
},
(error) => {
if (error) return callback(error);
// 通知 HoistContainerReferences 追踪这个容器入口(:100)
hooks.addContainerEntryDependency.call(dep);
callback();
}
);
}
);
// 注册 ContainerEntryDependency 的模块工厂
compiler.hooks.thisCompilation.tap(
PLUGIN_NAME,
(compilation, { normalModuleFactory }) => {
compilation.dependencyFactories.set(
ContainerEntryDependency,
new ContainerEntryModuleFactory()
);
compilation.dependencyFactories.set(
ContainerExposedDependency,
normalModuleFactory
);
}
);
}
}
这段代码做了两件关键的事:
- 在
make钩子中添加一个新入口。这意味着remoteEntry.js和你的主bundle.js是平行的两个入口——它们共享同一个编译过程,但生成独立的 Chunk。 - 注册依赖工厂。
ContainerEntryDependency用自定义的ContainerEntryModuleFactory来处理,而暴露出去的模块用标准的normalModuleFactory(因为它们就是普通模块,只是被"标记"为可暴露的)。
下图展示了 ContainerPlugin 在 Webpack 编译流程中的介入时机和产物关系:
sequenceDiagram
participant Compiler as Webpack Compiler
participant CP as ContainerPlugin
participant Compilation as Compilation
participant CEM as ContainerEntryModule
Compiler->>CP: thisCompilation 钩子
CP->>Compilation: 注册 ContainerEntryDependency 工厂
CP->>Compilation: 注册 ContainerExposedDependency 工厂
Compiler->>CP: make 钩子
CP->>Compilation: addEntry(ContainerEntryDependency)
Compilation->>CEM: 创建 ContainerEntryModule
CEM->>CEM: build(): 为每个 expose 创建 AsyncDependenciesBlock
CEM->>CEM: codeGeneration(): 生成 moduleMap + get + init
Note over Compilation: 最终产物
Compilation-->>Compiler: remoteEntry.js (容器入口)
Compilation-->>Compiler: 各 expose 模块的独立 Chunk
Compilation-->>Compiler: 主 bundle.js (业务代码)
💡 深度洞察:为什么 ContainerPlugin 选择在
make钩子中添加入口,而不是直接修改entry配置?因为entry配置只能描述普通模块请求,表达不了ContainerEntryDependency这种自定义依赖;而entry配置里的入口本身,也是由EntryPlugin在make钩子里调用compilation.addEntry加进来的(webpack-5.109.2/lib/EntryPlugin.js:49-50)——ContainerPlugin 只是在同一时机、用同一个 API 加入了一个自定义入口。这也是很多 Webpack 插件动态注入模块的标准模式。
10.1.3 ContainerEntryModule:容器的核心模块
理解 ContainerEntryModule,需要切换到"虚拟模块"的心智模型。webpack 的普通模块对应一个物理文件,有路径、有源码、可以被解析器读取;ContainerEntryModule 没有对应的源文件,代码是编译期拼接出来的——它以 javascript/dynamic 类型(JAVASCRIPT_MODULE_TYPE_DYNAMIC)创建,代码生成完全由插件接管。
ContainerEntryModule 是 Module Federation 架构中最精妙的部分之一。它负责生成 remoteEntry.js 的核心代码——包括 init 方法和 get 方法。
// webpack-5.109.2/lib/container/ContainerEntryModule.js:48(简化;源码 super() 传的是
// 常量 JAVASCRIPT_MODULE_TYPE_DYNAMIC,求值就是 'javascript/dynamic')
class ContainerEntryModule extends Module {
constructor(name, exposes, shareScope) {
super('javascript/dynamic', null);
this._name = name;
this._exposes = exposes;
this._shareScope = shareScope;
}
// 构建阶段:为每个暴露的模块创建一个异步块(AsyncDependenciesBlock)
build(options, compilation, resolver, fs, callback) {
this.buildMeta = {};
this.buildInfo = {
strict: true,
topLevelDeclarations: new Set(['moduleMap', 'get', 'init']),
};
this.buildMeta.exportsType = 'namespace';
this.clearDependenciesAndBlocks();
for (const [name, options] of this._exposes) {
const block = new AsyncDependenciesBlock(
{ name: options.name }, // 可选的自定义 chunk 名
{ name },
options.import[options.import.length - 1]
);
for (const request of options.import) {
const dep = new ContainerExposedDependency(name, request);
dep.loc = { name };
block.addDependency(dep);
}
this.addBlock(block);
}
// 容器只静态导出 get 和 init 两个名字
this.addDependency(new StaticExportsDependency(['get', 'init'], false));
callback();
}
// 代码生成阶段:生成容器入口的运行时代码
codeGeneration({ moduleGraph, chunkGraph, runtimeTemplate }) {
const sources = new Map();
const runtimeRequirements = new Set();
// 收集所有暴露模块的映射关系
const getters = [];
for (const block of this.blocks) {
const dep = block.dependencies[0];
const module = moduleGraph.getModule(dep);
const moduleId = chunkGraph.getModuleId(module);
// 源码用 runtimeTemplate.blockPromise 生成加载表达式(:193-212):异步块只落在
// 一个 chunk 时是 __webpack_require__.e(id),被 splitChunks 拆成多个 chunk 时是
// Promise.all([...]);这里只画单 chunk 的情形。一个 expose 配了多个 import 时,
// 工厂返回逗号表达式、导出最后一个;模块缺失时改生成抛错代码(:188-191)
getters.push(
`${JSON.stringify(dep.exposedName)}: () => {
return __webpack_require__.e(${JSON.stringify(
chunkGraph.getBlockChunkGroup(block).chunks[0].id
)}).then(() => () => __webpack_require__(${JSON.stringify(moduleId)}));
}`
);
}
// 生成容器模块的源代码
const source = new ConcatSource();
source.add(`
var moduleMap = {
${getters.join(',\n')}
};
var get = (module, getScope) => {
__webpack_require__.R = getScope;
getScope = (
__webpack_require__.o(moduleMap, module)
? moduleMap[module]()
: Promise.resolve().then(() => {
throw new Error('Module "' + module + '" does not exist in container.');
})
);
__webpack_require__.R = undefined;
return getScope;
};
var init = (shareScope, initScope) => {
if (!__webpack_require__.S) return;
var name = ${JSON.stringify(this._shareScope)};
var oldScope = __webpack_require__.S[name];
if (oldScope && oldScope !== shareScope) {
throw new Error(
'Container initialization failed as it has already been initialized with a different share scope'
);
}
__webpack_require__.S[name] = shareScope;
return __webpack_require__.I(name, initScope);
};
`);
// 导出 get 和 init
source.add(
`\n__webpack_require__.d(exports, {
get: () => get,
init: () => init,
});\n`
);
sources.set('javascript', source);
return { sources, runtimeRequirements };
}
}
这段代码生成器揭示了 remoteEntry.js 的核心结构:
moduleMap:一个从模块名到异步加载函数的映射表。每个暴露的模块并不内联在remoteEntry.js中,而是通过__webpack_require__.e(加载 Chunk)延迟加载。get(module):消费者调用这个方法来获取指定的暴露模块。它返回一个 Promise,解析后得到模块工厂。init(shareScope):消费者在调用get之前必须先调用init,传入共享作用域。容器借此把自己能提供的共享模块版本登记进这个作用域(经由__webpack_require__.I);真正挑选版本发生在之后消费共享模块时(见 10.3.3、10.4.4)。
// remoteEntry.js 最终产出的代码结构(简化)
var remoteApp;
remoteApp = (() => {
// ... webpack runtime ...
var moduleMap = {
'./Button': () => {
return __webpack_require__
.e('src_components_Button_tsx')
.then(() => () => __webpack_require__('./src/components/Button.tsx'));
},
};
var get = (module, getScope) => { /* ... */ };
var init = (shareScope, initScope) => { /* ... */ };
return { get, init };
})();
💡 深度洞察:
remoteEntry.js故意设计得很小。除了 webpack 运行时,它只包含模块映射表和两个方法,不包含业务代码(eager: true的共享模块除外,见 10.4.3)。真正的业务代码被分割到独立的 Chunk 中,按需加载。这意味着即使一个远程应用暴露了 50 个模块,宿主应用加载remoteEntry.js的成本也极低——只有当真正使用某个模块时,对应的 Chunk 才会被下载。
10.1.4 异步边界与 Chunk 的关系
"异步边界"是 Module Federation 里最关键、也最容易出问题的概念:在这个点之后的代码,必须通过 Promise 等待某些异步资源加载完成,才能真正执行。MF 把每个暴露模块都放进一个异步 Chunk,就是在制造这种边界。消费侧同样需要这样一道边界:如果入口 Chunk 里的代码同步引用了非 eager 的共享模块,运行时来不及先去共享作用域里取到它,就会报 "Shared module is not available for eager consumption" 这种经典错误(webpack-5.109.2/lib/sharing/ConsumeSharedRuntimeModule.js:300)。这也是为什么 MF 要求(或强烈建议)应用入口先用 import('./bootstrap') 隔出一道异步边界(见 10.4.3)——边界之后的代码照样可以静态 import 远程模块和共享模块,这是异步模型的内在要求,不是人为限制。
一个容易被忽视的细节是:每个暴露的模块都会被放入一个异步 Chunk。在 ContainerEntryModule 的 build 阶段,Webpack 会为每个暴露模块创建一个 AsyncDependenciesBlock(webpack-5.109.2/lib/container/ContainerEntryModule.js:133 处 new AsyncDependenciesBlock(...),随后 this.addBlock(block)):
// ContainerEntryModule.js 中的异步块创建
build(options, compilation, resolver, fs, callback) {
// ...
for (const [name, options] of this._exposes) {
const block = new AsyncDependenciesBlock(
{ name: options.name },
{ name },
options.import[options.import.length - 1]
);
for (const request of options.import) {
block.addDependency(new ContainerExposedDependency(name, request));
}
this.addBlock(block);
}
callback();
}
AsyncDependenciesBlock 是 Webpack 代码分割的核心原语——默认的 lazy 模式下,每一个 import() 动态导入语句最终都会被转换为一个 AsyncDependenciesBlock。Module Federation 复用了这个机制,让暴露模块天然支持按需加载。
10.2 ContainerReferencePlugin:如何消费远程模块
下图展示了远程模块从 import 语句到实际加载的完整转换链路,横跨编译期和运行时两个阶段:
flowchart LR
Import["import Button from\n'remoteApp/Button'"] --> CRP["ContainerReferencePlugin\n识别远程模块引用"]
CRP --> RM["RemoteModule\n替代 NormalModule"]
RM --> CodeGen["代码生成\n注入运行时加载逻辑"]
subgraph Runtime["运行时执行"]
CodeGen --> LoadScript["加载 remoteEntry.js\n(script 标签)"]
LoadScript --> Init["container.init(sharedScope)\n版本协商"]
Init --> Get["container.get('./Button')\n获取模块工厂"]
Get --> AsyncChunk["按需加载对应 Chunk"]
AsyncChunk --> Factory["执行工厂函数\n返回模块导出"]
end
Factory --> Component["Button 组件\n可正常使用"]
style Import fill:#f3e5f5,stroke:#7b1fa2
style Runtime fill:#e8f5e9,stroke:#2e7d32
style Component fill:#e3f2fd,stroke:#1565c0
10.2.1 远程引用的解析
当你在宿主应用的代码中写下:
import Button from 'remoteApp/Button';
Webpack 需要把这个看似普通的 import 语句识别为"远程模块引用",而不是尝试从 node_modules 中寻找。这正是 ContainerReferencePlugin 的职责。
// webpack-5.109.2/lib/container/ContainerReferencePlugin.js:25(核心流程)
const slashCode = '/'.charCodeAt(0);
class ContainerReferencePlugin {
// 构造函数只存原始 options,不做任何解析
constructor(options) {
this.options = options;
}
apply(compiler) {
// ...compiler.hooks.validate 校验 schema(:41-54)
// parseOptions 在 apply 里调用(:57-70),把 remotes 规整成
// [key, { external: string[], shareScope }]:
// 'remoteApp@http://localhost:3001/remoteEntry.js'
// => ['remoteApp', { external: ['remoteApp@http://...'], shareScope: 'default' }]
const { remoteType } = this.options;
const remotes = parseOptions(
this.options.remotes,
(item) => ({
external: Array.isArray(item) ? item : [item],
shareScope: this.options.shareScope || 'default',
}),
(item) => ({
external: Array.isArray(item.external) ? item.external : [item.external],
shareScope: item.shareScope || this.options.shareScope || 'default',
})
);
// 1. 真正的 URL 交给 ExternalsPlugin:
// 'webpack/container/reference/remoteApp' -> 'remoteApp@http://...'
// 多个地址时依次是 .../remoteApp、.../remoteApp/fallback-1 ...
const remoteExternals = {};
for (const [key, config] of remotes) {
let i = 0;
for (const external of config.external) {
if (external.startsWith('internal ')) continue;
remoteExternals[
`webpack/container/reference/${key}${i ? `/fallback-${i}` : ''}`
] = external;
i++;
}
}
new ExternalsPlugin(remoteType, remoteExternals).apply(compiler);
compiler.hooks.compilation.tap(
'ContainerReferencePlugin',
(compilation, { normalModuleFactory }) => {
compilation.dependencyFactories.set(RemoteToExternalDependency, normalModuleFactory);
compilation.dependencyFactories.set(FallbackItemDependency, normalModuleFactory);
compilation.dependencyFactories.set(FallbackDependency, new FallbackModuleFactory());
// 2. 拦截模块解析:当遇到匹配 remote 前缀的请求时,
// 用 RemoteModule 替代正常的模块解析
normalModuleFactory.hooks.factorize.tap(
'ContainerReferencePlugin',
(data) => {
if (!data.request.includes('!')) {
for (const [key, config] of remotes) {
if (
data.request.startsWith(`${key}`) &&
(data.request.length === key.length ||
data.request.charCodeAt(key.length) === slashCode)
) {
return new RemoteModule(
data.request, // 'remoteApp/Button'
config.external.map((external, i) => // 指向上面注册的 external
external.startsWith('internal ')
? external.slice(9)
: `webpack/container/reference/${key}${i ? `/fallback-${i}` : ''}`
),
`.${data.request.slice(key.length)}`, // './Button'
config.shareScope
);
}
}
}
}
);
// 3. 只要某个 chunk 需要 ensureChunkHandlers,就注入 RemoteRuntimeModule
// (它生成 __webpack_require__.f.remotes,见 10.4.2)
compilation.hooks.runtimeRequirementInTree
.for(RuntimeGlobals.ensureChunkHandlers)
.tap('ContainerReferencePlugin', (chunk, set) => {
set.add(RuntimeGlobals.module);
set.add(RuntimeGlobals.moduleFactoriesAddOnly);
set.add(RuntimeGlobals.hasOwnProperty);
set.add(RuntimeGlobals.initializeSharing);
set.add(RuntimeGlobals.shareScopeMap);
compilation.addRuntimeModule(chunk, new RemoteRuntimeModule());
});
}
);
}
}
这里发生了一个巧妙的"劫持":normalModuleFactory 的 factorize 钩子是 Webpack 解析模块路径的核心环节。当请求路径(例如 remoteApp/Button)的前缀匹配了 remotes 配置中的某个 key,ContainerReferencePlugin 会直接返回一个 RemoteModule 实例——跳过整个正常的模块解析流程。
这意味着 Webpack 不会尝试在文件系统中查找 remoteApp/Button 这个路径,而是创建一个特殊的"占位模块",记录下远程加载所需的元信息。注意 URL 本身并不写进 RemoteModule——它被交给 ExternalsPlugin,成为一个名为 webpack/container/reference/remoteApp 的 ExternalModule(类型 script),RemoteModule 只持有对这个 external 的引用。
10.2.2 RemoteModule:运行时加载的蓝图
RemoteModule 不包含任何实际代码。它是一个"蓝图",在代码生成阶段会被翻译为运行时的远程加载逻辑:
// webpack-5.109.2/lib/container/RemoteModule.js:54(简化;源码 super() 传的是常量
// WEBPACK_MODULE_TYPE_REMOTE,求值就是 'remote-module')
const TYPES = new Set(['remote', 'share-init']);
const RUNTIME_REQUIREMENTS = new Set([RuntimeGlobals.module]);
class RemoteModule extends Module {
constructor(request, externalRequests, internalRequest, shareScope) {
super('remote-module');
this.request = request; // 'remoteApp/Button'
this.externalRequests = externalRequests; // ['webpack/container/reference/remoteApp', ...]
this.internalRequest = internalRequest; // './Button'
this.shareScope = shareScope; // 'default'
}
// 两种源码类型:'remote'(占位)与 'share-init'(初始化共享作用域的代码片段)
getSourceTypes() {
return TYPES;
}
// 该模块的大小(用于优化决策)
size() {
return 6; // 很小,因为它只是一个引用
}
// 构建阶段:只有一个地址时依赖对应的 external;多个地址时依赖一个 FallbackDependency
build(options, compilation, resolver, fs, callback) {
this.buildMeta = {};
this.buildInfo = { strict: true };
this.clearDependenciesAndBlocks();
if (this.externalRequests.length === 1) {
this.addDependency(new RemoteToExternalDependency(this.externalRequests[0]));
} else {
this.addDependency(new FallbackDependency(this.externalRequests));
}
callback();
}
// 代码生成:'remote' 类型的源码是空串——真正的加载逻辑不在这里,
// 而在 RemoteRuntimeModule 生成的 __webpack_require__.f.remotes 里(见 10.4.2)
codeGeneration({ moduleGraph, chunkGraph }) {
const module = moduleGraph.getModule(this.dependencies[0]);
const id = module && chunkGraph.getModuleId(module);
const sources = new Map();
sources.set('remote', new RawSource(''));
const data = new Map();
// 让 ShareRuntimeModule 在 __webpack_require__.I 里加一句 initExternal(externalId):
// 初始化共享作用域时顺带调用远程容器的 init()
data.set('share-init', [
{
shareScope: this.shareScope,
initStage: 20,
init: id === undefined ? '' : `initExternal(${JSON.stringify(id)});`,
},
]);
return { sources, data, runtimeRequirements: RUNTIME_REQUIREMENTS };
}
// 序列化/反序列化支持(用于持久化缓存)
serialize(context) {
context.write(this.request);
context.write(this.externalRequests);
context.write(this.internalRequest);
context.write(this.shareScope);
super.serialize(context);
}
}
RemoteModule 在模块图中的角色非常独特:它在编译期占据了一个位置,确保 Webpack 的依赖分析能正确处理远程引用;但它自己生成的源码是空的——它的模块 id 会被 RemoteRuntimeModule 收进 chunkMapping / idToExternalAndNameMapping 两张表,实际内容在运行时由 __webpack_require__.f.remotes 填进 __webpack_require__.m[id]。
10.2.3 FallbackModule 与容错机制
在生产环境中,远程服务可能宕机。当 remotes 里某个 key 配置了多个地址(数组)时,RemoteModule 依赖的不再是单个 external,而是一个 FallbackDependency,由 FallbackModuleFactory 生成 FallbackModule——它在编译时生成一段带有 try-catch 的运行时代码,依次 require 主地址和备用地址对应的 external 模块:
// FallbackModule.codeGeneration 生成的运行时代码(ids 是各个 external 模块的 id)
var ids = ["webpack/container/reference/remoteApp", "webpack/container/reference/remoteApp/fallback-1"];
var error, result, i = 0;
var loop = (next) => {
while (i < ids.length) {
try { next = __webpack_require__(ids[i++]); } catch (e) { return handleError(e); }
if (next) return next.then ? next.then(handleResult, handleError) : handleResult(next);
}
if (error) throw error;
};
var handleResult = (result) => {
if (result) return result;
return loop();
};
var handleError = (e) => {
error = e;
return loop();
};
module.exports = loop();
💡 深度洞察:fallback 机制是在编译期"编织"进运行时代码的,而不是一个运行时的配置选项——
remotes里写了几个地址,编译期就把它们对应的 external 模块 id 写进ids数组,运行时由同一个loop逐个 try(webpack-5.109.2/lib/container/FallbackModule.js:148-174)。但别把这一点推广成"MF 的决策都在编译期完成":编译期定下的只是候选地址与版本区间这类静态信息,"到底用哪个远程容器、哪一份 shared 实例"仍然是运行时算出来的——共享依赖的版本协商就完全发生在运行时(见 10.4.4)。
10.3 SharePlugin:共享依赖的版本协商机制
10.3.1 为什么需要共享依赖
没有共享机制的 Module Federation 是不可用的。想象这样的场景:
- 宿主应用使用
react@18.2.0 - 远程应用 A 使用
react@18.2.0 - 远程应用 B 使用
react@18.3.1
如果每个应用都打包自己的 React,用户将下载三份 React + ReactDOM——每份 minified 后都在 100KB 量级,白白翻了三倍。更严重的是,React 的多实例会导致 Context、Hooks 等功能彻底失效。
SharePlugin 解决的就是这个问题:让多个独立构建的应用在运行时共享同一份依赖。
10.3.2 SharePlugin 的双面拆分
SharePlugin 本身也是一个编排者。它将配置拆分为两个子插件:
// webpack-5.109.2/lib/sharing/SharePlugin.js:19(简化)
class SharePlugin {
apply(compiler) {
// 1. ConsumeSharedPlugin —— 当前构建"消费"哪些共享模块
new ConsumeSharedPlugin({
shareScope: this._shareScope,
consumes: this._consumes,
}).apply(compiler);
// 2. ProvideSharedPlugin —— 当前构建"提供"哪些共享模块
new ProvideSharedPlugin({
shareScope: this._shareScope,
provides: this._provides,
}).apply(compiler);
}
}
每个参与 Module Federation 的应用都同时是提供者和消费者。当你配置 shared: ['react'] 时:
ProvideSharedPlugin确保当前构建的 React 被注册到共享作用域ConsumeSharedPlugin确保当前构建在使用 React 时,优先从共享作用域获取
10.3.3 ConsumeSharedPlugin 的版本协商
版本协商的难点在于:每个应用在编译时只知道自己依赖什么版本,不知道其他应用的版本,而各应用又会各自升级。webpack 的答案是"先登记、再各自挑选"——所有应用把自己能提供的版本登记进同一个共享作用域;每个消费方在用到时各自挑:非单例取满足自己 requiredVersion 的最高版本,单例则优先沿用已经加载的那一份、否则取最高版本,不满足自己的范围时只告警(strictVersion 时抛错)。它并不会替所有参与方算出一个"同时满足全部 requiredVersion"的版本。
版本协商是 Module Federation 最复杂的运行时逻辑之一。让我们看看 ConsumeSharedPlugin 是如何实现的:
// webpack-5.109.2/lib/sharing/ConsumeSharedPlugin.js:38(核心逻辑简化)
class ConsumeSharedPlugin {
apply(compiler) {
compiler.hooks.thisCompilation.tap(
'ConsumeSharedPlugin',
(compilation, { normalModuleFactory }) => {
// 本地 fallback 模块走普通的 normalModuleFactory
compilation.dependencyFactories.set(ConsumeSharedFallbackDependency, normalModuleFactory);
// consumes 由 apply 开头的 parseOptions(this.options.consumes, ...) 得到(:74-76)——
// 和 ContainerPlugin 一样,构造函数只存 this.options,不做解析
// 先把 consumes 配置按"已解析的绝对路径 / 未解析的包名 / 带斜杠的前缀"分成三张表
let unresolvedConsumes, resolvedConsumes, prefixedConsumes;
const promise = resolveMatchedConfigs(compilation, consumes).then(
({ resolved, unresolved, prefixed }) => {
resolvedConsumes = resolved;
unresolvedConsumes = unresolved;
prefixedConsumes = prefixed;
}
);
// 拦截模块解析:当遇到共享模块的请求时,用 ConsumeSharedModule 替代
normalModuleFactory.hooks.factorize.tapPromise(
'ConsumeSharedPlugin',
async ({ context, request, dependencies }) => {
await promise;
// fallback 与 ProvideSharedPlugin 自己的请求不能再被拦截,否则会无限递归
if (
dependencies[0] instanceof ConsumeSharedFallbackDependency ||
dependencies[0] instanceof ProvideForSharedDependency
) return;
const match = unresolvedConsumes.get(request);
if (match !== undefined) {
return createConsumeSharedModule(context, request, match);
}
for (const [prefix, options] of prefixedConsumes) {
if (request.startsWith(prefix)) {
const remainder = request.slice(prefix.length);
return createConsumeSharedModule(context, request, {
...options,
import: options.import ? options.import + remainder : undefined,
shareKey: options.shareKey + remainder,
});
}
}
}
);
// ……另有 createModule 钩子处理 resolvedConsumes(绝对路径命中)的情况
}
);
}
}
// createConsumeSharedModule:解析 fallback(import)与 requiredVersion 后,构造 ConsumeSharedModule
// - requiredVersion 缺省时,从最近的 package.json 里读出对该包的依赖范围
// - 构造参数就是 shared 配置项:shareScope / shareKey / import / requiredVersion /
// strictVersion / singleton / eager / packageName
const createConsumeSharedModule = (context, request, config) =>
Promise.all([resolveImport(config.import), resolveRequiredVersion(config)]).then(
([importResolved, requiredVersion]) =>
new ConsumeSharedModule(context, {
...config,
importResolved,
import: importResolved ? config.import : undefined,
requiredVersion,
})
);
ConsumeSharedModule 在代码生成阶段只产出一个"getter"(consume-shared 类型的源码),真正的版本选择逻辑由 ConsumeSharedRuntimeModule 注入到运行时。选用哪个函数由 ConsumeSharedModule.codeGeneration 按"有没有 requiredVersion / strictVersion / singleton"决定(webpack-5.109.2/lib/sharing/ConsumeSharedModule.js:314-322);本地 fallback 只是作为最后一个参数传进去:
// ConsumeSharedRuntimeModule 生成的运行时代码(webpack-5.109.2/lib/sharing/ConsumeSharedRuntimeModule.js:128-278,简化)
// 省略了 eager 参数(为 true 时只在 eager 注册的版本里挑)和 init 包装器:
// 每个 load* 都先等 __webpack_require__.I(scopeName) 完成,再以 scope = __webpack_require__.S[scopeName] 调用
var findLatestVersion = (scope, key) => {
var versions = scope[key];
// 取最高版本
var key = Object.keys(versions).reduce((a, b) => (!a || versionLt(a, b) ? b : a), 0);
return key && versions[key];
};
var findSatisfyingVersion = (scope, key, requiredVersion) => {
var versions = scope[key];
// 在满足 semver 范围的版本里取最高
var key = Object.keys(versions).reduce((a, b) => {
if (!satisfy(requiredVersion, b)) return a;
return !a || versionLt(a, b) ? b : a;
}, 0);
return key && versions[key];
};
var findSingletonVersionKey = (scope, key) => {
var versions = scope[key];
// 单例:已经 loaded 的版本优先;否则取最高版本
return Object.keys(versions).reduce((a, b) => {
return !a || (!versions[a].loaded && versionLt(a, b)) ? b : a;
}, 0);
};
var get = (entry) => { entry.loaded = 1; return entry.get(); };
// 作用域里根本没有这个 key:有 fallback 用 fallback,否则抛 "Shared module ... doesn't exist in shared scope"
var useFallback = (scopeName, key, fallback) => (fallback ? fallback() : failAsNotExist(scopeName, key));
// singleton + requiredVersion —— 不满足范围只警告;strictVersion 时换成 loadStrictSingletonVersion,改为抛错
var loadSingletonVersion = (scopeName, scope, key, requiredVersion, fallback) => {
if (!exists(scope, key)) return useFallback(scopeName, key, fallback);
var version = findSingletonVersionKey(scope, key);
if (!satisfy(requiredVersion, version)) warn(getInvalidSingletonVersionMessage(scope, key, version, requiredVersion));
return get(scope[key][version]);
};
// 非 singleton + strictVersion —— 没有满足范围的版本就用本地 fallback,没有 fallback 则抛错
var loadStrictVersion = (scopeName, scope, key, requiredVersion, fallback) => {
if (!exists(scope, key)) return useFallback(scopeName, key, fallback);
var satisfyingVersion = findSatisfyingVersion(scope, key, requiredVersion);
if (satisfyingVersion) return get(satisfyingVersion);
if (fallback) return fallback();
fail(getInvalidVersionMessage(scope, scopeName, key, requiredVersion));
};
// 非 singleton、非 strictVersion —— 没有满足范围的版本就警告,然后退回最高版本
var loadVersion = (scopeName, scope, key, requiredVersion, fallback) => {
if (!exists(scope, key)) return useFallback(scopeName, key, fallback);
var satisfyingVersion = findSatisfyingVersion(scope, key, requiredVersion);
if (satisfyingVersion) return get(satisfyingVersion);
warn(getInvalidVersionMessage(scope, scopeName, key, requiredVersion));
return get(findLatestVersion(scope, key));
};
// 没有 requiredVersion 时用 load / loadSingleton:直接取最高版本 / 单例版本
enhanced 版本的 ConsumeSharedRuntimeModule 不再内联这些函数,而是把每个消费项的 { fallback, shareScope, singleton, requiredVersion, strictVersion, eager, shareKey } 写进 __webpack_require__.consumesLoadingData,由 __webpack_require__.federation.bundlerRuntime.consumes 调用运行时实例的 loadShare();选择版本的算法在 packages/runtime-core/src/utils/share.ts 的 getRegisteredShare 里,语义与上面一致(shareStrategy 默认 'version-first','loaded-first' 会更激进地优先已加载版本)。
版本协商的核心规则:
| 配置项 | 含义 | 运行时行为 |
|---|---|---|
singleton: true |
全局只允许一个版本 | 所有消费者使用同一个实例 |
strictVersion: true |
严格版本匹配 | singleton 下版本不满足时抛错而非 warning;非 singleton 下没有满足范围的版本就用本地 fallback,没有 fallback 则抛错(schema 注明:有 fallback 且非 singleton 时默认即为 true) |
requiredVersion: '^18.0.0' |
semver 范围约束 | 非 singleton 时选择满足范围的最高版本;singleton 时只用来校验、不满足则告警或抛错 |
eager: true |
不延迟加载 | 共享模块内联到入口 Chunk |
10.3.4 共享作用域的数据结构
运行时的共享作用域 __webpack_require__.S 的数据结构是理解版本协商的关键:
// __webpack_require__.S 的运行时结构
__webpack_require__.S = {
default: { // shareScope 名称
react: { // shareKey
'18.2.0': { // 版本号
get: () => Promise.resolve().then(() => () => __webpack_require__('react')),
from: 'hostApp', eager: false,
// register 只写 get / from / eager;loaded 在第一次被消费时由 get(entry) 置为 1
},
'18.3.1': {
// 这个 get 闭包来自 remoteAppB 自己的 webpack 运行时,加载的是它的 chunk
get: () => __webpack_require__.e('vendors-react').then(() => () => __webpack_require__('react')),
from: 'remoteAppB', eager: false,
},
},
'react-dom': {
'18.2.0': { get: () => { /* ... */ }, from: 'hostApp', eager: false },
},
},
};
当容器的 init(shareScope) 被调用时,它将自己提供的共享模块注册到这个结构中。多个容器调用 init 后,共享作用域中就积累了来自不同应用的、不同版本的模块。消费时,版本协商算法从中选择最合适的版本。
💡 深度洞察:
singleton: true是 React、ReactDOM 等库的必选配置。因为 Hooks 的 dispatcher 挂在react模块内部的状态上(React 18 通过__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED暴露给 react-dom):渲染时 react-dom 只把 dispatcher 写进它自己依赖的那一份react,组件若 import 的是另一份react,读到的 dispatcher 为空,就会报 "Invalid hook call" 错误。Module Federation 的singleton选项本质上是对这类"有状态单例库"的架构级保护。
10.3.5 ProvideSharedPlugin 的注册机制
ProvideSharedPlugin 的工作相对简单——它在编译时将当前构建中的共享模块包装为 ProvideSharedModule,并在运行时的 __webpack_require__.I(initSharing)函数中完成注册:
ProvideSharedPlugin 在编译阶段通过 normalModuleFactory.hooks.module 钩子记录下命中 shared 配置的模块(此时能拿到 resourceResolveData.descriptionFileData,也就是该包的 package.json),然后在 compiler.hooks.finishMake 里用 compilation.addInclude 为每一项加入一个 ProvideSharedDependency,由 ProvideSharedModuleFactory 生成 ProvideSharedModule。这个模块只携带 shareScope、shareKey、version、模块请求路径和 eager 五项元信息(webpack-5.109.2/lib/sharing/ProvideSharedModule.js:43);requiredVersion、strictVersion、singleton 属于消费侧,由 ConsumeSharedModule 持有。其中 version 未显式配置时是在编译时从 package.json 中读取的——这意味着版本信息被"烘焙"到构建产物中,而非运行时动态解析。
// ShareRuntimeModule 生成的 __webpack_require__.I(webpack 原生,简化)
// 其中每条 register(...) 来自一个 ProvideSharedModule 的 codeGeneration
//('share-init' 数据,initStage 10);RemoteModule 贡献的 initExternal(...) 在 initStage 20
__webpack_require__.S = {};
var initPromises = {};
var initTokens = {};
__webpack_require__.I = (name, initScope) => {
if (!initScope) initScope = [];
// 防止循环初始化:每个 scope 一个 token 对象
var initToken = initTokens[name];
if (!initToken) initToken = initTokens[name] = {};
if (initScope.indexOf(initToken) >= 0) return;
initScope.push(initToken);
// 同一个 scope 只初始化一次
if (initPromises[name]) return initPromises[name];
if (!__webpack_require__.o(__webpack_require__.S, name)) __webpack_require__.S[name] = {};
var scope = __webpack_require__.S[name];
var uniqueName = 'hostApp'; // output.uniqueName:默认取 output.library 的名字,否则取 package.json 的 name
// 注册当前构建提供的共享模块
var register = (name, version, factory, eager) => {
var versions = (scope[name] = scope[name] || {});
var activeVersion = versions[version];
if (
!activeVersion ||
// 已加载的条目永远不会被覆盖;
// 同版本冲突时:eager 的胜过非 eager 的;都一样则 uniqueName 字典序大的胜出
(!activeVersion.loaded &&
(!eager != !activeVersion.eager ? eager : uniqueName > activeVersion.from))
) {
versions[version] = { get: factory, from: uniqueName, eager: !!eager };
}
};
// 远程容器:调用 external.init(__webpack_require__.S[name], initScope)
// 源码外面还包了一层 try/catch;handleError 只是 warn('Initialization of sharing external failed: ' + err)
var initExternal = (id) => {
var module = __webpack_require__(id);
var initFn = (module) => module && module.init && module.init(__webpack_require__.S[name], initScope);
if (module.then) return promises.push(module.then(initFn, handleError));
var initResult = initFn(module);
if (initResult && initResult.then) return promises.push(initResult['catch'](handleError));
};
var promises = [];
switch (name) {
case 'default': {
// 注册 react@18.2.0
register('react', '18.2.0', () =>
__webpack_require__.e('vendors-react').then(
() => () => __webpack_require__('../../node_modules/react/index.js')
)
);
// 注册 react-dom@18.2.0
register('react-dom', '18.2.0', () =>
__webpack_require__.e('vendors-react-dom').then(
() => () => __webpack_require__('../../node_modules/react-dom/index.js')
)
);
// 本构建引用到的远程容器
initExternal('webpack/container/reference/remoteApp');
}
break;
}
if (!promises.length) return (initPromises[name] = 1);
return (initPromises[name] = Promise.all(promises).then(() => (initPromises[name] = 1)));
};
enhanced 版本把 register / initExternal 这部分逻辑挪进了 @module-federation/webpack-bundler-runtime 的 initializeSharing,__webpack_require__.I 本身只剩一行对 federation.bundlerRuntime.I(...) 的转发;共享项则以 federation.initOptions.shared = { react: [{ version, get, scope, shareConfig }] } 的形式交给运行时实例。
10.4 运行时加载流程:从 remoteEntry.js 到模块实例化
10.4.1 完整加载序列图
当宿主应用执行 import Button from 'remoteApp/Button' 时,运行时会经历以下完整流程:
宿主应用代码
|
v
__webpack_require__.e(chunkId) // 加载包含这个 RemoteModule 的异步 Chunk
|
+---> __webpack_require__.f.remotes(chunkId, promises)
| |
| v
| __webpack_require__('webpack/container/reference/remoteApp') // script 类型的 ExternalModule
| | 内部调用 __webpack_require__.l('http://.../remoteEntry.js'),resolve 为 window.remoteApp
| v
| __webpack_require__.I('default') // 初始化共享作用域(同一 scope 只真正执行一次)
| | 内部调用 remoteApp.init(__webpack_require__.S['default'], initScope)
| v
| remoteApp.get('./Button', getScope) // 获取模块工厂
| |
| v
| 远程运行时的 __webpack_require__.e('src_components_Button_tsx') // 加载模块 Chunk
| |
| v
| __webpack_require__.m[id] = (module) => { module.exports = factory(); }
|
v
__webpack_require__(RemoteModule_id) 执行工厂 → Button 组件可用
10.4.2 webpack_require.f.remotes 运行时钩子
__webpack_require__.f 是 Webpack 5 的 Chunk 加载拦截器注册表。Module Federation 在其中注册了 remotes 处理器:
// Webpack 生成的运行时代码(简化)
// Chunk 加载的核心入口
__webpack_require__.e = (chunkId) => {
return Promise.all(
Object.keys(__webpack_require__.f).reduce((promises, key) => {
__webpack_require__.f[key](chunkId, promises);
return promises;
}, [])
);
};
// remotes 处理器(RemoteRuntimeModule 生成,webpack 原生,简化)
// chunkMapping 记录了哪些 Chunk 包含远程模块
var chunkMapping = {
'src_App_tsx': ['webpack/container/remote/remoteApp/Button'],
};
// idToExternalAndNameMapping 记录了远程模块的加载元信息:[shareScope, 内部模块名, external 模块 id]
var idToExternalAndNameMapping = {
'webpack/container/remote/remoteApp/Button': [
'default', // shareScope
'./Button', // 容器内的模块名
'webpack/container/reference/remoteApp', // ExternalModule 的 id(URL 在它里面)
],
};
__webpack_require__.f.remotes = (chunkId, promises) => {
if (!__webpack_require__.o(chunkMapping, chunkId)) return;
chunkMapping[chunkId].forEach((id) => {
var getScope = __webpack_require__.R; // 防止 get 之间的循环引用
if (!getScope) getScope = [];
var data = idToExternalAndNameMapping[id];
if (getScope.indexOf(data) >= 0) return;
getScope.push(data);
if (data.p) return promises.push(data.p); // 已经在加载/已加载过
var onError = (error) => {
if (!error) error = new Error('Container missing');
if (typeof error.message === 'string')
error.message += '\nwhile loading "' + data[1] + '" from ' + data[2];
__webpack_require__.m[id] = () => { throw error; };
data.p = 0;
};
// 把"同步返回 / 返回 Promise"两种情况统一起来,串起下面三步
var handleFunction = (fn, arg1, arg2, d, next, first) => {
try {
var promise = fn(arg1, arg2);
if (promise && promise.then) {
var p = promise.then((result) => next(result, d), onError);
if (first) promises.push((data.p = p)); else return p;
} else {
return next(promise, d, first);
}
} catch (error) { onError(error); }
};
// 2. 拿到容器后,初始化共享作用域(__webpack_require__.I 内部会调用 external.init(...))
var onExternal = (external, _, first) =>
external ? handleFunction(__webpack_require__.I, data[0], 0, external, onInitialized, first) : onError();
// 3. 作用域就绪后,向容器要模块工厂
var onInitialized = (_, external, first) =>
handleFunction(external.get, data[1], getScope, 0, onFactory, first);
// 4. 把工厂注册进模块表
var onFactory = (factory) => {
data.p = 1;
__webpack_require__.m[id] = (module) => { module.exports = factory(); };
};
// 1. 先 require external 模块:script 类型的 ExternalModule 会用 __webpack_require__.l 加载 remoteEntry.js,
// resolve 为全局变量 window.remoteApp
handleFunction(__webpack_require__, data[2], 0, 0, onExternal, 1);
});
};
enhanced 版本的 RemoteRuntimeModule 只保留这两张表(外加一张 idToRemoteMap),把上面的流程整体转发给 __webpack_require__.federation.bundlerRuntime.remotes(...);当 external 类型是 script 时,它不再自己 require external,而是改走运行时实例的 loadRemote()。
这段运行时代码是 Module Federation 的"心脏"。让我们逐步分析每个阶段:
阶段一:__webpack_require__.l 加载远程入口
// __webpack_require__.l —— 脚本加载器(LoadScriptRuntimeModule 生成,简化)
var inProgress = {};
__webpack_require__.l = (url, done, key, chunkId) => {
// 同一个 URL 正在加载中:把回调挂上去,不重复插入 <script>
if (inProgress[url]) { inProgress[url].push(done); return; }
inProgress[url] = [done];
var script = document.createElement('script');
script.src = url;
var onComplete = (event) => {
script.onerror = script.onload = null;
clearTimeout(timeout);
var doneFns = inProgress[url];
delete inProgress[url];
script.parentNode && script.parentNode.removeChild(script);
doneFns && doneFns.forEach((fn) => fn(event));
};
var timeout = setTimeout(() => onComplete({ type: 'timeout', target: script }), 120000); // output.chunkLoadTimeout
script.onerror = script.onload = onComplete;
document.head.appendChild(script);
};
阶段二:container.init 初始化共享
init 调用是双向的——宿主应用将自己的共享作用域传递给远程容器,远程容器将自己提供的共享模块注册到同一个作用域中。这就像一次"握手"——双方交换可用的共享资源。
时序上:宿主第一次需要共享作用域时(通常是加载 bootstrap 这类 Chunk、触发 consumes / remotes 处理器时)调用 __webpack_require__.I('default'):先 register 自己的 react@18.2.0(initStage 10),再对本构建引用到的每个远程容器执行 initExternal——加载它的 remoteEntry.js 并调用 container.init(shareScope) 注册远程版本(initStage 20)。此时 shareScope.react 中有来自多个应用的版本条目,消费时由版本协商算法选择最优版本。
阶段三:container.get 获取模块
get 方法返回一个 Promise,解析后得到的是一个模块工厂函数(而非模块本身)。这个工厂函数被注册到 __webpack_require__.m 中,当其他代码通过 __webpack_require__(id) 请求该模块时,工厂函数被执行,返回模块的 exports。
// 工厂注册的关键行
__webpack_require__.m[id] = (module, exports) => {
module.exports = factory();
};
// 之后任何地方的 require 都能获得远程模块
var Button = __webpack_require__('webpack/container/remote/remoteApp/Button');
// => 实际执行 factory(),返回远程 Button 组件
10.4.3 异步启动与 eager 配置
Module Federation 有一个常见的"陷阱":如果宿主应用的入口是同步的,共享模块的异步加载会导致运行时错误:
// 错误示例:同步入口
// index.js
import React from 'react'; // 此时共享的 React 可能还没加载完
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));
解决方案是使用异步边界:
// 正确示例:异步入口
// index.js
import('./bootstrap');
// bootstrap.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));
这个 import('./bootstrap') 创建了一个异步边界。Webpack 会在执行 bootstrap.js 之前,先加载所有必要的共享模块 Chunk。这背后的机制是 __webpack_require__.e 的 Promise 链——所有 __webpack_require__.f 中注册的处理器(包括 remotes 和 consumes)都会在 Chunk 加载时被触发。
如果确实需要同步加载共享模块,可以使用 eager: true:
// webpack.config.js
new ModuleFederationPlugin({
shared: {
react: {
singleton: true,
eager: true, // 将 react 内联到入口 Chunk
},
},
});
eager: true 的效果是:ProvideSharedModule 不再将共享模块放入独立的异步 Chunk,而是将其内联到入口 Chunk 中。代价是入口文件体积增大,但避免了异步加载的时序问题。
// eager: false(默认)生成的代码
register('react', '18.2.0', () =>
__webpack_require__.e('vendors-react').then( // 异步加载
() => () => __webpack_require__('react')
)
);
// eager: true 生成的代码(第四个参数 1 就是 eager 标记)
register('react', '18.2.0', () =>
() => __webpack_require__('react'), // 同步访问
1
);
💡 深度洞察:异步边界不仅仅是 Module Federation 的技术要求——它还是一个架构最佳实践。通过将应用的启动代码放在异步边界之后,你获得了一个天然的"预加载时机":
import('./bootstrap')加载这个 chunk 时,__webpack_require__.f.consumes与f.remotes会先把它同步依赖的共享模块和远程模块准备好,bootstrap 里的代码执行时不必再关心它们是否加载完成。之后再动态加载的 chunk 仍会各自走一遍这个流程。
10.4.4 webpack_require.f.consumes 与共享模块加载
除了 remotes 处理器,__webpack_require__.f 中还有一个 consumes 处理器,专门负责共享模块的运行时解析:
// ConsumeSharedRuntimeModule 生成的运行时代码(webpack 原生,简化)
var chunkMapping = {
'bootstrap': ['webpack/sharing/consume/default/react/react'],
};
// 每个消费项对应一个 handler:按配置选用 10.3.3 里的某个 load* 函数
var moduleToHandlerMapping = {
'webpack/sharing/consume/default/react/react': () =>
loadSingletonVersion(
'default', // shareScope(scope 对象由 init 包装器在 I() 完成后补上)
'react', // shareKey
false, // eager
'^18.0.0', // requiredVersion(实际产物中是 parseRange 编码后的数组)
// fallback:本地打包的 react
() => __webpack_require__.e('vendors-react').then(() => () => __webpack_require__('react'))
),
};
var installedModules = {};
__webpack_require__.f.consumes = (chunkId, promises) => {
if (!__webpack_require__.o(chunkMapping, chunkId)) return;
chunkMapping[chunkId].forEach((id) => {
if (__webpack_require__.o(installedModules, id)) return promises.push(installedModules[id]);
var onFactory = (factory) => {
installedModules[id] = 0;
__webpack_require__.m[id] = (module) => {
delete __webpack_require__.c[id];
module.exports = factory();
};
};
var onError = (error) => {
delete installedModules[id];
__webpack_require__.m[id] = (module) => {
delete __webpack_require__.c[id];
throw error;
};
};
try {
var promise = moduleToHandlerMapping[id]();
if (promise.then) {
promises.push((installedModules[id] = promise.then(onFactory)['catch'](onError)));
} else onFactory(promise);
} catch (e) { onError(e); }
});
};
enhanced 版本把 handler 换成了 { getter, shareInfo, shareKey } 对象,__webpack_require__.f.consumes 转发给 federation.bundlerRuntime.consumes(...),后者调用运行时实例的 loadShare(shareKey),返回 false 时才执行 getter()(本地 fallback)。
这段代码的关键逻辑:
- 根据
chunkId查找该 Chunk 中需要哪些共享模块 - 对每个共享模块,尝试从共享作用域中找到满足版本要求的模块
- 如果找到了,使用共享版本;如果没找到,按配置回退到本地打包的 fallback、告警后退回最高版本,或直接抛错
- 将获取到的模块工厂注册到
__webpack_require__.m中
这意味着共享模块的版本选择发生在 Chunk 加载时,而非模块执行时。这是一个重要的时序细节——当你的业务代码执行 require('react') 时,该用哪个版本已经在 Chunk 加载阶段确定了。
10.5 Chunk 分割与依赖去重的协作
10.5.1 Module Federation 对 Chunk 图的影响
Module Federation 改变了 Webpack 的 Chunk 图结构。在没有 Module Federation 的标准构建中,所有 Chunk 来自同一个编译过程,它们之间的依赖关系是确定的。但在 Module Federation 下,Chunk 图跨越了多个独立构建:
宿主应用的 Chunk 图
├── main.js (entry)
├── src_App_tsx.js (async)
└── vendors-lodash.js (split)
远程应用的 Chunk 图
├── main.js (entry, 远程应用自己的页面)
├── remoteEntry.js (container entry)
├── src_components_Button_tsx.js (exposed, async)
├── src_components_Form_tsx.js (exposed, async)
└── vendors-antd.js (split, 被 Button 和 Form 共享)
运行时的跨构建 Chunk 加载
宿主 main.js
→ 加载 remoteEntry.js
→ 请求 './Button'
→ remoteEntry 内部加载 src_components_Button_tsx.js
→ 如果 Button 依赖 antd,还需加载 vendors-antd.js
10.5.2 共享模块与 splitChunks 的交互
当一个模块同时被标记为"共享"和被 splitChunks 规则命中时,会发生什么?
// webpack.config.js
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendors: {
test: /node_modules/,
name: 'vendors',
},
},
},
},
plugins: [
new ModuleFederationPlugin({
shared: { react: { singleton: true } },
}),
],
};
Webpack 的处理优先级是:Module Federation 的共享声明优先于 splitChunks。
具体来说,ConsumeSharedPlugin 在模块解析阶段就将 react 替换为 ConsumeSharedModule。这个 ConsumeSharedModule 不是一个真正的模块(它不对应文件系统上的文件),因此 splitChunks 的 test: /node_modules/ 规则不会命中它。
但 ProvideSharedModule 背后那个真实的 react 模块(fallback 也是它)仍然是普通的 NormalModule,照样会被 splitChunks 的 cacheGroups 命中、抽进 vendors 之类的公共 Chunk——上面 register('react', ...) 里的 __webpack_require__.e('vendors-react') 就是这么来的。这本身没有问题:共享模块的 getter 只关心"加载哪个 chunk 再 require 哪个 id",chunk 怎么切它不在乎。
真正需要保护的是容器入口 chunk 本身:remoteEntry.js 必须是自包含的,如果 splitChunks.chunks: 'all' 把它的运行时或 moduleMap 抽走,消费方加载完 remoteEntry.js 就拿不到 init/get。enhanced 的 ContainerPlugin.patchChunkSplit(packages/enhanced/src/lib/container/ContainerPlugin.ts:68-150,注释原话是 "container should not be affected by splitChunks")会改写 splitChunks.chunks 和每个 cacheGroups[*].chunks,把名字等于容器 name(以及 name + '_partial')的 chunk 排除在外。webpack 原生没有这种改写 splitChunks 配置的做法;它从 v5.96.0 起由 ModuleFederationPlugin 注册 HoistContainerReferences,在 splitChunks 之后(optimizeChunks,STAGE_ADVANCED + 1)把容器入口同步引用的模块和远程 external 挪回运行时 chunk(webpack-5.109.2/lib/container/HoistContainerReferencesPlugin.js),保护的范围比 enhanced 窄,使用者仍需自己留意。
10.5.3 依赖去重的编译时与运行时
Module Federation 的依赖去重发生在两个层面:
编译时去重:ConsumeSharedPlugin 将对共享模块的直接引用替换为运行时查找。这意味着共享模块不会被重复打包到消费者的 bundle 中(除非作为 fallback)。
// 编译前
import React from 'react';
// 编译后(概念性)
var React = __webpack_require__('webpack/sharing/consume/default/react/react');
// 实际获取的是共享作用域中的 React,而非本地打包的
运行时去重:当多个应用注册了同一个共享模块的同一个版本时,运行时只会加载其中一个。同版本的条目只保留一条:已被加载(loaded)的不会被覆盖;否则 eager 的胜过非 eager 的,两者相同则 uniqueName 字典序大的胜出(webpack-5.109.2/lib/sharing/ShareRuntimeModule.js:106)。
// 运行时去重示意
// 宿主注册: scope.react['18.2.0'] = { get: hostFactory, from: 'host' }
// 远程A注册: scope.react['18.2.0'] 已存在且未加载,'remoteA' > 'host'(字典序),由远程A的条目替换
// 远程B注册: scope.react['18.3.1'] = { get: remoteBFactory, from: 'remoteB' }
// 最终 scope.react 只有两个版本条目,而非三个
// 消费者选择 18.3.1(满足 ^18.0.0 的最高版本)
10.5.4 动态远程加载的高级模式
如果远程地址只能在构建时写死,蓝绿发布、A/B 测试、多环境共用一份构建产物都很难做。MF 1.0 要靠 promise 形式的 remote 或手动加载脚本来实现动态地址;MF 2.0 把它做成了运行时 API(见第 11 章 §11.1.3)。
// 动态远程加载
// webpack.config.js
new ModuleFederationPlugin({
remotes: {
remoteApp: `promise new Promise((resolve) => {
const remoteUrl = window.__REMOTE_URL__ || 'http://localhost:3001/remoteEntry.js';
const script = document.createElement('script');
script.src = remoteUrl;
script.onload = () => {
resolve({
get: (request) => window.remoteApp.get(request),
init: (arg) => {
try {
return window.remoteApp.init(arg);
} catch(e) {
console.log('Remote already initialized');
}
}
});
};
document.head.appendChild(script);
})`,
},
});
这种 promise new Promise(...) 语法利用了 Webpack 的外部模块类型系统——promise 类型告诉 Webpack 这个"外部模块"是一个 Promise,需要异步解析。编译后的代码会等待这个 Promise resolve,然后将结果作为容器使用。
从源码角度看,这个 Promise 字符串被 ExternalModule 处理——当 externalType 为 'promise' 时,代码生成器直接将 Promise 表达式作为模块导出:module.exports = new Promise(...)。Webpack 的模块系统会等待这个 Promise resolve,再将结果当作标准的容器对象使用。
10.5.5 性能优化:预加载与预取
远程模块的加载涉及额外的 HTTP 请求、额外的 JS 解析和额外的依赖协商,不加优化,"首次打开某个用到远程模块的页面"会明显慢于传统 SPA。办法是在用户真正需要之前,用 prefetch/preload 在后台把它们拉下来——和第 3 章乾坤 prefetch(requestIdleCallback 空闲时预取子应用资源)是同一个思路,只是这次预加载的粒度是某个远程模块,而不是整个子应用。
// 在路由级别预加载远程模块
const routes = [
{
path: '/dashboard',
component: React.lazy(() => import('remoteApp/Dashboard')),
},
];
// Webpack magic comments 仍然有效
const Dashboard = React.lazy(() =>
import(/* webpackPrefetch: true */ 'remoteApp/Dashboard')
);
但这里有一个微妙的问题:webpackPrefetch 走的是 __webpack_require__.F(prefetch 处理器),最多只为宿主自己构建出的那个异步 Chunk 插入 <link rel="prefetch">;remotes / consumes 处理器只注册在 __webpack_require__.f 上(webpack-5.109.2/lib/container/RemoteRuntimeModule.js:76-78、webpack-5.109.2/lib/sharing/ConsumeSharedRuntimeModule.js:314-316),所以 remoteEntry.js 和远程容器里的模块 Chunk 都不会被预取——后者更是要等 remoteEntry.js 加载、init、get 之后才知道要加载哪些。
更高级的优化策略是使用 Module Federation 2.0 引入的 runtimePlugins,它允许你在容器初始化后立即预触发高频模块的 Chunk 加载(如 origin.get('./Dashboard')),将串行的请求链变为并行。
💡 深度洞察:Module Federation 的性能瓶颈通常不在模块代码的大小(因为 Tree Shaking 和代码分割仍然有效),而在网络请求的数量和瀑布流。加载一个远程组件可能产生三次串行请求:
remoteEntry.js→ 共享依赖 Chunk → 模块 Chunk。优化的核心是减少请求链的深度——通过eager共享、预加载、以及合理的 Chunk 合并策略。
10.5.6 全链路总结
让我们用一张完整的表格总结 Module Federation 从编译到运行的全链路:
| 阶段 | 参与者 | 关键操作 |
|---|---|---|
| 编译:入口注册 | ContainerPlugin | 在 make 钩子添加 remoteEntry.js 入口 |
| 编译:模块替换 | ContainerReferencePlugin | 将 remoteApp/X 替换为 RemoteModule |
| 编译:共享标记 | ConsumeSharedPlugin | 将 react 等替换为 ConsumeSharedModule |
| 编译:共享注册 | ProvideSharedPlugin | 将本地 react 包装为 ProvideSharedModule |
| 编译:代码生成 | ContainerEntryModule | 生成 moduleMap + get + init |
| 编译:Chunk 分割 | SplitChunksPlugin | 与 ProvideSharedModule 协作分割 Chunk |
| 运行时:入口加载 | __webpack_require__.l |
通过 <script> 标签加载 remoteEntry.js |
| 运行时:共享初始化 | container.init() |
向共享作用域注册自身提供的共享模块 |
| 运行时:版本协商 | __webpack_require__.f.consumes |
从共享作用域选择最优版本 |
| 运行时:模块获取 | container.get() |
按需加载暴露模块的 Chunk 并返回工厂 |
| 运行时:模块实例化 | __webpack_require__ |
执行工厂函数,得到最终的模块导出 |
最后,让我们总结 Module Federation 运行时各编译产物之间的关系:宿主应用的 main.js 通过 __webpack_require__.f.remotes 加载 remoteEntry.js,容器通过 init(shareScope) 完成共享依赖的双向注册,再通过 get('./Button') 触发模块 Chunk 的按需加载。所有共享依赖汇聚在同一个共享作用域对象中(宿主的 __webpack_require__.S['default'],经 init 传给各容器),由 __webpack_require__.f.consumes 在 Chunk 加载时完成版本选择。整个流程是编译时编排、运行时执行的典范。
下一章讨论 MF 2.0 如何把运行时从 webpack 中独立出来,以及它与 Rspack 的结合。
思考题
读完本章,试着回答以下问题来检验你的理解深度:
-
remoteEntry.js的体积与暴露模块的数量是什么关系? 如果一个远程应用暴露了 100 个模块,remoteEntry.js会变得很大吗?为什么? -
如果宿主应用配置了
shared: { react: { singleton: true, requiredVersion: '^17.0.0' } },而远程应用提供了react@18.2.0,运行时会发生什么?strictVersion的有无会改变结果吗? -
为什么 Module Federation 要求(或强烈建议)使用异步入口(
import('./bootstrap'))? 如果不用异步入口,共享模块的加载时序会出现什么问题?请从__webpack_require__.f.consumes的执行时机来分析。 -
eager: true和eager: false分别在什么场景下使用?eager: true虽然避免了异步加载问题,但它会带来什么副作用? -
假设你需要实现一个"运行时动态注册远程容器"的功能——在宿主应用运行时,根据后端配置动态加载一个编译时未知的远程应用。 请基于本章的源码分析,描述你需要在运行时执行哪些步骤(提示:考虑
__webpack_require__.l、container.init、container.get三个阶段)。