微前端源码精讲

第10章 Webpack 5 Module Federation 源码

作者 杨艺韬 · 11,864 字 · 发布于 · 更新于

第10章 Webpack 5 Module Federation 源码

真正理解 Module Federation,不是学会写配置——是读懂那些配置背后,Webpack 在编译期和运行时各做了什么。

本章要点

  • 深入 ContainerPlugin 源码,理解远程模块如何被暴露为一个独立入口
  • 剖析 ContainerReferencePlugin 与 RemoteModule,理解消费端如何透明地加载远程模块
  • 解读 SharePlugin 与 ConsumeSharedPlugin 的版本协商机制,理解共享依赖如何在多个应用间去重
  • 完整追踪运行时加载流程:从 remoteEntry.js 的加载到模块工厂的实例化
  • 理解 Chunk 分割与依赖去重如何在 Module Federation 架构下协同工作

本章引用的 webpack 原生 MF 源码位于 webpack 5 的 lib/container/、lib/sharing/ 目录,核查基准是 webpack v5.109.2;enhanced 侧的对照基准是 module-federation/core 的 commit 80e9597 中 packages/enhanced/src/lib/{container,sharing}——它是这两个目录的直接分叉(文件头仍署名 Tobias Koppers 与 Zack Jackson),类名、钩子与选项名一致,最主要的差别在于 enhanced 把运行时换成了 @module-federation/runtime(__webpack_require__.federation.bundlerRuntime)。下文的运行时代码按 webpack 原生产物写,涉及 enhanced 的差异会单独注明。


如果你曾经配置过 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:如何将模块暴露为远程入口

10.1.1 ModuleFederationPlugin 的分解

ModuleFederationPlugin 是一个门面(Facade):使用者只配置一次,插件内部把配置拆给 ContainerPlugin、ContainerReferencePlugin、SharePlugin 分别处理。@module-federation/enhanced 后来在同一个门面里又多分发了 FederationRuntimePlugin、TreeShakingSharedPlugin、StatsPlugin(manifest)等插件,对使用者是透明的。

Module Federation 的用户入口是 ModuleFederationPlugin,但它本身几乎不做任何实质工作——它只是一个"编排者",将配置分发给三个底层插件:

// webpack-5.109.2/lib/container/ModuleFederationPlugin.js:40(简化)
class ModuleFederationPlugin {
  apply(compiler) {
    // ...compiler.hooks.validate 校验 schema(:56-69)
    const { options } = this;          // 注意是 this.options,不是早期版本的 this._options
    // remoteEntry 挂到全局变量:默认 library = { type: 'var', name }
    const library = options.library || { type: 'var', name: options.name };
    // remotes 的外部模块类型:默认 'script'(即 name@url 形式,用 <script> 加载)
    const remoteType =
      options.remoteType ||
      (options.library && isValidExternalsType(options.library.type)
        ? options.library.type
        : 'script');
    if (library && !compiler.options.output.enabledLibraryTypes.includes(library.type)) {
      compiler.options.output.enabledLibraryTypes.push(library.type);
    }

    // 三个底层插件都在 afterPlugins 钩子里注册
    compiler.hooks.afterPlugins.tap('ModuleFederationPlugin', () => {
      // 1. 如果有 exposes —— 注册 ContainerPlugin
      if (
        options.exposes &&
        (Array.isArray(options.exposes)
          ? options.exposes.length > 0
          : Object.keys(options.exposes).length > 0)
      ) {
        new ContainerPlugin({
          name: options.name,
          library,
          filename: options.filename,
          runtime: options.runtime,
          shareScope: options.shareScope,
          exposes: options.exposes,
        }).apply(compiler);
      }

      // 2. 如果有 remotes —— 注册 ContainerReferencePlugin
      if (
        options.remotes &&
        (Array.isArray(options.remotes)
          ? options.remotes.length > 0
          : Object.keys(options.remotes).length > 0)
      ) {
        new ContainerReferencePlugin({
          remoteType,
          shareScope: options.shareScope,
          remotes: options.remotes,
        }).apply(compiler);
      }

      // 3. 如果有 shared —— 注册 SharePlugin
      if (options.shared) {
        new SharePlugin({
          shared: options.shared,
          shareScope: options.shareScope,
        }).apply(compiler);
      }

      // 4. 无条件注册 HoistContainerReferences(:118,v5.96.0 起):在 splitChunks 之后
      //    把容器入口同步引用的模块和远程 external 挪回运行时 chunk(见 10.5.2)
      new HoistContainerReferences().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-5.109.2/lib/container/ContainerPlugin.js:24(核心流程)
class ContainerPlugin {
  // 构造函数只存原始 options(:29-32),解析全在 apply 里
  apply(compiler) {
    // ...compiler.hooks.validate 校验 schema(:40-54)

    // library 默认 { type: 'var', name: options.name }(:55-58)
    const library = this.options.library || {
      type: 'var',            // 挂载到全局变量
      name: this.options.name,
    };
    // ...把 library.type 加进 output.enabledLibraryTypes(:60-62)
    // exposes 同样在 apply 里 parseOptions(:64-76)
    const exposes = parseOptions(this.options.exposes, /* ... */);
    const shareScope = this.options.shareScope || 'default';   // :78

    compiler.hooks.make.tapAsync(
      PLUGIN_NAME,             // 源码用的是模块级常量,不是字面量 'ContainerPlugin'
      (compilation, callback) => {
        const hooks = getModuleFederationPlugin().getCompilationHooks(compilation);
        const dep = new ContainerEntryDependency(
          this.options.name, exposes, shareScope,
        );

        // 设置入口的 loc 信息,用于调试和错误追踪
        dep.loc = { name: this.options.name };

        compilation.addEntry(
          compilation.options.context,
          dep,
          {
            name: this.options.name,
            filename: this.options.filename,   // 通常是 'remoteEntry.js'
            runtime: this.options.runtime,
            library,
          },
          (error) => {
            if (error) return callback(error);
            // 通知 HoistContainerReferences 追踪这个容器入口(:100)
            hooks.addContainerEntryDependency.call(dep);
            callback();
          }
        );
      }
    );

    // 注册 ContainerEntryDependency 的模块工厂
    compiler.hooks.thisCompilation.tap(
      PLUGIN_NAME,
      (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 编译流程中的介入时机和产物关系:

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 配置只能描述普通模块请求,表达不了 ContainerEntryDependency 这种自定义依赖;而 entry 配置里的入口本身,也是由 EntryPlugin 在 make 钩子里调用 compilation.addEntry 加进来的(webpack-5.109.2/lib/EntryPlugin.js:49-50)——ContainerPlugin 只是在同一时机、用同一个 API 加入了一个自定义入口。这也是很多 Webpack 插件动态注入模块的标准模式。

10.1.3 ContainerEntryModule:容器的核心模块

理解 ContainerEntryModule,需要切换到"虚拟模块"的心智模型。webpack 的普通模块对应一个物理文件,有路径、有源码、可以被解析器读取;ContainerEntryModule 没有对应的源文件,代码是编译期拼接出来的——它以 javascript/dynamic 类型(JAVASCRIPT_MODULE_TYPE_DYNAMIC)创建,代码生成完全由插件接管。

ContainerEntryModule 是 Module Federation 架构中最精妙的部分之一。它负责生成 remoteEntry.js 的核心代码——包括 init 方法和 get 方法。

// webpack-5.109.2/lib/container/ContainerEntryModule.js:48(简化;源码 super() 传的是
// 常量 JAVASCRIPT_MODULE_TYPE_DYNAMIC,求值就是 'javascript/dynamic')
class ContainerEntryModule extends Module {
  constructor(name, exposes, shareScope) {
    super('javascript/dynamic', null);
    this._name = name;
    this._exposes = exposes;
    this._shareScope = shareScope;
  }

  // 构建阶段:为每个暴露的模块创建一个异步块(AsyncDependenciesBlock)
  build(options, compilation, resolver, fs, callback) {
    this.buildMeta = {};
    this.buildInfo = {
      strict: true,
      topLevelDeclarations: new Set(['moduleMap', 'get', 'init']),
    };
    this.buildMeta.exportsType = 'namespace';
    this.clearDependenciesAndBlocks();

    for (const [name, options] of this._exposes) {
      const block = new AsyncDependenciesBlock(
        { name: options.name },          // 可选的自定义 chunk 名
        { name },
        options.import[options.import.length - 1]
      );
      for (const request of options.import) {
        const dep = new ContainerExposedDependency(name, request);
        dep.loc = { name };
        block.addDependency(dep);
      }
      this.addBlock(block);
    }
    // 容器只静态导出 get 和 init 两个名字
    this.addDependency(new StaticExportsDependency(['get', 'init'], false));

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

      // 源码用 runtimeTemplate.blockPromise 生成加载表达式(:193-212):异步块只落在
      // 一个 chunk 时是 __webpack_require__.e(id),被 splitChunks 拆成多个 chunk 时是
      // Promise.all([...]);这里只画单 chunk 的情形。一个 expose 配了多个 import 时,
      // 工厂返回逗号表达式、导出最后一个;模块缺失时改生成抛错代码(:188-191)
      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 as it has already been initialized with a different share scope'
          );
        }
        __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,传入共享作用域。容器借此把自己能提供的共享模块版本登记进这个作用域(经由 __webpack_require__.I);真正挑选版本发生在之后消费共享模块时(见 10.3.3、10.4.4)。
// 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 故意设计得很小。除了 webpack 运行时,它只包含模块映射表和两个方法,不包含业务代码(eager: true 的共享模块除外,见 10.4.3)。真正的业务代码被分割到独立的 Chunk 中,按需加载。这意味着即使一个远程应用暴露了 50 个模块,宿主应用加载 remoteEntry.js 的成本也极低——只有当真正使用某个模块时,对应的 Chunk 才会被下载。

10.1.4 异步边界与 Chunk 的关系

"异步边界"是 Module Federation 里最关键、也最容易出问题的概念:在这个点之后的代码,必须通过 Promise 等待某些异步资源加载完成,才能真正执行。MF 把每个暴露模块都放进一个异步 Chunk,就是在制造这种边界。消费侧同样需要这样一道边界:如果入口 Chunk 里的代码同步引用了非 eager 的共享模块,运行时来不及先去共享作用域里取到它,就会报 "Shared module is not available for eager consumption" 这种经典错误(webpack-5.109.2/lib/sharing/ConsumeSharedRuntimeModule.js:300)。这也是为什么 MF 要求(或强烈建议)应用入口先用 import('./bootstrap') 隔出一道异步边界(见 10.4.3)——边界之后的代码照样可以静态 import 远程模块和共享模块,这是异步模型的内在要求,不是人为限制。

一个容易被忽视的细节是:每个暴露的模块都会被放入一个异步 Chunk。在 ContainerEntryModule 的 build 阶段,Webpack 会为每个暴露模块创建一个 AsyncDependenciesBlock(webpack-5.109.2/lib/container/ContainerEntryModule.js:133 处 new AsyncDependenciesBlock(...),随后 this.addBlock(block)):

// ContainerEntryModule.js 中的异步块创建
build(options, compilation, resolver, fs, callback) {
  // ...
  for (const [name, options] of this._exposes) {
    const block = new AsyncDependenciesBlock(
      { name: options.name },
      { name },
      options.import[options.import.length - 1]
    );
    for (const request of options.import) {
      block.addDependency(new ContainerExposedDependency(name, request));
    }
    this.addBlock(block);
  }
  callback();
}

AsyncDependenciesBlock 是 Webpack 代码分割的核心原语——默认的 lazy 模式下,每一个 import() 动态导入语句最终都会被转换为一个 AsyncDependenciesBlock。Module Federation 复用了这个机制,让暴露模块天然支持按需加载。

10.2 ContainerReferencePlugin:如何消费远程模块

下图展示了远程模块从 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-5.109.2/lib/container/ContainerReferencePlugin.js:25(核心流程)
const slashCode = '/'.charCodeAt(0);

class ContainerReferencePlugin {
  // 构造函数只存原始 options,不做任何解析
  constructor(options) {
    this.options = options;
  }

  apply(compiler) {
    // ...compiler.hooks.validate 校验 schema(:41-54)

    // parseOptions 在 apply 里调用(:57-70),把 remotes 规整成
    // [key, { external: string[], shareScope }]:
    // 'remoteApp@http://localhost:3001/remoteEntry.js'
    // => ['remoteApp', { external: ['remoteApp@http://...'], shareScope: 'default' }]
    const { remoteType } = this.options;
    const remotes = parseOptions(
      this.options.remotes,
      (item) => ({
        external: Array.isArray(item) ? item : [item],
        shareScope: this.options.shareScope || 'default',
      }),
      (item) => ({
        external: Array.isArray(item.external) ? item.external : [item.external],
        shareScope: item.shareScope || this.options.shareScope || 'default',
      })
    );

    // 1. 真正的 URL 交给 ExternalsPlugin:
    //    'webpack/container/reference/remoteApp' -> 'remoteApp@http://...'
    //    多个地址时依次是 .../remoteApp、.../remoteApp/fallback-1 ...
    const remoteExternals = {};
    for (const [key, config] of remotes) {
      let i = 0;
      for (const external of config.external) {
        if (external.startsWith('internal ')) continue;
        remoteExternals[
          `webpack/container/reference/${key}${i ? `/fallback-${i}` : ''}`
        ] = external;
        i++;
      }
    }
    new ExternalsPlugin(remoteType, remoteExternals).apply(compiler);

    compiler.hooks.compilation.tap(
      'ContainerReferencePlugin',
      (compilation, { normalModuleFactory }) => {
        compilation.dependencyFactories.set(RemoteToExternalDependency, normalModuleFactory);
        compilation.dependencyFactories.set(FallbackItemDependency, normalModuleFactory);
        compilation.dependencyFactories.set(FallbackDependency, new FallbackModuleFactory());

        // 2. 拦截模块解析:当遇到匹配 remote 前缀的请求时,
        //    用 RemoteModule 替代正常的模块解析
        normalModuleFactory.hooks.factorize.tap(
          'ContainerReferencePlugin',
          (data) => {
            if (!data.request.includes('!')) {
              for (const [key, config] of remotes) {
                if (
                  data.request.startsWith(`${key}`) &&
                  (data.request.length === key.length ||
                    data.request.charCodeAt(key.length) === slashCode)
                ) {
                  return new RemoteModule(
                    data.request,                                   // 'remoteApp/Button'
                    config.external.map((external, i) =>            // 指向上面注册的 external
                      external.startsWith('internal ')
                        ? external.slice(9)
                        : `webpack/container/reference/${key}${i ? `/fallback-${i}` : ''}`
                    ),
                    `.${data.request.slice(key.length)}`,           // './Button'
                    config.shareScope
                  );
                }
              }
            }
          }
        );

        // 3. 只要某个 chunk 需要 ensureChunkHandlers,就注入 RemoteRuntimeModule
        //    (它生成 __webpack_require__.f.remotes,见 10.4.2)
        compilation.hooks.runtimeRequirementInTree
          .for(RuntimeGlobals.ensureChunkHandlers)
          .tap('ContainerReferencePlugin', (chunk, set) => {
            set.add(RuntimeGlobals.module);
            set.add(RuntimeGlobals.moduleFactoriesAddOnly);
            set.add(RuntimeGlobals.hasOwnProperty);
            set.add(RuntimeGlobals.initializeSharing);
            set.add(RuntimeGlobals.shareScopeMap);
            compilation.addRuntimeModule(chunk, new RemoteRuntimeModule());
          });
      }
    );
  }
}

这里发生了一个巧妙的"劫持":normalModuleFactory 的 factorize 钩子是 Webpack 解析模块路径的核心环节。当请求路径(例如 remoteApp/Button)的前缀匹配了 remotes 配置中的某个 key,ContainerReferencePlugin 会直接返回一个 RemoteModule 实例——跳过整个正常的模块解析流程。

这意味着 Webpack 不会尝试在文件系统中查找 remoteApp/Button 这个路径,而是创建一个特殊的"占位模块",记录下远程加载所需的元信息。注意 URL 本身并不写进 RemoteModule——它被交给 ExternalsPlugin,成为一个名为 webpack/container/reference/remoteApp 的 ExternalModule(类型 script),RemoteModule 只持有对这个 external 的引用。

10.2.2 RemoteModule:运行时加载的蓝图

RemoteModule 不包含任何实际代码。它是一个"蓝图",在代码生成阶段会被翻译为运行时的远程加载逻辑:

// webpack-5.109.2/lib/container/RemoteModule.js:54(简化;源码 super() 传的是常量
// WEBPACK_MODULE_TYPE_REMOTE,求值就是 'remote-module')
const TYPES = new Set(['remote', 'share-init']);
const RUNTIME_REQUIREMENTS = new Set([RuntimeGlobals.module]);

class RemoteModule extends Module {
  constructor(request, externalRequests, internalRequest, shareScope) {
    super('remote-module');
    this.request = request;                   // 'remoteApp/Button'
    this.externalRequests = externalRequests; // ['webpack/container/reference/remoteApp', ...]
    this.internalRequest = internalRequest;   // './Button'
    this.shareScope = shareScope;             // 'default'
  }

  // 两种源码类型:'remote'(占位)与 'share-init'(初始化共享作用域的代码片段)
  getSourceTypes() {
    return TYPES;
  }

  // 该模块的大小(用于优化决策)
  size() {
    return 6; // 很小,因为它只是一个引用
  }

  // 构建阶段:只有一个地址时依赖对应的 external;多个地址时依赖一个 FallbackDependency
  build(options, compilation, resolver, fs, callback) {
    this.buildMeta = {};
    this.buildInfo = { strict: true };
    this.clearDependenciesAndBlocks();
    if (this.externalRequests.length === 1) {
      this.addDependency(new RemoteToExternalDependency(this.externalRequests[0]));
    } else {
      this.addDependency(new FallbackDependency(this.externalRequests));
    }
    callback();
  }

  // 代码生成:'remote' 类型的源码是空串——真正的加载逻辑不在这里,
  // 而在 RemoteRuntimeModule 生成的 __webpack_require__.f.remotes 里(见 10.4.2)
  codeGeneration({ moduleGraph, chunkGraph }) {
    const module = moduleGraph.getModule(this.dependencies[0]);
    const id = module && chunkGraph.getModuleId(module);
    const sources = new Map();
    sources.set('remote', new RawSource(''));
    const data = new Map();
    // 让 ShareRuntimeModule 在 __webpack_require__.I 里加一句 initExternal(externalId):
    // 初始化共享作用域时顺带调用远程容器的 init()
    data.set('share-init', [
      {
        shareScope: this.shareScope,
        initStage: 20,
        init: id === undefined ? '' : `initExternal(${JSON.stringify(id)});`,
      },
    ]);
    return { sources, data, runtimeRequirements: RUNTIME_REQUIREMENTS };
  }

  // 序列化/反序列化支持(用于持久化缓存)
  serialize(context) {
    context.write(this.request);
    context.write(this.externalRequests);
    context.write(this.internalRequest);
    context.write(this.shareScope);
    super.serialize(context);
  }
}

RemoteModule 在模块图中的角色非常独特:它在编译期占据了一个位置,确保 Webpack 的依赖分析能正确处理远程引用;但它自己生成的源码是空的——它的模块 id 会被 RemoteRuntimeModule 收进 chunkMapping / idToExternalAndNameMapping 两张表,实际内容在运行时由 __webpack_require__.f.remotes 填进 __webpack_require__.m[id]。

10.2.3 FallbackModule 与容错机制

在生产环境中,远程服务可能宕机。当 remotes 里某个 key 配置了多个地址(数组)时,RemoteModule 依赖的不再是单个 external,而是一个 FallbackDependency,由 FallbackModuleFactory 生成 FallbackModule——它在编译时生成一段带有 try-catch 的运行时代码,依次 require 主地址和备用地址对应的 external 模块:

// FallbackModule.codeGeneration 生成的运行时代码(ids 是各个 external 模块的 id)
var ids = ["webpack/container/reference/remoteApp", "webpack/container/reference/remoteApp/fallback-1"];
var error, result, i = 0;
var loop = (next) => {
  while (i < ids.length) {
    try { next = __webpack_require__(ids[i++]); } catch (e) { return handleError(e); }
    if (next) return next.then ? next.then(handleResult, handleError) : handleResult(next);
  }
  if (error) throw error;
};
var handleResult = (result) => {
  if (result) return result;
  return loop();
};
var handleError = (e) => {
  error = e;
  return loop();
};
module.exports = loop();

💡 深度洞察:fallback 机制是在编译期"编织"进运行时代码的,而不是一个运行时的配置选项——remotes 里写了几个地址,编译期就把它们对应的 external 模块 id 写进 ids 数组,运行时由同一个 loop 逐个 try(webpack-5.109.2/lib/container/FallbackModule.js:148-174)。但别把这一点推广成"MF 的决策都在编译期完成":编译期定下的只是候选地址与版本区间这类静态信息,"到底用哪个远程容器、哪一份 shared 实例"仍然是运行时算出来的——共享依赖的版本协商就完全发生在运行时(见 10.4.4)。

10.3 SharePlugin:共享依赖的版本协商机制

10.3.1 为什么需要共享依赖

没有共享机制的 Module Federation 是不可用的。想象这样的场景:

  • 宿主应用使用 react@18.2.0
  • 远程应用 A 使用 react@18.2.0
  • 远程应用 B 使用 react@18.3.1

如果每个应用都打包自己的 React,用户将下载三份 React + ReactDOM——每份 minified 后都在 100KB 量级,白白翻了三倍。更严重的是,React 的多实例会导致 Context、Hooks 等功能彻底失效。

SharePlugin 解决的就是这个问题:让多个独立构建的应用在运行时共享同一份依赖。

10.3.2 SharePlugin 的双面拆分

SharePlugin 本身也是一个编排者。它将配置拆分为两个子插件:

// webpack-5.109.2/lib/sharing/SharePlugin.js:19(简化)
class SharePlugin {
  apply(compiler) {
    // 1. ConsumeSharedPlugin —— 当前构建"消费"哪些共享模块
    new ConsumeSharedPlugin({
      shareScope: this._shareScope,
      consumes: this._consumes,
    }).apply(compiler);

    // 2. ProvideSharedPlugin —— 当前构建"提供"哪些共享模块
    new ProvideSharedPlugin({
      shareScope: this._shareScope,
      provides: this._provides,
    }).apply(compiler);
  }
}

每个参与 Module Federation 的应用都同时是提供者和消费者。当你配置 shared: ['react'] 时:

  • ProvideSharedPlugin 确保当前构建的 React 被注册到共享作用域
  • ConsumeSharedPlugin 确保当前构建在使用 React 时,优先从共享作用域获取

10.3.3 ConsumeSharedPlugin 的版本协商

版本协商的难点在于:每个应用在编译时只知道自己依赖什么版本,不知道其他应用的版本,而各应用又会各自升级。webpack 的答案是"先登记、再各自挑选"——所有应用把自己能提供的版本登记进同一个共享作用域;每个消费方在用到时各自挑:非单例取满足自己 requiredVersion 的最高版本,单例则优先沿用已经加载的那一份、否则取最高版本,不满足自己的范围时只告警(strictVersion 时抛错)。它并不会替所有参与方算出一个"同时满足全部 requiredVersion"的版本。

版本协商是 Module Federation 最复杂的运行时逻辑之一。让我们看看 ConsumeSharedPlugin 是如何实现的:

// webpack-5.109.2/lib/sharing/ConsumeSharedPlugin.js:38(核心逻辑简化)
class ConsumeSharedPlugin {
  apply(compiler) {
    compiler.hooks.thisCompilation.tap(
      'ConsumeSharedPlugin',
      (compilation, { normalModuleFactory }) => {
        // 本地 fallback 模块走普通的 normalModuleFactory
        compilation.dependencyFactories.set(ConsumeSharedFallbackDependency, normalModuleFactory);

        // consumes 由 apply 开头的 parseOptions(this.options.consumes, ...) 得到(:74-76)——
        // 和 ContainerPlugin 一样,构造函数只存 this.options,不做解析
        // 先把 consumes 配置按"已解析的绝对路径 / 未解析的包名 / 带斜杠的前缀"分成三张表
        let unresolvedConsumes, resolvedConsumes, prefixedConsumes;
        const promise = resolveMatchedConfigs(compilation, consumes).then(
          ({ resolved, unresolved, prefixed }) => {
            resolvedConsumes = resolved;
            unresolvedConsumes = unresolved;
            prefixedConsumes = prefixed;
          }
        );

        // 拦截模块解析:当遇到共享模块的请求时,用 ConsumeSharedModule 替代
        normalModuleFactory.hooks.factorize.tapPromise(
          'ConsumeSharedPlugin',
          async ({ context, request, dependencies }) => {
            await promise;
            // fallback 与 ProvideSharedPlugin 自己的请求不能再被拦截,否则会无限递归
            if (
              dependencies[0] instanceof ConsumeSharedFallbackDependency ||
              dependencies[0] instanceof ProvideForSharedDependency
            ) return;
            const match = unresolvedConsumes.get(request);
            if (match !== undefined) {
              return createConsumeSharedModule(context, request, match);
            }
            for (const [prefix, options] of prefixedConsumes) {
              if (request.startsWith(prefix)) {
                const remainder = request.slice(prefix.length);
                return createConsumeSharedModule(context, request, {
                  ...options,
                  import: options.import ? options.import + remainder : undefined,
                  shareKey: options.shareKey + remainder,
                });
              }
            }
          }
        );
        // ……另有 createModule 钩子处理 resolvedConsumes(绝对路径命中)的情况
      }
    );
  }
}

// createConsumeSharedModule:解析 fallback(import)与 requiredVersion 后,构造 ConsumeSharedModule
// - requiredVersion 缺省时,从最近的 package.json 里读出对该包的依赖范围
// - 构造参数就是 shared 配置项:shareScope / shareKey / import / requiredVersion /
//   strictVersion / singleton / eager / packageName
const createConsumeSharedModule = (context, request, config) =>
  Promise.all([resolveImport(config.import), resolveRequiredVersion(config)]).then(
    ([importResolved, requiredVersion]) =>
      new ConsumeSharedModule(context, {
        ...config,
        importResolved,
        import: importResolved ? config.import : undefined,
        requiredVersion,
      })
  );

ConsumeSharedModule 在代码生成阶段只产出一个"getter"(consume-shared 类型的源码),真正的版本选择逻辑由 ConsumeSharedRuntimeModule 注入到运行时。选用哪个函数由 ConsumeSharedModule.codeGeneration 按"有没有 requiredVersion / strictVersion / singleton"决定(webpack-5.109.2/lib/sharing/ConsumeSharedModule.js:314-322);本地 fallback 只是作为最后一个参数传进去:

// ConsumeSharedRuntimeModule 生成的运行时代码(webpack-5.109.2/lib/sharing/ConsumeSharedRuntimeModule.js:128-278,简化)
// 省略了 eager 参数(为 true 时只在 eager 注册的版本里挑)和 init 包装器:
// 每个 load* 都先等 __webpack_require__.I(scopeName) 完成,再以 scope = __webpack_require__.S[scopeName] 调用
var findLatestVersion = (scope, key) => {
  var versions = scope[key];
  // 取最高版本
  var key = Object.keys(versions).reduce((a, b) => (!a || versionLt(a, b) ? b : a), 0);
  return key && versions[key];
};
var findSatisfyingVersion = (scope, key, requiredVersion) => {
  var versions = scope[key];
  // 在满足 semver 范围的版本里取最高
  var key = Object.keys(versions).reduce((a, b) => {
    if (!satisfy(requiredVersion, b)) return a;
    return !a || versionLt(a, b) ? b : a;
  }, 0);
  return key && versions[key];
};
var findSingletonVersionKey = (scope, key) => {
  var versions = scope[key];
  // 单例:已经 loaded 的版本优先;否则取最高版本
  return Object.keys(versions).reduce((a, b) => {
    return !a || (!versions[a].loaded && versionLt(a, b)) ? b : a;
  }, 0);
};
var get = (entry) => { entry.loaded = 1; return entry.get(); };
// 作用域里根本没有这个 key:有 fallback 用 fallback,否则抛 "Shared module ... doesn't exist in shared scope"
var useFallback = (scopeName, key, fallback) => (fallback ? fallback() : failAsNotExist(scopeName, key));

// singleton + requiredVersion —— 不满足范围只警告;strictVersion 时换成 loadStrictSingletonVersion,改为抛错
var loadSingletonVersion = (scopeName, scope, key, requiredVersion, fallback) => {
  if (!exists(scope, key)) return useFallback(scopeName, key, fallback);
  var version = findSingletonVersionKey(scope, key);
  if (!satisfy(requiredVersion, version)) warn(getInvalidSingletonVersionMessage(scope, key, version, requiredVersion));
  return get(scope[key][version]);
};
// 非 singleton + strictVersion —— 没有满足范围的版本就用本地 fallback,没有 fallback 则抛错
var loadStrictVersion = (scopeName, scope, key, requiredVersion, fallback) => {
  if (!exists(scope, key)) return useFallback(scopeName, key, fallback);
  var satisfyingVersion = findSatisfyingVersion(scope, key, requiredVersion);
  if (satisfyingVersion) return get(satisfyingVersion);
  if (fallback) return fallback();
  fail(getInvalidVersionMessage(scope, scopeName, key, requiredVersion));
};
// 非 singleton、非 strictVersion —— 没有满足范围的版本就警告,然后退回最高版本
var loadVersion = (scopeName, scope, key, requiredVersion, fallback) => {
  if (!exists(scope, key)) return useFallback(scopeName, key, fallback);
  var satisfyingVersion = findSatisfyingVersion(scope, key, requiredVersion);
  if (satisfyingVersion) return get(satisfyingVersion);
  warn(getInvalidVersionMessage(scope, scopeName, key, requiredVersion));
  return get(findLatestVersion(scope, key));
};
// 没有 requiredVersion 时用 load / loadSingleton:直接取最高版本 / 单例版本

enhanced 版本的 ConsumeSharedRuntimeModule 不再内联这些函数,而是把每个消费项的 { fallback, shareScope, singleton, requiredVersion, strictVersion, eager, shareKey } 写进 __webpack_require__.consumesLoadingData,由 __webpack_require__.federation.bundlerRuntime.consumes 调用运行时实例的 loadShare();选择版本的算法在 packages/runtime-core/src/utils/share.ts 的 getRegisteredShare 里,语义与上面一致(shareStrategy 默认 'version-first','loaded-first' 会更激进地优先已加载版本)。

版本协商的核心规则:

配置项 含义 运行时行为
singleton: true 全局只允许一个版本 所有消费者使用同一个实例
strictVersion: true 严格版本匹配 singleton 下版本不满足时抛错而非 warning;非 singleton 下没有满足范围的版本就用本地 fallback,没有 fallback 则抛错(schema 注明:有 fallback 且非 singleton 时默认即为 true)
requiredVersion: '^18.0.0' semver 范围约束 非 singleton 时选择满足范围的最高版本;singleton 时只用来校验、不满足则告警或抛错
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')),
        from: 'hostApp', eager: false,
        // register 只写 get / from / eager;loaded 在第一次被消费时由 get(entry) 置为 1
      },
      '18.3.1': {
        // 这个 get 闭包来自 remoteAppB 自己的 webpack 运行时,加载的是它的 chunk
        get: () => __webpack_require__.e('vendors-react').then(() => () => __webpack_require__('react')),
        from: 'remoteAppB', eager: false,
      },
    },
    'react-dom': {
      '18.2.0': { get: () => { /* ... */ }, from: 'hostApp', eager: false },
    },
  },
};

当容器的 init(shareScope) 被调用时,它将自己提供的共享模块注册到这个结构中。多个容器调用 init 后,共享作用域中就积累了来自不同应用的、不同版本的模块。消费时,版本协商算法从中选择最合适的版本。

💡 深度洞察:singleton: true 是 React、ReactDOM 等库的必选配置。因为 Hooks 的 dispatcher 挂在 react 模块内部的状态上(React 18 通过 __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED 暴露给 react-dom):渲染时 react-dom 只把 dispatcher 写进它自己依赖的那一份 react,组件若 import 的是另一份 react,读到的 dispatcher 为空,就会报 "Invalid hook call" 错误。Module Federation 的 singleton 选项本质上是对这类"有状态单例库"的架构级保护。

10.3.5 ProvideSharedPlugin 的注册机制

ProvideSharedPlugin 的工作相对简单——它在编译时将当前构建中的共享模块包装为 ProvideSharedModule,并在运行时的 __webpack_require__.I(initSharing)函数中完成注册:

ProvideSharedPlugin 在编译阶段通过 normalModuleFactory.hooks.module 钩子记录下命中 shared 配置的模块(此时能拿到 resourceResolveData.descriptionFileData,也就是该包的 package.json),然后在 compiler.hooks.finishMake 里用 compilation.addInclude 为每一项加入一个 ProvideSharedDependency,由 ProvideSharedModuleFactory 生成 ProvideSharedModule。这个模块只携带 shareScope、shareKey、version、模块请求路径和 eager 五项元信息(webpack-5.109.2/lib/sharing/ProvideSharedModule.js:43);requiredVersion、strictVersion、singleton 属于消费侧,由 ConsumeSharedModule 持有。其中 version 未显式配置时是在编译时从 package.json 中读取的——这意味着版本信息被"烘焙"到构建产物中,而非运行时动态解析。

// ShareRuntimeModule 生成的 __webpack_require__.I(webpack 原生,简化)
// 其中每条 register(...) 来自一个 ProvideSharedModule 的 codeGeneration
//('share-init' 数据,initStage 10);RemoteModule 贡献的 initExternal(...) 在 initStage 20
__webpack_require__.S = {};
var initPromises = {};
var initTokens = {};
__webpack_require__.I = (name, initScope) => {
  if (!initScope) initScope = [];
  // 防止循环初始化:每个 scope 一个 token 对象
  var initToken = initTokens[name];
  if (!initToken) initToken = initTokens[name] = {};
  if (initScope.indexOf(initToken) >= 0) return;
  initScope.push(initToken);
  // 同一个 scope 只初始化一次
  if (initPromises[name]) return initPromises[name];

  if (!__webpack_require__.o(__webpack_require__.S, name)) __webpack_require__.S[name] = {};
  var scope = __webpack_require__.S[name];
  var uniqueName = 'hostApp'; // output.uniqueName:默认取 output.library 的名字,否则取 package.json 的 name

  // 注册当前构建提供的共享模块
  var register = (name, version, factory, eager) => {
    var versions = (scope[name] = scope[name] || {});
    var activeVersion = versions[version];
    if (
      !activeVersion ||
      // 已加载的条目永远不会被覆盖;
      // 同版本冲突时:eager 的胜过非 eager 的;都一样则 uniqueName 字典序大的胜出
      (!activeVersion.loaded &&
        (!eager != !activeVersion.eager ? eager : uniqueName > activeVersion.from))
    ) {
      versions[version] = { get: factory, from: uniqueName, eager: !!eager };
    }
  };
  // 远程容器:调用 external.init(__webpack_require__.S[name], initScope)
  // 源码外面还包了一层 try/catch;handleError 只是 warn('Initialization of sharing external failed: ' + err)
  var initExternal = (id) => {
    var module = __webpack_require__(id);
    var initFn = (module) => module && module.init && module.init(__webpack_require__.S[name], initScope);
    if (module.then) return promises.push(module.then(initFn, handleError));
    var initResult = initFn(module);
    if (initResult && initResult.then) return promises.push(initResult['catch'](handleError));
  };
  var promises = [];
  switch (name) {
    case 'default': {
      // 注册 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')
        )
      );
      // 本构建引用到的远程容器
      initExternal('webpack/container/reference/remoteApp');
    }
    break;
  }
  if (!promises.length) return (initPromises[name] = 1);
  return (initPromises[name] = Promise.all(promises).then(() => (initPromises[name] = 1)));
};

enhanced 版本把 register / initExternal 这部分逻辑挪进了 @module-federation/webpack-bundler-runtime 的 initializeSharing,__webpack_require__.I 本身只剩一行对 federation.bundlerRuntime.I(...) 的转发;共享项则以 federation.initOptions.shared = { react: [{ version, get, scope, shareConfig }] } 的形式交给运行时实例。

10.4 运行时加载流程:从 remoteEntry.js 到模块实例化

10.4.1 完整加载序列图

当宿主应用执行 import Button from 'remoteApp/Button' 时,运行时会经历以下完整流程:

宿主应用代码
    |
    v
__webpack_require__.e(chunkId)  // 加载包含这个 RemoteModule 的异步 Chunk
    |
    +---> __webpack_require__.f.remotes(chunkId, promises)
    |         |
    |         v
    |     __webpack_require__('webpack/container/reference/remoteApp')  // script 类型的 ExternalModule
    |         |   内部调用 __webpack_require__.l('http://.../remoteEntry.js'),resolve 为 window.remoteApp
    |         v
    |     __webpack_require__.I('default')  // 初始化共享作用域(同一 scope 只真正执行一次)
    |         |   内部调用 remoteApp.init(__webpack_require__.S['default'], initScope)
    |         v
    |     remoteApp.get('./Button', getScope)  // 获取模块工厂
    |         |
    |         v
    |     远程运行时的 __webpack_require__.e('src_components_Button_tsx')  // 加载模块 Chunk
    |         |
    |         v
    |     __webpack_require__.m[id] = (module) => { module.exports = factory(); }
    |
    v
__webpack_require__(RemoteModule_id) 执行工厂 → 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 处理器(RemoteRuntimeModule 生成,webpack 原生,简化)
// chunkMapping 记录了哪些 Chunk 包含远程模块
var chunkMapping = {
  'src_App_tsx': ['webpack/container/remote/remoteApp/Button'],
};
// idToExternalAndNameMapping 记录了远程模块的加载元信息:[shareScope, 内部模块名, external 模块 id]
var idToExternalAndNameMapping = {
  'webpack/container/remote/remoteApp/Button': [
    'default',                                   // shareScope
    './Button',                                  // 容器内的模块名
    'webpack/container/reference/remoteApp',     // ExternalModule 的 id(URL 在它里面)
  ],
};
__webpack_require__.f.remotes = (chunkId, promises) => {
  if (!__webpack_require__.o(chunkMapping, chunkId)) return;
  chunkMapping[chunkId].forEach((id) => {
    var getScope = __webpack_require__.R;   // 防止 get 之间的循环引用
    if (!getScope) getScope = [];
    var data = idToExternalAndNameMapping[id];
    if (getScope.indexOf(data) >= 0) return;
    getScope.push(data);
    if (data.p) return promises.push(data.p);   // 已经在加载/已加载过

    var onError = (error) => {
      if (!error) error = new Error('Container missing');
      if (typeof error.message === 'string')
        error.message += '\nwhile loading "' + data[1] + '" from ' + data[2];
      __webpack_require__.m[id] = () => { throw error; };
      data.p = 0;
    };
    // 把"同步返回 / 返回 Promise"两种情况统一起来,串起下面三步
    var handleFunction = (fn, arg1, arg2, d, next, first) => {
      try {
        var promise = fn(arg1, arg2);
        if (promise && promise.then) {
          var p = promise.then((result) => next(result, d), onError);
          if (first) promises.push((data.p = p)); else return p;
        } else {
          return next(promise, d, first);
        }
      } catch (error) { onError(error); }
    };
    // 2. 拿到容器后,初始化共享作用域(__webpack_require__.I 内部会调用 external.init(...))
    var onExternal = (external, _, first) =>
      external ? handleFunction(__webpack_require__.I, data[0], 0, external, onInitialized, first) : onError();
    // 3. 作用域就绪后,向容器要模块工厂
    var onInitialized = (_, external, first) =>
      handleFunction(external.get, data[1], getScope, 0, onFactory, first);
    // 4. 把工厂注册进模块表
    var onFactory = (factory) => {
      data.p = 1;
      __webpack_require__.m[id] = (module) => { module.exports = factory(); };
    };
    // 1. 先 require external 模块:script 类型的 ExternalModule 会用 __webpack_require__.l 加载 remoteEntry.js,
    //    resolve 为全局变量 window.remoteApp
    handleFunction(__webpack_require__, data[2], 0, 0, onExternal, 1);
  });
};

enhanced 版本的 RemoteRuntimeModule 只保留这两张表(外加一张 idToRemoteMap),把上面的流程整体转发给 __webpack_require__.federation.bundlerRuntime.remotes(...);当 external 类型是 script 时,它不再自己 require external,而是改走运行时实例的 loadRemote()。

这段运行时代码是 Module Federation 的"心脏"。让我们逐步分析每个阶段:

阶段一:__webpack_require__.l 加载远程入口

// __webpack_require__.l —— 脚本加载器(LoadScriptRuntimeModule 生成,简化)
var inProgress = {};
__webpack_require__.l = (url, done, key, chunkId) => {
  // 同一个 URL 正在加载中:把回调挂上去,不重复插入 <script>
  if (inProgress[url]) { inProgress[url].push(done); return; }
  inProgress[url] = [done];

  var script = document.createElement('script');
  script.src = url;

  var onComplete = (event) => {
    script.onerror = script.onload = null;
    clearTimeout(timeout);
    var doneFns = inProgress[url];
    delete inProgress[url];
    script.parentNode && script.parentNode.removeChild(script);
    doneFns && doneFns.forEach((fn) => fn(event));
  };

  var timeout = setTimeout(() => onComplete({ type: 'timeout', target: script }), 120000); // output.chunkLoadTimeout
  script.onerror = script.onload = onComplete;
  document.head.appendChild(script);
};

阶段二:container.init 初始化共享

init 调用是双向的——宿主应用将自己的共享作用域传递给远程容器,远程容器将自己提供的共享模块注册到同一个作用域中。这就像一次"握手"——双方交换可用的共享资源。

时序上:宿主第一次需要共享作用域时(通常是加载 bootstrap 这类 Chunk、触发 consumes / remotes 处理器时)调用 __webpack_require__.I('default'):先 register 自己的 react@18.2.0(initStage 10),再对本构建引用到的每个远程容器执行 initExternal——加载它的 remoteEntry.js 并调用 container.init(shareScope) 注册远程版本(initStage 20)。此时 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 ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));

解决方案是使用异步边界:

// 正确示例:异步入口
// index.js
import('./bootstrap');

// bootstrap.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('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 生成的代码(第四个参数 1 就是 eager 标记)
register('react', '18.2.0', () =>
  () => __webpack_require__('react'),              // 同步访问
  1
);

💡 深度洞察:异步边界不仅仅是 Module Federation 的技术要求——它还是一个架构最佳实践。通过将应用的启动代码放在异步边界之后,你获得了一个天然的"预加载时机":import('./bootstrap') 加载这个 chunk 时,__webpack_require__.f.consumes 与 f.remotes 会先把它同步依赖的共享模块和远程模块准备好,bootstrap 里的代码执行时不必再关心它们是否加载完成。之后再动态加载的 chunk 仍会各自走一遍这个流程。

10.4.4 webpack_require.f.consumes 与共享模块加载

除了 remotes 处理器,__webpack_require__.f 中还有一个 consumes 处理器,专门负责共享模块的运行时解析:

// ConsumeSharedRuntimeModule 生成的运行时代码(webpack 原生,简化)
var chunkMapping = {
  'bootstrap': ['webpack/sharing/consume/default/react/react'],
};
// 每个消费项对应一个 handler:按配置选用 10.3.3 里的某个 load* 函数
var moduleToHandlerMapping = {
  'webpack/sharing/consume/default/react/react': () =>
    loadSingletonVersion(
      'default',                      // shareScope(scope 对象由 init 包装器在 I() 完成后补上)
      'react',                        // shareKey
      false,                          // eager
      '^18.0.0',                      // requiredVersion(实际产物中是 parseRange 编码后的数组)
      // fallback:本地打包的 react
      () => __webpack_require__.e('vendors-react').then(() => () => __webpack_require__('react'))
    ),
};
var installedModules = {};
__webpack_require__.f.consumes = (chunkId, promises) => {
  if (!__webpack_require__.o(chunkMapping, chunkId)) return;
  chunkMapping[chunkId].forEach((id) => {
    if (__webpack_require__.o(installedModules, id)) return promises.push(installedModules[id]);
    var onFactory = (factory) => {
      installedModules[id] = 0;
      __webpack_require__.m[id] = (module) => {
        delete __webpack_require__.c[id];
        module.exports = factory();
      };
    };
    var onError = (error) => {
      delete installedModules[id];
      __webpack_require__.m[id] = (module) => {
        delete __webpack_require__.c[id];
        throw error;
      };
    };
    try {
      var promise = moduleToHandlerMapping[id]();
      if (promise.then) {
        promises.push((installedModules[id] = promise.then(onFactory)['catch'](onError)));
      } else onFactory(promise);
    } catch (e) { onError(e); }
  });
};

