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 编译流程中的介入时机和产物关系:
💡 深度洞察:为什么 ContainerPlugin 选择在
make钩子中添加入口,而不是直接修改entry配置?因为entry配置在 Webpack 启动时就已经被处理完毕,make阶段是编译开始后的第一个可以动态添加入口的时机。这也是很多 Webpack 插件动态注入模块的标准模式。
10.1.3 ContainerEntryModule:容器的核心模块
ContainerEntryModule 是 Module Federation 架构中最精妙的部分之一。它负责生成 remoteEntry.js 的核心代码——包括 init 方法和 get 方法。
typescript
// 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,传入共享作用域。这就是版本协商发生的时机。
javascript
// 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 的关系
一个容易被忽视的细节是:每个暴露的模块都会被放入一个异步 Chunk。在 ContainerEntryModule 的 build 阶段,Webpack 会为每个暴露模块创建一个 AsyncDependenciesBlock:
typescript
// 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 复用了这个机制,让暴露模块天然支持按需加载。