Webpack Module Federation 核心原理

2,794 阅读3分钟

总述

几周前我写过一篇文章,讲述了 Webpack Module Federation 场景下的微前端解决方案。那么读者可能会好奇,mf 的模块共享究竟是如何实现的?在本文中我们将一探究竟。

提示

  • 本文的案例来自官方 DEMO(来自 webpack module federation 官方文档)。建议读者在阅读本文之前将其运行起来,方便对照。
  • 使用的 webpack 版本:5.65.0

原理详解

案例基本介绍

案例有两个项目,第一个叫 app1,第二个叫 app2,app1 配置了远程路径 app2,并指定了共享依赖(即 shared 配置项,后文都称作共享依赖),分别为 react 和 react-dom。

另外,在业务代码中它复用了 app2 暴露出来的 Button 组件,这个我们称为远程组件, 请读者和 react、react-dom 这种共享依赖区分开

ModuleFederationPlugin 配置如下:

new ModuleFederationPlugin({
  name: 'app1',
  remotes: {
    app2: `app2@${getRemoteEntryUrl(3002)}`,
  },
  shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),

打开 localhost:3001,app1 的界面如图所示:

app2 是生产者,暴露出了一个 Button 组件,由于要和 app1 共享 react 和 react-dom,它也要配置共享依赖,其 ModuleFederationPlugin 配置如下:

new ModuleFederationPlugin({
  name: 'app2',
  library: { type: 'var', name: 'app2' },
  filename: 'remoteEntry.js',
  exposes: {
    './Button': './src/Button',
  },
  shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),

另外再给出 app1 的核心代码:

index.js

// index.js
import('./bootstrap'); // 动态 import

bootstrap.js

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

ReactDOM.render(<App />, document.getElementById('root'));

综合上述结论可以画出依赖图:

从 import react from 'react' 讲起

以 react 这个共享依赖为例,依照开发经验,react 真正被消费的时机在 import 语句中,找到相应的代码,react 的导入本质上是执行 __webpack_require__("reactModuleId")

reactModuleId 即 react 的模块 Id,由 webpack 生成,下面都用此简写来表示。

走进 __webpack_require__("reactModuleId"),我们注意到在没有缓存的情况下,会从 __webpack_modules__["reactModuleId"] 中找到相应的模块,然后通过 Function.prototype.call 方法执行:

模块得以执行,上下文如下所示:

我们可以看出:

  • 某个时机 __webpack_require__.m["reactModuleId"] 被赋值(成一个函数),我们在 react 真正执行的时候就可以调用它,这个时机我们到后面再讨论。
  • react 模块的执行,本质上是执行最后的 factory() 方法。
  • 在 factory 方法执行之前的代码(也就是本文至此为止走过的流程)全部在 app1

而 factory 方法的本质,则是执行 app2__webpack_require__("../node_modules/react/index.js"),如下所示(读者可以在 factory() 调用处单步调试,就可以看到下面的代码),接着就是 webpack 的常规模块获取逻辑,最终获取并执行 react 真正的模块, 具体实现就略去不表了。

上面消费 react 的流程执行流程为: app1 的代码 -- app2 的代码 -- react 真正的代码(来自 app2)

而将这些关联起来的核心,就是这个 factory() 方法,而 factory() 得以执行,依靠的就是 __webpack__.m[reactModuleId](这个东西本质上就是 __webpack_require__.m),因为在打包产物的某处有一段这样的代码:

// expose the modules object (__webpack_modules__)
__webpack_require__.m = __webpack_modules__;

所以我们现在要找 webpack.m[reactModuleId] 值被写入的时机,这个值决定了作为远程依赖 react 的来源。

何时指定依赖源?

通过调试,指定依赖源的时机是 import('./bootstrap') 时(这也是为什么 mf 要求我们对入口文件需要使用动态的 import -- 我们要在此时下载 app2 的 mf 入口文件,然后判断到底需要加载哪一个模块),下面我们走一遍流程:

进入 index.js,执行 import('./bootstrap'); 语句。

接着执行 __webpack_require__.e("src_bootstrap_js"),一路向下,最后会去执行 __webpack_require__.f.consumes(src_bootstrap_js 的 ModuleId)

通过 chunkIdchunkMapping 中拿到其下所有依赖的共享依赖,我们可以看到在 shared 中配置的 react 和 react-dom 的身影:

遍历 chunkMapping['src_bootstrap_js'],通过 moduleToHandlerMapping 这个对象拿到对应的 loadSingletonVersionCheckFallback 回调,并执行之,执行的结果是一个 promise,它的 resoloved 回调参数就是我们在第一小节提到的 factory 函数:

可以看出依赖源的指定就是 loadSingletonVersionCheckFallback 方法了!我们来看它的具体实现,首先先看传参:

  • default,sharedScope 的名称(sharedScope 具体概念后面提)
  • react,共享依赖名称
  • [1, 16, 13, 0],版本信息,这里表示 16.13.0

首先这个函数会去执行 __webpack_require__.I('default'),来看其代码:

  • 如果 __webpack_require__.S 中没有此 sharedScope,初始化成一个空对象。
  • 在后面的 switch case 语句中 调用了 register 方法将 app1 的 reactreact-dom 模块的加载器注册到名称为 defaultsharedScope 中。
  • 所谓 sharedScope,就是一个 (key = 共享依赖名称,value = 包含该依赖各个版本的基本信息和加载器) 的对象。
  • 如下图,app1 有一个名称为 default 的 sharedScope,有两个共享依赖 reactreactDOM,各自有一个版本,通过调用 get() 方法来执行后续模块加载与执行的操作:

提示

这里的 get 方法执行,并不代表共享依赖被加载与执行(后面会提到),可以暂时把它看成一个加载器,还请读者注意。

  • 在上面几个 register 之后,我们会调用 initExternal("webpack/container/reference/app2"),这是和远程项目 app2 交互的纽带,这个方法会去下载远程 app2 相关入口文件,然后调用其暴露的 init 方法(mf 特有):

特别注意执行 app2 的 init 方法前的第一个入参,它是 __webpack_require__.S['default'],即 app1 的 sharedScope

来看 app2 的 init() 方法:

var init = (shareScope, initScope) => {
    if (!__webpack_require__.S) return;
    var name = "default"       
    // 将 app1 的 shared scope 挂载到 app2 的 __webpack_require__.S 下
    __webpack_require__.S[name] = shareScope;
    // 执行 APP2 的 __webpack_require__.I,见下图
 return __webpack_require__.I(name, initScope);
};

上图是 app2 的 __webpack_require__.I(name, initScope)

和上面 app1 的基本一致,但是有几点不同:

  • 由于 app2 是生产者,没有配置远程依赖(remotes 参数), 所以下面的 switch 语句中就不在有 initExternal 的执行。
  • 另外 __webpack_require__.S 中有 app1sharedScope,这个已经提过,是通过 app1 调用 init 方法初始化的。

由于 app2 也配置了公共依赖 reactreact-dom,所以类似于 app1 的逻辑,会调用 register 注册他们,但由于我们已经在 __webpack_require__.S 挂载了 app1 的 reactreact-dom,具体细节会有不同(请看下面注释):

var register = (name, version, factory, eager) => {
  // 拿到已经 注册 react 的所有版本
  var versions = (scope[name] = scope[name] || {});

  // 寻找 16.14.0 是否已经初始化
  var activeVersion = versions[version];

  
  // 如果符合下面的条件之一(即下面的 if 语句为 true),
  // 挂载 app2 的加载器,否则复用 app1 的加载器:

  // 1. 在 app1 中没有对应的模块版本,即 activeVersion 为空
  // 2. 旧版本没有被加载,并且没有配置 eager(强制加载)
  // 3. 旧版本没有被加载,并且 app2 的 uniqueName > 源模块的 uniqueName
  //(uniqueName 其实就是 packagejson 的 main 字段)

  
  // TIP: 这个条件 3 我觉得很迷,感觉像一个兜底逻辑
  // 如果知道为什么要这样做的同学务必告诉我,不过对复用的机制没有影响
  // 另外这部分代码相应的 pull request 在这里,我已经询问了相关作者,但对方没有给出答复:
  // https://github.com/webpack/webpack/pull/12132

  if (
    !activeVersion ||
    (!activeVersion.loaded &&
      (!eager != !activeVersion.eager
        ? eager
 : uniqueName > activeVersion.from))
  )
    versions[version] = {
      get: factory,
      from: uniqueName,
      eager: !!eager,
    };
};

举个例子,如果 app1 配置了 shared = ['react', 'reactDOM'],则 sharedScope 可能如下所示:

const scope = {
  'react': {
    '16.14.0': {
      eager: false,
      from: '@basic-host-remote/app1',
      get: () => {
        /* 获取、加载该模块的函数 */
      },
    },
  },
  'react-dom': {
    '16.14.0': {
      eager: false,
      from: '@basic-host-remote/app1',
      get: () => {
        /* 获取、加载该模块的函数 */
      },
    },
  },
};

如果 app2 配置了 shared = ['react', 'reactDOM'],且 react 为 16.14.0 版本,react-dom 为 16.15.0,则最终的 sharedScope 如下所示:

const scope = {
  'react': {
    '16.14.0': {
      eager: false,
      from: '@basic-host-remote/app1',
      get: () => {
        /* 一个函数,供获取、加载该模块 */
      },
    },
  },

  'react-dom': {
    '16.14.0': {
      eager: false,
      from: '@basic-host-remote/app1',
      get: () => {
        /* 一个函数,供获取、加载该模块 */
      },
    },

    '16.15.0': {
      eager: false,
      from: '@basic-host-remote/app2',
      get: () => {
        /* 一个函数,供获取、加载该模块 */
      },
    },
  },
};

由于 shareScope 是一个引用类型,所以在 app1 和 app2 获取的 shareScope 值都是同一个对象

上述操作的意义在于收集 module 的各个版本,供后续调用,在一定程度上也决定了本地 React 和远程组件的 React 在 React 版本相同的情况下,会使用谁的。

回到 app1 的上下文,__webpack_require__.I(scopeName) 执行完成,接着会去执行 getSingletonVersion 方法:

// 注意:下面的 versionLt(a, b) 是一个基于 semver 规范的版本比较函数,
// 表示 若 a 比 b 版本小,返回真,否则假
// 其具体实现较复杂,且不是本文要点,故略去
// 感兴趣的读者可以查看源码:https://github.com/webpack/webpack/blob/ccecc17c01af96edddb931a76e7a3b21ef2969d8/lib/util/semver.js#L42

var findSingletonVersionKey = (scope, key) => {

    // 拿到共享依赖的所有版本
    var versions = scope[key];

    // 遍历所有版本号,利用 reduce 方法,从代码中不难看出最终返回的版本是版本较大的那个。
    return Object.keys(versions).reduce((a, b) => {
        return !a || (!versions[a].loaded && versionLt(a, b)) ? b : a;
    }, 0);
};

var getSingletonVersion = (scope, scopeName, key, requiredVersion) => {
    var version = findSingletonVersionKey(scope, key);
    if (!satisfy(requiredVersion, version)) typeof console !== "undefined" && console.warn && console.warn(getInvalidSingletonVersionMessage(scope, key, version, requiredVersion));
    return get(scope[key][version]);

};

拿到对应的版本之后,直接获取 get 方法,然后调用它,这个 get 方法执行并没有拉取模块,而是返回一个工厂函数,这个工厂函数就是我们之前提到的 factory(),最终和第一小节所讲的内容实现了完美的闭环:

加载远程 expose 的 Button 组件

app1 还有一个能力,那就是调用了 app2 的一个组件,在实际开发中,他也可以是一个 JavaScript 模块,甚至是一个 JavaScript 库,那么这个是如何实现的呢,上面的依赖共享提到了远程 mf 入口提供了一个 init 方法,除此以外,还提供了一个 get 方法供 app1 调用:

接下来的流程就和 webpack 的基本模块调度逻辑类似了,如果这个组件是一个懒加载组件,那么去执行懒加载逻辑,否则就执行默认加载的逻辑,相信读者应该很熟悉了。

实践与补充

开头提到的的例子

基于上面的原理,我们再来总结文中开头提到的案例

// app1 的 mf 配置
new ModuleFederationPlugin({
  name: 'app1',
  remotes: {
    app2: `app2@${getRemoteEntryUrl(3002)}`,
  },
  shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),

// app2 的 mf 配置
new ModuleFederationPlugin({
  name: 'app2',
  library: { type: 'var', name: 'app2' },
  filename: 'remoteEntry.js',
  exposes: {
    './Button': './src/Button',
  },
  shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),

另外 app1 的 package.json 的 main 属性为 @basic-host-remote/app1,app2 为 @basic-host-remote/app2

那么打开 app1,app1 加载了 react(假设两 app 的 react 版本相同),请问这个 react 是谁的?

答案是 app2,app1 初始化 sharedScope,如下:

const scope = {
  'react': {
    '16.14.0': {
      eager: false,
      from: '@basic-host-remote/app1',
      get: () => {
        /* 一个函数,供获取、加载该模块 */
      },
    },
  }
};

接着 app1 调用 app2 的 init 方法,app2 的初始 sharedScope 来自 app1,app2 在注册自己的 react 发现版本相同,且 app2 的 package.json 字符串 > app1 的 package.json 字符串(字符串比较),则 app1 的 react['16.14.0'] 被覆盖,所以接下来 app1 的 react 和 app2 的 react 最终指向 app2 的 react。

app1 和 app2 维护不同版本的共享依赖

  • 还是和上面一样的配置,但 app1 的 react 和 react-dom 版本变成了 16.14.0,app2 的 react 和 react-dom 版本变成了 16.13.0,请问 app1 加载了哪个版本的 react?

答案是 react 版本更高的 app1:

在这种情况下,app1 的 sharedScope 如下,由于两个 react 版本不一样,所以没有冲突,都被保留下来:

const scope = {
  'react': {
    '16.14.0': {
      eager: false,
      from: '@basic-host-remote/app1',
      get: () => {
        /* 一个函数,供获取、加载该模块 */
      },
    },
    '16.13.0': {
      eager: false,
      from: '@basic-host-remote/app2',
      get: () => {
        /* 一个函数,供获取、加载该模块 */
      },
    },
  }
};

真正决定使用哪个版本是在执行入口异步 chunk,执行 getSingletonVersion 的阶段,我们会选择那个最大的,也就是 16.14.0,它来自 app1,所以加载 app1 的 react。

同一页面,多版本共存

我们更新 app1 的 package.json,将 react 版本号改为 16.14.0,同理,将 app2 的 react 版本改成 17.0.2

特别注意: 官方案例的 webpack 配置中对每一个 shared 的依赖都增加了 singleton: true 属性(该值默认为 false),这个属性会强制使用单个版本的 react,请读者务必删除此选项

然后在 App1 入口的代码、App2 expose 的 Button 组件处打印一下 React.version

打开 app1,刷新页面,查看浏览器控制台,可以看到此时页面上两个版本 React:

当然,多版本的 React 在真实环境下不会使用,这里只是举一个例子,但是在微前端架构下,由于种种原因,一些工具库可能要做到多版本共存,不难看出,mf 已经优雅地帮我们解决了这个问题。

那么多版本共存的原理是?基于上面的源码分析,特别简单,请看 React 加载时的代码:

App1

App2

可以看出,mf 插件在编译时就已经通过模块 Id 之后追加的 hash 值来确定版本,对于不同的版本会从 webpack_require.m 对象中拿到不同的加载器,从而执行不同的 factory 函数。

参考资料

达达XxjzZ(知乎用户),Module Federation 没有魔法仅仅是异步chunk

webpack,Module Federation 案例合集