enhanced 版本把 handler 换成了 { getter, shareInfo, shareKey } 对象,__webpack_require__.f.consumes 转发给 federation.bundlerRuntime.consumes(...),后者调用运行时实例的 loadShare(shareKey),返回 false 时才执行 getter()(本地 fallback)。

这段代码的关键逻辑:

  1. 根据 chunkId 查找该 Chunk 中需要哪些共享模块
  2. 对每个共享模块,尝试从共享作用域中找到满足版本要求的模块
  3. 如果找到了,使用共享版本;如果没找到,按配置回退到本地打包的 fallback、告警后退回最高版本,或直接抛错
  4. 将获取到的模块工厂注册到 __webpack_require__.m 中

这意味着共享模块的版本选择发生在 Chunk 加载时,而非模块执行时。这是一个重要的时序细节——当你的业务代码执行 require('react') 时,该用哪个版本已经在 Chunk 加载阶段确定了。

10.5 Chunk 分割与依赖去重的协作

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 的交互

当一个模块同时被标记为"共享"和被 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/ 规则不会命中它。

但 ProvideSharedModule 背后那个真实的 react 模块(fallback 也是它)仍然是普通的 NormalModule,照样会被 splitChunks 的 cacheGroups 命中、抽进 vendors 之类的公共 Chunk——上面 register('react', ...) 里的 __webpack_require__.e('vendors-react') 就是这么来的。这本身没有问题:共享模块的 getter 只关心"加载哪个 chunk 再 require 哪个 id",chunk 怎么切它不在乎。

