Appearance
第10章 Webpack 5 Module Federation 源码
"真正理解 Module Federation,不是学会写配置——是读懂那些配置背后,Webpack 在编译期和运行时各做了什么。"
本章要点
- 深入 ContainerPlugin 源码,理解远程模块如何被暴露为一个独立入口
- 剖析 ContainerReferencePlugin 与 RemoteModule,理解消费端如何透明地加载远程模块
- 解读 SharePlugin 与 ConsumeSharedPlugin 的版本协商机制,理解共享依赖如何在多个应用间去重
- 完整追踪运行时加载流程:从 remoteEntry.js 的加载到模块工厂的实例化
- 理解 Chunk 分割与依赖去重如何在 Module Federation 架构下协同工作
如果你曾经配置过 Module Federation,你一定写过这样的代码:
javascript
// webpack.config.js - 远程应用
new ModuleFederationPlugin({
name: 'remoteApp',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/components/Button',
},
shared: ['react', 'react-dom'],
});然后在宿主应用里:
javascript
// 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 的分解
Module Federation 的用户入口是 ModuleFederationPlugin,但它本身几乎不做任何实质工作——它只是一个"编排者",将配置分发给三个底层插件:
typescript
// 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 如何将配置分发给三个底层插件,以及各插件在编译期和运行时的职责分工:
10.1.2 ContainerPlugin 的核心逻辑
ContainerPlugin 的职责很明确:为当前构建生成一个"容器入口"(即 remoteEntry.js),让外部消费者可以通过这个入口获取被暴露的模块。
typescript
// 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 编译流程中的介入时机和产物关系: