微前端源码精讲
第10章 Webpack 5 Module Federation 源码
第10章 Webpack 5 Module Federation 源码
“真正理解 Module Federation,不是学会写配置——是读懂那些配置背后,Webpack 在编译期和运行时各做了什么。”
本章要点
- 深入 ContainerPlugin 源码,理解远程模块如何被暴露为一个独立入口
- 剖析 ContainerReferencePlugin 与 RemoteModule,理解消费端如何透明地加载远程模块
- 解读 SharePlugin 与 ConsumeSharedPlugin 的版本协商机制,理解共享依赖如何在多个应用间去重
- 完整追踪运行时加载流程:从 remoteEntry.js 的加载到模块工厂的实例化
- 理解 Chunk 分割与依赖去重如何在 Module Federation 架构下协同工作
如果你曾经配置过 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:如何将模块暴露为远程入口
这一章将是一次”深入编译器内部”的旅程——我们要讨论的不再是”MF 的 API 长什么样”、而是”Webpack 在编译 MF 配置时、究竟做了什么”。这是一种截然不同的阅读体验——它需要你暂时放下”业务开发者”的视角、进入”编译器工程师”的视角——看每一个配置项如何被转换成 AST 改写、每一个 import 如何被替换成 RemoteModule、每一个 shared 如何被注入到运行时的共享作用域。这种视角切换不容易、但一旦掌握、你看待”构建工具”这件事就会发生根本转变——你会从”用黑盒配置”升级到”能预测编译产物”、从”遇到问题只能 google”升级到”能读 webpack 内部日志自己调试”。
10.1.1 ModuleFederationPlugin 的分解
“一个看似复杂的 API、背后是多个专门插件的组合”——这种设计模式在软件架构里叫做”门面模式(Facade Pattern)”。**门面模式的价值在于——让使用者面对一个简洁统一的接口、把内部的复杂性隐藏起来。ModuleFederationPlugin 就是这种模式的典型应用——使用者只需要配置一次、插件内部把配置拆分给 ContainerPlugin、ContainerReferencePlugin、SharePlugin 分别处理。**这种”门面 + 底层分工”的架构、让 MF 的内部实现可以独立演化——比如后来新增了 DelegateSharedPlugin 处理特殊的共享场景、只需要在 ModuleFederationPlugin 内部多分发一次、对使用者是完全透明的。门面模式的精髓、不是”把几个插件捆在一起卖”、而是”把多个底层能力组合成一个符合用户心智模型的 API”——是站在使用者的视角、而不是实现者的视角设计接口。
Module Federation 的用户入口是 ModuleFederationPlugin,但它本身几乎不做任何实质工作——它只是一个”编排者”,将配置分发给三个底层插件:
// webpack/lib/container/ModuleFederationPlugin.js(简化)
class ModuleFederationPlugin {
apply(compiler) {
const { name, filename, exposes, remotes, shared } = this._options;
// 1. 如果有 exposes —— 注册 ContainerPlugin
if (exposes && Object.keys(exposes).length > 0) {
new ContainerPlugin({
name,
filename,
exposes,
shareScope: this._options.shareScope || 'default',
}).apply(compiler);
}
// 2. 如果有 remotes —— 注册 ContainerReferencePlugin
if (remotes && Object.keys(remotes).length > 0) {
new ContainerReferencePlugin({
remoteType: this._options.remoteType || 'script',
remotes,
shareScope: this._options.shareScope || 'default',
}).apply(compiler);
}
// 3. 如果有 shared —— 注册 SharePlugin
if (shared) {
new SharePlugin({
shared,
shareScope: this._options.shareScope || 'default',
}).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/lib/container/ContainerPlugin.js(核心流程)
class ContainerPlugin {
apply(compiler) {
const { name, exposes, shareScope, filename } = this._options;
compiler.hooks.make.tapAsync(
'ContainerPlugin',
(compilation, callback) => {
const dep = new ContainerEntryDependency(name, exposes, shareScope);
// 设置入口的 loc 信息,用于调试和错误追踪
dep.loc = { name };
compilation.addEntry(
compilation.options.context,
dep,
{
name,
filename, // 通常是 'remoteEntry.js'
library: {
type: 'var', // 挂载到全局变量
name,
},
},
(error) => {
if (error) return callback(error);
callback();
}
);
}
);
// 注册 ContainerEntryDependency 的模块工厂
compiler.hooks.thisCompilation.tap(
'ContainerPlugin',
(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配置在 Webpack 启动时就已经被处理完毕,make阶段是编译开始后的第一个可以动态添加入口的时机。这也是很多 Webpack 插件动态注入模块的标准模式。
10.1.3 ContainerEntryModule:容器的核心模块
理解 ContainerEntryModule、你需要切换到”虚拟模块”的心智模型。Webpack 原本的模块系统、假设每个模块对应一个物理文件——有路径、有源码、可以被解析器读取。但 MF 要做的事情、是”凭空生成一个模块”——没有对应的源文件、代码是编译期动态拼接出来的。Webpack 为这种场景预留了 javascript/dynamic 这种特殊的模块类型、让插件可以在编译期创建”虚拟模块”。ContainerEntryModule 正是利用了这个能力——它声称自己是一个模块、但它的代码生成完全由插件接管。这种”虚拟模块”机制、在 webpack 生态里非常普遍——HtmlWebpackPlugin 生成 HTML、MiniCssExtractPlugin 生成 CSS、Webpack 自己的 runtime 也是虚拟模块——**它们共同证明了一点:一个优秀的编译器、不只应该解析开发者写的代码、还应该能生成那些”开发者不需要手写但运行时必须存在”的代码。
ContainerEntryModule 是 Module Federation 架构中最精妙的部分之一。它负责生成 remoteEntry.js 的核心代码——包括 init 方法和 get 方法。
// webpack/lib/container/ContainerEntryModule.js(简化)
class ContainerEntryModule extends Module {
constructor(name, exposes, shareScope) {
super('javascript/dynamic', null);
this._name = name;
this._exposes = exposes;
this._shareScope = shareScope;
}
// 构建阶段:声明该模块依赖哪些暴露的模块
build(options, compilation, resolver, fs, callback) {
this.buildInfo = {};
this.buildMeta = {};
this.dependencies = [];
// 为每个暴露的模块创建一个依赖
for (const [name, options] of this._exposes) {
const dep = new ContainerExposedDependency(name, options.import[0]);
dep.name = name;
this.dependencies.push(dep);
}
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);
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: share scope "' + name + '" already initialized'
);
}
__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,传入共享作用域。这就是版本协商发生的时机。
// 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故意设计得很小。它只包含模块映射表和两个方法,不包含任何实际的业务代码。真正的业务代码被分割到独立的 Chunk 中,按需加载。这意味着即使一个远程应用暴露了 50 个模块,宿主应用加载remoteEntry.js的成本也极低——只有当真正使用某个模块时,对应的 Chunk 才会被下载。
10.1.4 异步边界与 Chunk 的关系
“异步边界”是 Module Federation 整个架构最关键的一个概念——也是新手最容易忽视但出问题最多的地方。什么叫异步边界?就是”在这个点之后的代码、必须通过 Promise 等待某些异步资源加载完成、才能真正执行”。**MF 把每个暴露模块都放进一个异步 Chunk、本质上是在制造这种边界——让远程模块的消费者知道”你要等一下、这些东西不在初始 bundle 里、需要网络往返才能拿到”。如果没有这层异步边界、消费者会以为可以同步调用、结果运行时崩溃——报 “Shared module is not available for eager consumption” 这种经典错误。理解了”异步边界”、你就理解了为什么 MF 要求所有用到远程模块的入口、都必须是 import() 动态导入——这不是人为制造的限制、是异步模型的内在要求。
一个容易被忽视的细节是:每个暴露的模块都会被放入一个异步 Chunk。在 ContainerEntryModule 的 build 阶段,Webpack 会为每个暴露模块创建一个 AsyncDependenciesBlock:
// ContainerEntryModule.js 中的异步块创建
build(options, compilation, resolver, fs, callback) {
// ...
for (const [name, options] of this._exposes) {
const block = new AsyncDependenciesBlock(undefined, name);
const dep = new ContainerExposedDependency(name, options.import[0]);
dep.name = name;
block.addDependency(dep);
this.addBlock(block);
}
callback();
}
AsyncDependenciesBlock 是 Webpack 代码分割的核心原语——每一个 import() 动态导入语句最终都会被转换为一个 AsyncDependenciesBlock。Module Federation 复用了这个机制,让暴露模块天然支持按需加载。
10.2 ContainerReferencePlugin:如何消费远程模块
ContainerPlugin 和 ContainerReferencePlugin 是一对共生的插件——一个负责”生产”、一个负责”消费”。在 MF 的世界里、任何一个应用都可能既是生产者(把自己的模块暴露出去)、又是消费者(从别的应用获取模块)——这两个角色对应两个插件。这种”把对称的两端拆成两个专门的插件”的设计、符合单一职责原则——每个插件只关心一个方向的逻辑、代码更清晰、bug 更少。对比一下、如果 MF 用一个庞大的 MasterPlugin 同时处理生产和消费、代码复杂度会翻倍、调试难度会翻倍、演化成本也会翻倍。这种”把大职责拆成对称的小职责”的设计、在很多优秀框架里都能看到——React 的 reducer 和 action、Redux 的 dispatch 和 subscribe、Vue 的 setup 和 template、Rust 的 read_half 和 write_half——它们都在用”对称拆分”来降低单点复杂度。
下图展示了远程模块从 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/lib/container/ContainerReferencePlugin.js(核心流程)
class ContainerReferencePlugin {
apply(compiler) {
const { remotes, remoteType } = this._options;
// 将 remotes 配置转换为内部格式
// 'remoteApp@http://localhost:3001/remoteEntry.js'
// => { external: ['remoteApp@http://...'], shareScope: 'default' }
const remoteMap = {};
for (const [key, config] of Object.entries(remotes)) {
remoteMap[key] = {
external: Array.isArray(config) ? config : [config],
shareScope: this._options.shareScope || 'default',
};
}
compiler.hooks.compilation.tap(
'ContainerReferencePlugin',
(compilation, { normalModuleFactory }) => {
// 拦截模块解析:当遇到匹配 remote 前缀的请求时,
// 用 RemoteModule 替代正常的模块解析
normalModuleFactory.hooks.factorize.tap(
'ContainerReferencePlugin',
(data) => {
if (!data.request) return;
// 检查请求是否匹配任何 remote 前缀
for (const [key, config] of Object.entries(remoteMap)) {
if (
data.request.startsWith(key) &&
(data.request.length === key.length ||
data.request.charCodeAt(key.length) === '/'.charCodeAt(0))
) {
return new RemoteModule(
data.request,
config.external,
`.${data.request.slice(key.length)}` || '.',
config.shareScope
);
}
}
}
);
}
);
}
}
这里发生了一个巧妙的”劫持”:normalModuleFactory 的 factorize 钩子是 Webpack 解析模块路径的核心环节。当请求路径(例如 remoteApp/Button)的前缀匹配了 remotes 配置中的某个 key,ContainerReferencePlugin 会直接返回一个 RemoteModule 实例——跳过整个正常的模块解析流程。
这意味着 Webpack 不会尝试在文件系统中查找 remoteApp/Button 这个路径,而是创建一个特殊的”占位模块”,记录下远程加载所需的元信息。
10.2.2 RemoteModule:运行时加载的蓝图
**“蓝图模式”是软件架构里一个很优雅的技巧——不直接生成最终代码、而是生成一个”描述最终代码应该长什么样的数据结构”。然后在稍后的阶段、由另一个组件读取这个蓝图、按照它的描述生成真正的代码。这种”两阶段生成”的好处是——第一阶段可以专注于”分析、决策、配置”、不用管代码怎么写;第二阶段可以专注于”模板、输出、优化”、不用管业务逻辑。RemoteModule 就是这种模式的典型应用——它不包含任何实际的运行时代码、只包含”我是什么”(一个远程模块)、“我从哪来”(哪个 remote、哪个 expose)、“我要什么”(哪个 shareScope)这些元信息。真正的加载代码由后续的代码生成阶段、根据这份蓝图生成。这种分离让代码生成逻辑可以独立演化——比如后来 MF 2.0 要换一套运行时、只需要改代码生成阶段、RemoteModule 的定义可以保持不变。
RemoteModule 不包含任何实际代码。它是一个”蓝图”,在代码生成阶段会被翻译为运行时的远程加载逻辑:
// webpack/lib/container/RemoteModule.js(简化)
class RemoteModule extends Module {
constructor(request, externalRequests, internalRequest, shareScope) {
super('remote-module');
this.request = request; // 'remoteApp/Button'
this.externalRequests = externalRequests; // ['remoteApp@http://...']
this.internalRequest = internalRequest; // './Button'
this.shareScope = shareScope; // 'default'
}
// 标记该模块为外部依赖
getSourceTypes() {
return new Set(['remote']);
}
// 该模块的大小(用于优化决策)
size() {
return 6; // 很小,因为它只是一个引用
}
// 代码生成
codeGeneration({ runtimeTemplate, moduleGraph, chunkGraph }) {
const sources = new Map();
const runtimeRequirements = new Set([
RuntimeGlobals.module,
]);
// 生成的代码本质上是:
// module.exports = __webpack_require__.federation.get(remoteName, moduleName)
sources.set(
'remote',
new RawSource(
`module.exports = __webpack_require__.m[${JSON.stringify(
this.request
)}]`
)
);
return { sources, runtimeRequirements };
}
// 序列化/反序列化支持(用于持久化缓存)
serialize(context) {
context.write(this.request);
context.write(this.externalRequests);
context.write(this.internalRequest);
context.write(this.shareScope);
super.serialize(context);
}
}
RemoteModule 在模块图中的角色非常独特:它在编译期占据了一个位置,确保 Webpack 的依赖分析能正确处理远程引用;但它的实际内容在运行时才会被填充。
10.2.3 FallbackModule 与容错机制
在生产环境中,远程服务可能宕机。Module Federation 通过 FallbackModule 提供了多地址容错——在编译时生成一段带有 try-catch 的运行时代码,依次尝试主地址和备用地址:
// FallbackModule 生成的运行时代码结构(简化)
var remotes = [
() => loadScript('http://cdn.example.com/remoteEntry.js'),
() => loadScript('http://backup.example.com/remoteEntry.js'),
];
var loadRemote = async () => {
for (const remote of remotes) {
try { return await remote(); }
catch (e) { console.warn('Remote loading failed, trying fallback...', e); }
}
throw new Error('All remotes failed to load');
};
💡 深度洞察:fallback 机制是在编译期”编织”进运行时代码的,而不是一个运行时的配置选项。这体现了 Module Federation 的设计哲学——尽可能多的决策在编译期完成,运行时只负责执行。这与 qiankun 等运行时方案形成鲜明对比。
10.3 SharePlugin:共享依赖的版本协商机制
SharePlugin 是 MF 最关键、也最复杂的插件——它承担着”让多方共享依赖”这件核心任务。没有 SharePlugin、MF 只是一个”模块加载工具”、和 webpack 的 require.ensure 没本质区别;有了 SharePlugin、MF 才成为一个真正意义上的”前端分布式模块系统”。SharePlugin 的复杂性、源于它要同时解决三个相互纠缠的问题——如何让多方声明自己支持的版本、如何让运行时协商出一个大家都能用的版本、如何让已加载的模块实例能被后续加载的模块复用。这三个问题的耦合、让 SharePlugin 不得不做”双面拆分”——把”提供方的逻辑”和”消费方的逻辑”分别交给 ProvideSharedPlugin 和 ConsumeSharedPlugin。这种进一步的拆分、再次印证了”把复杂问题拆成多个对称的小问题”这个通用原则。
10.3.1 为什么需要共享依赖
没有共享机制的 Module Federation 是不可用的。想象这样的场景:
- 宿主应用使用
react@18.2.0 - 远程应用 A 使用
react@18.2.0 - 远程应用 B 使用
react@18.3.1
如果每个应用都打包自己的 React,用户将下载三份 React——总共约 400KB(压缩后)。更严重的是,React 的多实例会导致 Context、Hooks 等功能彻底失效。
SharePlugin 解决的就是这个问题:让多个独立构建的应用在运行时共享同一份依赖。
10.3.2 SharePlugin 的双面拆分
SharePlugin 本身也是一个编排者。它将配置拆分为两个子插件:
// webpack/lib/sharing/SharePlugin.js(简化)
class SharePlugin {
apply(compiler) {
// 1. ProvideSharedPlugin —— 当前构建"提供"哪些共享模块
new ProvideSharedPlugin({
provides: this._resolvedProvides,
shareScope: this._options.shareScope,
}).apply(compiler);
// 2. ConsumeSharedPlugin —— 当前构建"消费"哪些共享模块
new ConsumeSharedPlugin({
consumes: this._resolvedConsumes,
shareScope: this._options.shareScope,
}).apply(compiler);
}
}
每个参与 Module Federation 的应用都同时是提供者和消费者。当你配置 shared: ['react'] 时:
ProvideSharedPlugin确保当前构建的 React 被注册到共享作用域ConsumeSharedPlugin确保当前构建在使用 React 时,优先从共享作用域获取
10.3.3 ConsumeSharedPlugin 的版本协商
版本协商的核心难题、是在”静态信息有限、动态变化随时发生”的环境下、做出一个”大家都能接受”的决策。静态信息有限——每个应用在编译时只知道自己依赖什么版本、不知道其他应用的版本;动态变化随时发生——一个应用可能临时升级了依赖、另一个应用可能还在用旧版本。在这种条件下、如何让多方在运行时”商量出”一个统一的版本? 答案是”单例 + 最高版本 + 语义检查”的组合策略——所有应用注册自己的版本、运行时选出最高的那个满足所有 requiredVersion 的版本。这种”让参与方声明自己支持的范围、由协商器选出一个大家都能接受的值”的模式、在网络协议里广泛存在——TLS 的密码套件协商、HTTP 的内容协商(Content Negotiation)、TCP 的窗口缩放协商——它们都在处理”多方需要就一件事达成一致”这个通用问题。
版本协商是 Module Federation 最复杂的运行时逻辑之一。让我们看看 ConsumeSharedPlugin 是如何实现的:
// webpack/lib/sharing/ConsumeSharedPlugin.js(核心逻辑简化)
class ConsumeSharedPlugin {
apply(compiler) {
compiler.hooks.compilation.tap(
'ConsumeSharedPlugin',
(compilation, { normalModuleFactory }) => {
// 拦截模块解析:当遇到共享模块的请求时,
// 用 ConsumeSharedModule 替代
compilation.hooks.factorize.tap(
'ConsumeSharedPlugin',
(data) => {
for (const [key, config] of this._consumes) {
if (data.request === key || data.request.startsWith(key + '/')) {
return new ConsumeSharedModule(
compilation.options.context,
{
shareKey: config.shareKey || key,
shareScope: config.shareScope || 'default',
requiredVersion: config.requiredVersion,
strictVersion: config.strictVersion || false,
singleton: config.singleton || false,
eager: config.eager || false,
}
);
}
}
}
);
}
);
}
}
ConsumeSharedModule 在代码生成阶段,会注入一段运行时版本选择逻辑。其核心伪代码如下:
// ConsumeSharedModule 生成的运行时代码(简化)
var scope = __webpack_require__.S['default'];
var versions = scope['react'];
if (singleton) {
// 单例模式:直接取第一个可用版本
var entry = Object.values(versions)[0];
if (strictVersion && !satisfies(entry.version, requiredVersion)) {
throw new Error('Unsatisfied version ' + entry.version);
}
module.exports = entry.get();
} else {
// 非单例模式:遍历所有版本,选择满足 semver 范围的最高版本
var bestVersion = findSatisfyingVersion(versions, requiredVersion);
if (bestVersion) {
module.exports = versions[bestVersion].get();
} else {
// fallback:使用本地打包的版本
module.exports = __webpack_require__(fallbackModuleId);
}
}
版本协商的核心规则:
| 配置项 | 含义 | 运行时行为 |
|---|---|---|
singleton: true | 全局只允许一个版本 | 所有消费者使用同一个实例 |
strictVersion: true | 严格版本匹配 | 版本不满足时抛出错误而非 warning |
requiredVersion: '^18.0.0' | semver 范围约束 | 选择满足范围的最高版本 |
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')),
loaded: false, from: 'hostApp', eager: false,
},
'18.3.1': {
get: () => loadScript('remoteB/chunk-react.js').then(() => () => __webpack_require__('react')),
loaded: false, from: 'remoteAppB', eager: false,
},
},
'react-dom': {
'18.2.0': { get: () => /* ... */, loaded: false, from: 'hostApp', eager: false },
},
},
};
当容器的 init(shareScope) 被调用时,它将自己提供的共享模块注册到这个结构中。多个容器调用 init 后,共享作用域中就积累了来自不同应用的、不同版本的模块。消费时,版本协商算法从中选择最合适的版本。
💡 深度洞察:
singleton: true是 React、ReactDOM 等库的必选配置。因为 React 使用内部的全局状态(如__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED)来管理 Hooks 的调度器,如果存在两个 React 实例,Hooks 的调用顺序会混乱,导致 “Invalid hook call” 错误。Module Federation 的singleton选项本质上是对这类”有状态单例库”的架构级保护。
10.3.5 ProvideSharedPlugin 的注册机制
ProvideSharedPlugin 的工作相对简单——它在编译时将当前构建中的共享模块包装为 ProvideSharedModule,并在运行时的 __webpack_require__.I(initSharing)函数中完成注册:
ProvideSharedPlugin 在编译阶段通过 afterResolve 钩子拦截匹配的模块,将原始模块包装为 ProvideSharedModule。这个包装模块携带了 shareScope、shareKey、version、eager 等元信息。其中 version 是在编译时从 package.json 中读取的——这意味着版本信息被”烘焙”到构建产物中,而非运行时动态解析。
// ProvideSharedModule 生成的运行时注册代码
__webpack_require__.I = (name, initScope) => {
if (!initScope) initScope = [];
// 防止循环初始化
var initToken = '__webpack_require__.I(' + name + ')';
if (initScope.indexOf(initToken) >= 0) return;
initScope.push(initToken);
var scope = __webpack_require__.S[name];
if (!scope) scope = __webpack_require__.S[name] = {};
// 注册当前构建提供的共享模块
var register = (name, version, factory, eager) => {
var versions = scope[name] = scope[name] || {};
var activeVersion = versions[version];
if (
!activeVersion ||
// 当版本相同时,后注册的覆盖先注册的
// 但 eager 模块优先级更高
(!activeVersion.loaded && (!eager !== !activeVersion.eager ? eager : false))
) {
versions[version] = { get: factory, loaded: false, eager };
}
};
// 注册 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')
)
);
};
10.4 运行时加载流程:从 remoteEntry.js 到模块实例化
至此我们已经看完了 MF 的”编译期”工作——各种插件如何改写模块、替换 import、生成 remoteEntry.js。但编译期只是故事的一半——另一半是”运行时”的工作:浏览器真正运行这些改写后的代码时、到底发生了什么? 这一节就要揭开运行时的面纱——一个被 webpack 精心藏在 __webpack_require__ 这个命名空间里的小型运行时系统。理解这个运行时、不只是为了”调试 MF 问题”、更是为了理解现代前端构建工具的”幕后英雄”是如何工作的——那些你以为是”纯静态的打包产物”的代码、实际上内部运行着一个非常聪明的运行时小系统。这个运行时系统、由 webpack 在编译期动态生成、大小通常几十到几百 KB、承担着”模块加载、依赖解析、共享协商、错误恢复”等复杂任务——它是一个”被编译器发明出来的微型操作系统”。
10.4.1 完整加载序列图
当宿主应用执行 import Button from 'remoteApp/Button' 时,运行时会经历以下完整流程:
宿主应用代码
|
v
__webpack_require__(RemoteModule_id)
|
v
__webpack_require__.e('remoteApp/Button') // 加载远程 Chunk
|
+---> __webpack_require__.f.remotes('remoteApp/Button')
| |
| v
| __webpack_require__.l('http://.../remoteEntry.js') // 加载远程入口
| |
| v
| window.remoteApp // 全局变量挂载
| |
| v
| remoteApp.init(__webpack_require__.S['default']) // 初始化共享作用域
| |
| v
| remoteApp.get('./Button') // 获取模块工厂
| |
| v
| __webpack_require__.e('src_components_Button_tsx') // 加载模块 Chunk
| |
| v
| moduleFactory() // 执行模块工厂
|
v
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 处理器
__webpack_require__.f.remotes = (chunkId, promises) => {
// chunkMapping 记录了哪些 Chunk 包含远程模块
var chunkMapping = {
'src_App_tsx': ['webpack/container/remote/remoteApp/Button'],
};
// idToExternalAndNameMapping 记录了远程模块的加载元信息
var idToExternalAndNameMapping = {
'webpack/container/remote/remoteApp/Button': [
'default', // shareScope
'remoteApp', // 远程容器名
'remoteApp@http://localhost:3001/remoteEntry.js', // 外部引用
'./Button', // 内部模块名
],
};
var remoteModules = chunkMapping[chunkId];
if (!remoteModules) return;
remoteModules.forEach((id) => {
var data = idToExternalAndNameMapping[id];
var shareScope = data[0];
var name = data[1];
var externalUrl = data[2];
var moduleName = data[3];
var promise = (async () => {
// 1. 加载远程入口脚本
await __webpack_require__.l(externalUrl);
// 2. 获取远程容器
var container = window[name];
if (!container) {
throw new Error('Container ' + name + ' is not available');
}
// 3. 初始化共享作用域
__webpack_require__.S[shareScope] = __webpack_require__.S[shareScope] || {};
await container.init(__webpack_require__.S[shareScope]);
// 4. 获取模块工厂
var factory = await container.get(moduleName);
// 5. 注册到模块注册表
__webpack_require__.m[id] = (module, exports) => {
module.exports = factory();
};
})();
promises.push(promise);
});
};
这段运行时代码是 Module Federation 的”心脏”。让我们逐步分析每个阶段:
阶段一:__webpack_require__.l 加载远程入口
// __webpack_require__.l —— 脚本加载器(简化)
__webpack_require__.l = (url, done, key, chunkId) => {
if (installedScripts[url]) { done(); return; }
var script = document.createElement('script');
script.src = url;
script.timeout = 120;
var onComplete = (event) => {
script.onerror = script.onload = null;
clearTimeout(timeout);
event.type === 'load' ? (installedScripts[url] = true, done()) : done(new Error('Loading failed: ' + url));
};
var timeout = setTimeout(() => onComplete({ type: 'timeout' }), 120000);
script.onerror = script.onload = onComplete;
document.head.appendChild(script);
};
阶段二:container.init 初始化共享
init 调用是双向的——宿主应用将自己的共享作用域传递给远程容器,远程容器将自己提供的共享模块注册到同一个作用域中。这就像一次”握手”——双方交换可用的共享资源。
时序上:宿主应用启动时先通过 __webpack_require__.I('default') 注册自己的 react@18.2.0,远程入口加载后调用 container.init(shareScope) 注册远程版本。此时 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 App from './App';
ReactDOM.render(<App />, root);
解决方案是使用异步边界:
// 正确示例:异步入口
// index.js
import('./bootstrap');
// bootstrap.js
import React from 'react';
import App from './App';
ReactDOM.render(<App />, 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 生成的代码
register('react', '18.2.0', () =>
() => __webpack_require__('react') // 同步访问
);
💡 深度洞察:异步边界不仅仅是 Module Federation 的技术要求——它还是一个架构最佳实践。通过将应用的启动代码放在异步边界之后,你获得了一个天然的”预加载时机”:在执行任何业务代码之前,所有共享依赖、远程入口都已经就绪。这消除了一整类运行时的竞态条件。
10.4.4 webpack_require.f.consumes 与共享模块加载
除了 remotes 处理器,__webpack_require__.f 中还有一个 consumes 处理器,专门负责共享模块的运行时解析:
// Webpack 生成的运行时代码(简化)
__webpack_require__.f.consumes = (chunkId, promises) => {
var chunkMapping = {
'bootstrap': ['webpack/sharing/consume/default/react'],
};
var moduleToHandlerMapping = {
'webpack/sharing/consume/default/react': {
getter: () => {
// 从共享作用域获取 react
var scope = __webpack_require__.S['default'];
var entry = findSatisfyingVersion(scope['react'], '^18.0.0');
if (entry) return entry.get();
// fallback: 使用本地版本
return __webpack_require__.e('vendors-react').then(
() => () => __webpack_require__('react')
);
},
shareInfo: {
shareKey: 'react',
shareScope: 'default',
requiredVersion: '^18.0.0',
singleton: true,
strictVersion: false,
},
},
};
var moduleIds = chunkMapping[chunkId];
if (!moduleIds) return;
moduleIds.forEach((id) => {
var handler = moduleToHandlerMapping[id];
if (!handler) return;
var promise = handler.getter().then((factory) => {
__webpack_require__.m[id] = (module) => {
module.exports = factory();
};
});
promises.push(promise);
});
};
这段代码的关键逻辑:
- 根据
chunkId查找该 Chunk 中需要哪些共享模块 - 对每个共享模块,尝试从共享作用域中找到满足版本要求的模块
- 如果找到了,使用共享版本;如果没找到,回退到本地打包的版本
- 将获取到的模块工厂注册到
__webpack_require__.m中
这意味着共享模块的版本选择发生在 Chunk 加载时,而非模块执行时。这是一个重要的时序细节——当你的业务代码执行 require('react') 时,该用哪个版本已经在 Chunk 加载阶段确定了。
10.5 Chunk 分割与依赖去重的协作
Chunk 是 webpack 在”如何拆分打包产物”这个问题上最核心的概念——一个 Chunk 代表浏览器在某个时刻会作为一个整体请求和执行的代码单元。Chunk 的合理拆分、直接决定了首屏加载速度、缓存命中率、依赖去重效果。在 MF 出现之前、Chunk 拆分是一个”单构建产物内部”的问题——所有 Chunk 都来自同一个 webpack 进程、它们之间的依赖关系可以被精确分析。**MF 打破了这个假设——不同的 MF 应用是独立构建的、它们之间的 Chunk 依赖是在运行时才能确定。这种”跨构建的 Chunk 图”、给 webpack 的 Chunk 分割策略带来了新的挑战——它必须协调”本地构建的 Chunk”和”远程加载的 Chunk”、让它们在运行时能像一个统一的 Chunk 图一样工作。本节讨论的就是 MF 和 splitChunks 之间那些微妙但关键的协作细节。
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 的交互
“插件之间如何协同”是任何生态系统都要面对的难题——两个独立设计的插件、各自有自己的世界观、当它们被同时启用时、会不会产生冲突? Webpack 的插件生态能长期繁荣、一个关键原因就是 Webpack 团队在”协同优先级”这个维度做了大量细致工作——明确哪种插件在什么阶段介入、哪种操作可以覆盖哪种操作、哪种规则之间的冲突应该如何裁决。**MF 和 splitChunks 的协同、就是这种”优先级设计”的一个具体案例——MF 的共享声明优先于 splitChunks 的分包规则。这种明确的优先级、让开发者可以预测”我同时配置这两个东西、会得到什么结果”——没有这种可预测性、生产环境的构建会变得像赌博。读这些源码时、不要只关心”这段代码在做什么”、也要关心”这段代码和其他代码的交互顺序是什么”——后者往往是更容易出 bug 的地方。一个复杂系统的稳定性、不取决于任何单个组件的正确性、而取决于所有组件之间交互协议的完整性。这也是为什么真正的工程大师、读源码时不只读”单个函数”、而是带着”组件交互”的视角去读——他们在看代码的同时也在看架构、在看细节的同时也在看宏观。
当一个模块同时被标记为”共享”和被 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/ 规则不会命中它。
但被 ProvideSharedPlugin 包装的模块仍然会被 splitChunks 影响:
Webpack 内部通过 ChunkGroup 的依赖关系来协调两者——ProvideSharedModule 创建的专用 async Chunk 不会被 splitChunks 再次合并,因为它们已经有明确的异步加载语义。
10.5.3 依赖去重的编译时与运行时
Module Federation 的依赖去重发生在两个层面:
编译时去重:ConsumeSharedPlugin 将对共享模块的直接引用替换为运行时查找。这意味着共享模块不会被重复打包到消费者的 bundle 中(除非作为 fallback)。
// 编译前
import React from 'react';
// 编译后(概念性)
var React = __webpack_require__('webpack/sharing/consume/default/react');
// 实际获取的是共享作用域中的 React,而非本地打包的
运行时去重:当多个应用注册了同一个共享模块的同一个版本时,运行时只会加载其中一个。后注册的不会覆盖先注册的(除非 eager 配置不同)。
// 运行时去重示意
// 宿主注册: scope.react['18.2.0'] = { get: hostFactory, from: 'host' }
// 远程A注册: scope.react['18.2.0'] 已存在,跳过
// 远程B注册: scope.react['18.3.1'] = { get: remoteBFactory, from: 'remoteB' }
// 最终 scope.react 只有两个版本条目,而非三个
// 消费者选择 18.3.1(满足 ^18.0.0 的最高版本)
10.5.4 动态远程加载的高级模式
“动态远程地址”是 MF 在生产环境能否真正落地的关键特性。如果远程地址只能在构建时硬编码、那 MF 的使用场景就会被严重限制——你没法做蓝绿发布(新老版本共存)、没法做 A/B 测试(不同用户看到不同版本)、没法做多环境构建(开发、预发、生产共用一份代码)。MF 1.0 在这个方面的支持有限、需要借助”动态 import + script 手动加载”这种有点 hack 的方式;MF 2.0 则把”动态远程”作为一等公民来设计——这一代的设计演化、精确反映了从”实验室可用”到”生产级可用”的距离。技术的成熟度、不是看它在 demo 里能不能跑、而是看它在”各种边界场景、各种环境配置、各种故障注入”下还能不能稳定工作——动态远程加载就是这种”生产级成熟度”的一次考验。
// 动态远程加载
// 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 性能优化:预加载与预取
**性能优化是 MF 落地生产时无法回避的话题——如果 MF 集成后用户体验反而变差、那这个技术就失去了意义。MF 性能优化的核心挑战是——远程模块的加载、涉及额外的 HTTP 请求、额外的 JS 解析、额外的依赖协商——这些开销如果不加以优化、会让”首次打开某个用到远程模块的页面”变得明显慢于”传统 SPA”。解决之道是在用户”真正需要”这些模块之前、就用 prefetch/preload 机制在后台把它们拉下来——这本质上是我们在第 5 章讨论过的”用当前闲置时间换未来加载时间”这个通用思想的又一次应用。不同的是、这一次预加载的不是”整个子应用”、而是”某个特定的远程模块”——粒度更细、策略也可以更精准。
// 在路由级别预加载远程模块
const routes = [
{
path: '/dashboard',
component: React.lazy(() => import('remoteApp/Dashboard')),
},
];
// Webpack magic comments 仍然有效
const Dashboard = React.lazy(() =>
import(/* webpackPrefetch: true */ 'remoteApp/Dashboard')
);
但这里有一个微妙的问题:webpackPrefetch 只对 Chunk 级别的加载有效,而远程模块的加载涉及两层 Chunk——remoteEntry.js 和实际的模块 Chunk。Webpack 的 prefetch 只能处理第一层,第二层的 Chunk 需要等到 remoteEntry.js 加载并解析后才能知道。
更高级的优化策略是使用 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 中,由 __webpack_require__.f.consumes 在 Chunk 加载时完成版本选择。整个流程是编译时编排、运行时执行的典范。
读完这一章、你对 Module Federation 不再是”会用配置”的层次、而是”理解运行机制”的层次——这个认知跃迁、比任何数量的 cheat sheet 都重要。核心洞察可以用一句话概括——Module Federation 是一种把”静态模块系统”和”动态加载系统”融合在一起的精妙工程。静态模块系统的好处是”编译期类型检查、打包期 tree shaking、运行期零开销”;动态加载系统的好处是”不同 bundle 可以跨网络共享”。MF 的天才之处、在于它通过编译期的精巧改写(把 import Button from 'remoteApp/Button' 改写成运行时的 container.get('./Button'))、让开发者既保留了静态模块的开发体验、又获得了动态加载的灵活性——这就像一个变速箱,让开发者在”开发时像单体代码”和”运行时像分布式模块”之间无缝切换。
对比我们在《Vue 3 源码》第 7 章看到的编译时优化——Vue 的编译器把 <div>{{ msg }}</div> 变成高效的 render 函数调用;在《Tokio 源码》第 10 章我们看到 async/await 语法糖被编译器转换为 Future 状态机——这些都是”让开发者写得舒服、让运行时跑得高效”的典型案例。MF 只是把这个哲学应用到了”模块共享”这个新场景——编译器接管那些繁琐、易错、需要精细协调的部分、让开发者的心智负担降到最低。这也是为什么现代前端工程越来越离不开构建工具——不是”构建工具让事情变复杂了”、而是”构建工具替你处理了本来就复杂的事情”。
如果说第 9 章讨论的是”什么是 MF、它为什么存在”、这一章讨论的是”MF 是如何工作的”——那么下一章我们将讨论”MF 2.0 如何把自己从 Webpack 里解放出来”。那将是一次”从专用特性到通用协议”的架构升级——对任何想要设计可持续演化的开源框架的读者、都是一次宝贵的案例学习。
思考题
读完本章,试着回答以下问题来检验你的理解深度:
-
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三个阶段)。