真正需要保护的是容器入口 chunk 本身:remoteEntry.js 必须是自包含的,如果 splitChunks.chunks: 'all' 把它的运行时或 moduleMap 抽走,消费方加载完 remoteEntry.js 就拿不到 init/get。enhanced 的 ContainerPlugin.patchChunkSplit(packages/enhanced/src/lib/container/ContainerPlugin.ts:68-150,注释原话是 "container should not be affected by splitChunks")会改写 splitChunks.chunks 和每个 cacheGroups[*].chunks,把名字等于容器 name(以及 name + '_partial')的 chunk 排除在外。webpack 原生没有这种改写 splitChunks 配置的做法;它从 v5.96.0 起由 ModuleFederationPlugin 注册 HoistContainerReferences,在 splitChunks 之后(optimizeChunks,STAGE_ADVANCED + 1)把容器入口同步引用的模块和远程 external 挪回运行时 chunk(webpack-5.109.2/lib/container/HoistContainerReferencesPlugin.js),保护的范围比 enhanced 窄,使用者仍需自己留意。

10.5.3 依赖去重的编译时与运行时

Module Federation 的依赖去重发生在两个层面:

编译时去重:ConsumeSharedPlugin 将对共享模块的直接引用替换为运行时查找。这意味着共享模块不会被重复打包到消费者的 bundle 中(除非作为 fallback)。

