Skip to content

第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
        );
      }
    );
  }
}

这段代码做了两件关键的事:

  1. make 钩子中添加一个新入口。这意味着 remoteEntry.js 和你的主 bundle.js平行的两个入口——它们共享同一个编译过程,但生成独立的 Chunk。
  2. 注册依赖工厂ContainerEntryDependency 用自定义的 ContainerEntryModuleFactory 来处理,而暴露出去的模块用标准的 normalModuleFactory(因为它们就是普通模块,只是被"标记"为可暴露的)。

下图展示了 ContainerPlugin 在 Webpack 编译流程中的介入时机和产物关系:

基于 VitePress 构建