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 编译流程中的介入时机和产物关系:

💡 深度洞察:为什么 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 的核心结构:

  1. moduleMap:一个从模块名到异步加载函数的映射表。每个暴露的模块并不内联在 remoteEntry.js 中,而是通过 __webpack_require__.e(加载 Chunk)延迟加载。
  2. get(module):消费者调用这个方法来获取指定的暴露模块。它返回一个 Promise,解析后得到模块工厂。
  3. 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。在 ContainerEntryModulebuild 阶段,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 复用了这个机制,让暴露模块天然支持按需加载。

基于 VitePress 构建