// 编译前
import React from 'react';
// 编译后(概念性)
var React = __webpack_require__('webpack/sharing/consume/default/react/react');
// 实际获取的是共享作用域中的 React,而非本地打包的

运行时去重:当多个应用注册了同一个共享模块的同一个版本时,运行时只会加载其中一个。同版本的条目只保留一条:已被加载(loaded)的不会被覆盖;否则 eager 的胜过非 eager 的,两者相同则 uniqueName 字典序大的胜出(webpack-5.109.2/lib/sharing/ShareRuntimeModule.js:106)。

// 运行时去重示意
// 宿主注册: scope.react['18.2.0'] = { get: hostFactory, from: 'host' }
// 远程A注册: scope.react['18.2.0'] 已存在且未加载,'remoteA' > 'host'(字典序),由远程A的条目替换
// 远程B注册: scope.react['18.3.1'] = { get: remoteBFactory, from: 'remoteB' }

// 最终 scope.react 只有两个版本条目,而非三个
// 消费者选择 18.3.1(满足 ^18.0.0 的最高版本)

10.5.4 动态远程加载的高级模式

如果远程地址只能在构建时写死,蓝绿发布、A/B 测试、多环境共用一份构建产物都很难做。MF 1.0 要靠 promise 形式的 remote 或手动加载脚本来实现动态地址;MF 2.0 把它做成了运行时 API(见第 11 章 §11.1.3)。

// 动态远程加载
// 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 性能优化:预加载与预取

远程模块的加载涉及额外的 HTTP 请求、额外的 JS 解析和额外的依赖协商,不加优化,"首次打开某个用到远程模块的页面"会明显慢于传统 SPA。办法是在用户真正需要之前,用 prefetch/preload 在后台把它们拉下来——和第 3 章乾坤 prefetch(requestIdleCallback 空闲时预取子应用资源)是同一个思路,只是这次预加载的粒度是某个远程模块,而不是整个子应用。

// 在路由级别预加载远程模块
const routes = [
  {
    path: '/dashboard',
    component: React.lazy(() => import('remoteApp/Dashboard')),
  },
];

// Webpack magic comments 仍然有效
const Dashboard = React.lazy(() =>
  import(/* webpackPrefetch: true */ 'remoteApp/Dashboard')
);

但这里有一个微妙的问题:webpackPrefetch 走的是 __webpack_require__.F(prefetch 处理器),最多只为宿主自己构建出的那个异步 Chunk 插入 <link rel="prefetch">;remotes / consumes 处理器只注册在 __webpack_require__.f 上(webpack-5.109.2/lib/container/RemoteRuntimeModule.js:76-78、webpack-5.109.2/lib/sharing/ConsumeSharedRuntimeModule.js:314-316),所以 remoteEntry.js 和远程容器里的模块 Chunk 都不会被预取——后者更是要等 remoteEntry.js 加载、init、get 之后才知道要加载哪些。

更高级的优化策略是使用 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['default'],经 init 传给各容器),由 __webpack_require__.f.consumes 在 Chunk 加载时完成版本选择。整个流程是编译时编排、运行时执行的典范。

下一章讨论 MF 2.0 如何把运行时从 webpack 中独立出来,以及它与 Rspack 的结合。


思考题

读完本章,试着回答以下问题来检验你的理解深度:

  1. remoteEntry.js 的体积与暴露模块的数量是什么关系? 如果一个远程应用暴露了 100 个模块,remoteEntry.js 会变得很大吗?为什么?

  2. 如果宿主应用配置了 shared: { react: { singleton: true, requiredVersion: '^17.0.0' } },而远程应用提供了 react@18.2.0,运行时会发生什么? strictVersion 的有无会改变结果吗?

  3. 为什么 Module Federation 要求(或强烈建议)使用异步入口(import('./bootstrap'))? 如果不用异步入口,共享模块的加载时序会出现什么问题?请从 __webpack_require__.f.consumes 的执行时机来分析。

  4. eager: true 和 eager: false 分别在什么场景下使用? eager: true 虽然避免了异步加载问题,但它会带来什么副作用?

  5. 假设你需要实现一个"运行时动态注册远程容器"的功能——在宿主应用运行时,根据后端配置动态加载一个编译时未知的远程应用。 请基于本章的源码分析,描述你需要在运行时执行哪些步骤(提示:考虑 __webpack_require__.l、container.init、container.get 三个阶段)。