模块联邦实战

代码复用新革命

Posted by Li Yucang on July 11, 2023

模块联邦实战

webpack5 的 Module federation 对于深受多应用伤害的人来说,着实让人眼前一亮,这篇文章就带你了解我们项目遇到的困境以及 Module federation 可以如何帮助我们走出这个困境。

背景

多应用场景

对于一个互联网产品来说,一般会有不同的细分应用,比如腾讯文档可以分为word、excel、ppt等等品类,抖音 PC 站点可以分为短视频站点、直播站点、搜索站点等子站点,而每个子站又彼此独立,可能由不同的开发团队进行单独的开发和维护,看似没有什么问题。

但实际上会经常遇到一些模块共享的问题,也就是说不同应用中总会有一些共享的代码,比如公共组件、公共工具函数、公共第三方依赖等等。

我们以腾讯文档来举例,从功能层面上来说,用户最熟悉的可能就是 word、excel、ppt、表单这四个大品类,四个品类彼此独立,可能由不同的团队主要负责开发维护,那从开发者角度来说,四个品类四个仓库各自独立维护,好像事情就很简单,但是现实情况实际上却复杂很多。我们来看一个场景:

通知中心的需求

对于复杂的权限场景,为了让使用者能快速能获得最新状态,我们实际上有一个通知中心的需求,在 pc 的样式大致就是上图里面的样子。这里是在文档的列表页看到的入口,实际上在上面提到的四大品类里面,都需要嵌入这样的一个页面。

那么问题来了,为了最小化这里的开发和维护成本,肯定是各个品类公用一套代码是最好的。

模块共享之痛

1、发布 npm 包

发布 npm 包是一种常见的复用模块的做法,我们可以将一些公用的代码封装为一个 npm 包,具体的发布更新流程是这样的:

1、公共库 lib1 改动,发布到 npm;

2、所有的应用安装新的依赖,并进行联调。

封装 npm 包可以解决模块复用的问题,但它本身又引入了新的问题:

  • 开发效率问题。每次改动都需要发版,并所有相关的应用安装新依赖,流程比较复杂。

  • 项目构建问题。引入了公共库之后,公共库的代码都需要打包到项目最后的产物后,导致产物体积偏大,构建速度相对较慢。

因此,这种方案并不能作为最终方案,只是暂时用来解决问题的无奈之举。

2、Git Submodule

通过 git submodule 的方式,我们可以将代码封装成一个公共的 Git 仓库,然后复用到不同的应用中,但也需要经历如下的步骤:

1、公共库 lib1 改动,提交到 Git 远程仓库;

2、所有的应用通过git submodule命令更新子仓库代码,并进行联调。

你可以看到,整体的流程其实跟发 npm 包相差无几,仍然存在 npm 包方案所存在的各种问题。

3、依赖外部化(external)+ CDN 引入

即对于某些第三方依赖我们并不需要让其参与构建,而是使用某一份公用的代码。按照这个思路,我们可以在构建引擎中对某些依赖声明external,然后在 HTML 中加入依赖的 CDN 地址:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
  </head>
  <body>
    <div id="root"></div>
    <!-- 从 CDN 上引入第三方依赖的代码 -->
    <script src="https://cdn.jsdelivr.net/npm/react@17.0.2/index.min.js"><script>
    <script src="https://cdn.jsdelivr.net/npm/react-dom@17.0.2/index.min.js"><script>
  </body>
</html>

如上面的例子所示,我们可以对react和react-dom使用 CDN 的方式引入,一般使用UMD格式产物,这样不同的项目间就可以通过window.React来使用同一份依赖的代码了,从而达到模块复用的效果。不过在实际的使用场景,这种方案的局限性也很突出:

  • 兼容性问题。并不是所有的依赖都有 UMD 格式的产物,因此这种方案不能覆盖所有的第三方 npm 包。

  • 依赖顺序问题。我们通常需要考虑间接依赖的问题,如对于 antd 组件库,它本身也依赖了 react 和 moment,那么react和moment 也需要 external,并且在 HTML 中引用这些包,同时也要严格保证引用的顺序,比如说moment如果放在了antd后面,代码可能无法运行。而第三方包背后的间接依赖数量一般很庞大,如果逐个处理,对于开发者来说简直就是噩梦。

  • 产物体积问题。由于依赖包被声明external之后,应用在引用其 CDN 地址时,会全量引用依赖的代码,这种情况下就没有办法通过 Tree Shaking 来去除无用代码了,会导致应用的性能有所下降。

4、Monorepo

作为一种新的项目管理方式,Monorepo 也可以很好地解决模块复用的问题。在 Monorepo 架构下,多个项目可以放在同一个 Git 仓库中,各个互相依赖的子项目通过软链的方式进行调试,代码复用显得非常方便,如果有依赖的代码变动,那么用到这个依赖的项目当中会立马感知到。

不得不承认,对于应用间模块复用的问题,Monorepo 是一种非常优秀的解决方案,但与此同时,它也给团队带来了一些挑战:

  • 所有的应用代码必须放到同一个仓库。如果是旧有项目,并且每个应用使用一个 Git 仓库的情况,那么使用 Monorepo 之后项目架构调整会比较大,也就是说改造成本会相对比较高。

  • Monorepo 本身也存在一些天然的局限性,如项目数量多起来之后依赖安装时间会很久、项目整体构建时间会变长等等,我们也需要去解决这些局限性所带来的的开发效率问题。而这项工作一般需要投入专业的人去解决,如果没有足够的人员投入或者基建的保证,Monorepo 可能并不是一个很好的选择。

  • 项目构建问题。跟 发 npm 包的方案一样,所有的公共代码都需要进入项目的构建流程中,产物体积还是会偏大。

5、Iframe

Iframe 是另一种方案,可以将 共享模块 做一个 iframe 嵌入到各个应用中,这样只需要升级 iframe 一个应用,其他应用都不用改动。

  • 但 iframe 也有缺点,首先使用 iframe 每次打开组件,DOM 树都会重建,所以打开速度较慢。

  • 其次 iframe 跨应用通信使用 window.postMessage 的方式,若应用部署在不同的域名下,使用 postMessage 需要控制好 origin 和 source 属性验证发件人的身份,不然可能会存在跨站点脚本漏洞。

我们的解决方案

为了能在不支持 ES6 代码的环境下快速引入 React 来加速需求开发,我们想出了一个所谓的 Script-Loader(下面会简称 SL)的模式。

整体架构如图:

简单来说就是,参考 jquery 的引入方式,我们用另外一个项目去实现这些功能,然后把代码打包成 ES5 代码,对外提供很多接口,然后在各个品类页,引入我们提供的加载脚本,内部会自动去加载文件,获取每个模块的 js 文件的 CDN 地址并且加载。这样做到各个模块各自独立,并且所有模块和各个品类形成独立。

在这种模式下,每次发布,我们只需要去发布各个改动的模块以及最新的配置文件,其他品类就能获得自动更新。

这个模式并不一定适合所有项目,也不一定是最好的解决方案,从现在的角度来看,有点像微前端的概念,但是实际上却也是有区别的,这里就不展开了。这种模式目前确实能解决多应用复用代码的需求。

遇到的问题

这种模式本质上目前没有很严重的问题,但是有一个很痛点一直困扰我们,那就是品类代码和 SL 的代码共享问题。举个例子:

Excel 品类改造后使用了 React,SL 的模块 A、模块 B、模块 C 引入了 React

因为 SL 的模块之间是各自独立的,所以 React 也是各自打包的,那就是说当你打开 Excel 的时候,如果你用了模块 A、B、C,那你最终页面会加载四份 React 代码,虽然不会带上什么问题,但是对于有追求的前端来说,我们还是想去解决这样的问题。

解决方案: External

对于 React 来说,我们可以默认品类是加载了 React,所以我们直接把 SL 里面的 React 配置为 External,这样就不会打包了,但是实际上情况没有这么简单:

问题一:模块可能独立页面

就以上面的通知中心来说,在移动端上面就不是嵌入的了,而且独立页面,所以这个独立页面需要你手动引入 React

问题二:公共包不匹配

简单来说,就是 SL 依赖的包,在品类里面可能并没有使用,例如 Mobx 或者 Redux

问题三:不是所有包都可以直接配置 External

这里的问题是说像 React 这种包我们可以通过配置 External 为 window.React 来达到共用,但是不是所有包都可以这样的,那对于不能配置为全局环境的包来说,还没法解决这里的代码共享问题

基于这些问题,我们目前的选择是一种折中方案,我们把可以配置全局环境的包提取出来,每个模块指明依赖,然后在 SL 内部,加载模块代码之前会去检测依赖,依赖加载完成才会加载执行实际模块代码。

这种方式有很大问题,你需要手动去维护这样的依赖,每个共享包实际上你都是需要单独打包成一个 CDN 文件,为的是当依赖检测失败的时候,可以有一个兜底加载文件。因此,实际上目前也只有 React 包做了这个共享。

那么到这里,核心问题就变成了品类代码和 SL 如何做到代码共享。对于其他项目来说,其实也就是多应用如何做到代码共享。

webpack 的打包原理

为了解决上面的问题,我们实际上想从 webpack 入手,去实现这样的一个插件帮我们解决这个问题。核心思路就是 hook webpack 的内部 require 函数,在这之前我们先来看一下 webpack 打包后的一些原理,这个也是后面理解 Module federation 的核心。

chunk 和 module

webpack 里面有两个很核心的概念,叫 chunk 和 module,这里为了简单,只看 js 相关的,用笔者自己的理解去解释一下他们直接的区别:

module:每一个源码 js 文件其实都可以看成一个 module
chunk:每一个打包落地的 js 文件其实都是一个 chunk,每个 chunk 都包含很多 module

默认的 chunk 数量实际上是由你的入口文件的 js 数量决定的,但是如果你配置动态加载或者提取公共包的话,也会生成新的 chunk。

打包代码解读

有了基本理解后,我们需要去理解 webpack 打包后的代码在浏览器端是如何加载执行的。为此我们准备一个非常简单的 demo,来看一下它的生成文件。

src
---main.js
---moduleA.js
---moduleB.js
 
/**
* moduleA.js
*/
export default function testA() {
    console.log('this is A');
}
 
/**
* main.js
*/
import testA from './moduleA';
 
testA();
 
import('./moduleB').then(module => {
 
});

非常简单,入口 js 是 main.js,里面就是直接引入 moduleA.js,然后动态引入 moduleB.js,那么最终生成的文件就是两个 chunk,分别是:

1、main.js 和 moduleA.js 组成的 bundle.js

2、moduleB.js 组成的 0.bundle.js

如果你了解 webpack 底层原理的话,那你会知道这里是用 mainTemplate 和 chunkTemplate 分别渲染出来的,不了解也没关系,我们继续解读生成的代码

import 变成了什么样

整个 main.js 的代码打包后是下面这样的

(function (module, __webpack_exports__, __webpack_require__) {
 
    "use strict";
    __webpack_require__.r(__webpack_exports__);
    /* harmony import */
    var _moduleA__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__( /*!        ./moduleA */ "./src/moduleA.js");
 
    Object(_moduleA__WEBPACK_IMPORTED_MODULE_0__["default"])();
 
    __webpack_require__.e( /*! import() */ 0).then(__webpack_require__.bind(null, /*! ./moduleB             */ "./src/moduleB.js")).then(module => {
 
    });
})

可以看到,我们的直接 import moduleA 最后会变成 webpack_require,而这个函数是 webpack 打包后的一个核心函数,就是解决依赖引入的。

webpack_require 是怎么实现的

那我们看一下 webpack_require 它是怎么实现的:

function __webpack_require__(moduleId) {
    // Check if module is in cache
    // 先检查模块是否已经加载过了,如果加载过了直接返回
    if (installedModules[moduleId]) {
        return installedModules[moduleId].exports;
    }
    // Create a new module (and put it into the cache)
    // 如果一个import的模块是第一次加载,那之前必然没有加载过,就会去执行加载过程
    var module = installedModules[moduleId] = {
        i: moduleId,
        l: false,
        exports: {}
    };
    // Execute the module function
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    // Flag the module as loaded
    module.l = true;
    // Return the exports of the module
    return module.exports;
}

如果简化一下它的实现,其实很简单,就是每次 require,先去缓存的 installedModules 这个缓存 map 里面看是否加载过了,如果没有加载过,那就从 modules 这个所有模块的 map 里去加载。

modules 从哪里来的

那相信很多人都有疑问了,modules 这么个至关重要的 map 是从哪里来的呢,我们把 bundle.js 生成的 js 再简化一下:

(function (modules) {})({
    "./src/main.js": (function (module, __webpack_exports__, __webpack_require__) {}),
    "./src/moduleA.js": (function (module, __webpack_exports__, __webpack_require__) {})
});

所以可以看到,这其实是个立即执行函数,modules 就是函数的入参,具体值就是我们包含的所有 module,到此,一个 chunk 是如何加载的,以及 chunk 如何包含 module,相信大家一定会有自己的理解了。

动态引入如何操作呢

上面的 chunk 就是一个 js 文件,所以维护了自己的局部 modules,然后自己使用没啥问题,但是动态引入我们知道是会生成一个新的 js 文件的,那这个新的 js 文件 0.bundle.js 里面是不是也有自己的 modules 呢?那 bundle.js 如何知道 0.bundle.js 里面的 modules 呢?

先看动态 import 的代码变成了什么样:

__webpack_require__.e( /*! import() */ 0).then(__webpack_require__.bind(null, /*! ./moduleB             */ "./src/moduleB.js")).then(module => {
 
});

从代码看,实际上就是外面套了一层 webpck_require.e,然后这是一个 promise,在 then 里面再去执行 webpack_require。

实际上 webpck_require.e 就是去加载 chunk 的 js 文件 0.bundle.js,具体代码就不贴了,没啥特别的。

等到加载回来后它认为bundle.js 里面的 modules 就一定会有了 0.bundle.js 包含的那些 modules,这是如何做到的呢?

我们看 0.bundle.js 到底是什么内容,让它拥有这样的魔力:

(window["webpackJsonp"] = window["webpackJsonp"] || []).push(
    [
        [0],
        {
            "./src/moduleB.js": (function (module, __webpack_exports__, __webpack_require__) {})
        }
    ]
);

拿简化后的代码一看,大家第一眼想到的是 jsonp,但是很遗憾的是它不是一个函数,却只是向一个全局数组里面 push 了自己的模块 id 以及对应的 modules。那看起来魔法的核心应该是在 bundle.js 里面了,事实的确也是如此。

var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
jsonpArray.push = webpackJsonpCallback;
jsonpArray = jsonpArray.slice();
for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
var parentJsonpFunction = oldJsonpFunction;

在 bundle.js 的里面,我们看到这么一段代码,其实就是说我们劫持了 push 函数,那 0.bundle.js 一旦加载完成,我们岂不是就会执行这里,那不就能拿到所有的参数,然后把 0.bundle.js 里面的所有 module 加到自己的 modules 里面去!

总结一下

如果你没有很理解,可以配合下面的图片,再把上面的代码读几遍。

其实简单来说就是,对于 mainChunk 文件,我们维护一个 modules 这样的所有模块 map,并且提供类似 webpack_require 这样的函数。对于 chunkA 文件(可能是因为提取公共代码生成的、或者是动态加载)我们就用类似 jsonp 的方式,让它把自己的所有 modules 添加到主 chunk 的 modules 里面去。

如何解决我们的问题?

基于这样的一个理解,我们就在思考,多应用代码共享能不能解决呢?

具体到实际场景,就是如下图:

因为是独立的项目,所以 webpack 打包也是有两个 mainChunk,然后有各自的 chunk(其实这里会有 chunk 覆盖或者 chunk 里面的 module 覆盖问题,所以 id 要采用 md5)。

那问题的核心就是如何打通两个 mainChunk 的 modules?

如果是自由编程,我想大家的实现方式可就太多了,但是在 webpack 的框架限制下面,如何快速的实现这个,我们也一直在思考方案,目前想到的方案如下:

SL 模块内部的 webpack_require 被我们 hack,每次在 modules 里面找不到的时候,我们去 Excel 的 modules 里面去找,这样需要把 Excel 的 modules 作为全局变量

但是对于 Excel 不存在的模块我们需要怎么处理?

这种很明显就是运行时环境,我们需要做好加载时的失败降级处理,但是这样就会遇到同步转异步的问题,本来你是同步引入一个模块的,但是如果它在 Excel 的 modules 不存在的时候,你就需要先一步加载这个 module 对应的 chunk,变成了类似动态加载,但是你的代码还是同步的,这样就会有问题。

所以我们需要将依赖前置,也就是说在加载 SL 模块后,它知道自己依赖哪些共享模块,然后去检测是否存在,不存在则依次去加载,所有依赖就位后才开始执行自己。

Module federation

说实话,webpack 底层还是很复杂的,在不熟悉的情况下而且定制程度也不能确定,所以我们也是迟迟没有去真正做这个事情。但是偶然的机会了解到了 webpack5 的 Module federation,通过看描述,感觉和我们想要的东西很像,于是我们开始一探究竟!

介绍

下面我们就来正式介绍Module Federation,即模块联邦解决方案,看看它到底是如何解决模块复用问题的。

模块联邦中主要有两种模块: 本地模块和远程模块。

本地模块即为普通模块,是当前构建流程中的一部分,而远程模块不属于当前构建流程,在本地模块的运行时进行导入,同时本地模块和远程模块可以共享某些依赖的代码,如下图所示:

值得强调的是,在模块联邦中,每个模块既可以是本地模块,导入其它的远程模块,又可以作为远程模块,被其他的模块导入。如下面这个例子所示:

如图,其中 A 模块既可以作为本地模块导入 B,又可以作为远程模块被 C 导入。

以上就是模块联邦的主要设计原理,现在我们来好好分析一下这种设计究竟有哪些优势:

1、实现任意粒度的模块共享。这里所指的模块粒度可大可小,包括第三方 npm 依赖、业务组件、工具函数,甚至可以是整个前端应用!而整个前端应用能够共享产物,代表着各个应用单独开发、测试、部署,这也是一种微前端的实现。

2、优化构建产物体积。远程模块可以从本地模块运行时被拉取,而不用参与本地模块的构建,可以加速构建过程,同时也能减小构建产物。

3、运行时按需加载。远程模块导入的粒度可以很小,如果你只想使用 app1 模块的add函数,只需要在 app1 的构建配置中导出这个函数,然后在本地模块中按照诸如import(‘app1/add’)的方式导入即可,这样就很好地实现了模块按需加载。

4、第三方依赖共享。通过模块联邦中的共享依赖机制,我们可以很方便地实现在模块间公用依赖代码,从而避免以往的external + CDN 引入方案的各种问题。

从以上的分析你可以看到,模块联邦近乎完美地解决了以往模块共享的问题,甚至能够实现应用级别的共享,进而达到微前端的效果。

demo

然而,我们最关心的还是 Module federation 的的实现方式,才能决定它是不是真的适合。

在此之前,还是需要向大家介绍一下这个 demo 做的事情

app1
---index.js 入口文件
---bootstrap.js 启动文件
---App.js react组件
 
app2
---index.js 入口文件
---bootstrap.js 启动文件
---App.js react组件
---Button.js react组件

这是文件结构,其实你可以看成是两个独立应用 app1 和 app2,那他们之前有什么爱恨情仇呢?

/** app1 **/
/**
* index.js
**/
import('./bootstrap');
 
/**
* bootstrap.js
**/
import('./bootstrap');
import App from "./App";
import React from "react";
import ReactDOM from "react-dom";
 
ReactDOM.render(<App />, document.getElementById("root"));
 
/**
* App.js
**/
import('./bootstrap');
import React from "react";
 
import RemoteButton from 'app2/Button';
 
const App = () => (
  <div>
    <h1>Basic Host-Remote</h1>
    <h2>App 1</h2>
    <React.Suspense fallback="Loading Button">
      <RemoteButton />
    </React.Suspense>
  </div>
);
 
export default App;

我这里只贴了 app1 的 js 代码,app2 的代码你不需要关心。代码没有什么特殊的,只有一点,app1 的 App.js 里面:

import RemoteButton from 'app2/Button';

也就是关键来了,跨应用复用代码来了!app1 的代码用了 app2 的代码,但是这个代码最终长什么样?是如何引入 app2 的代码的?

配置

先看我们的 webpack 需要如何配置:

/**
 * app1/webpack.js
 */
{
    plugins: [
        new ModuleFederationPlugin({
            name: "app1",
            library: {
                type: "var",
                name: "app1"
            },
            remotes: {
                app2: "app2"
            },
            shared: ["react", "react-dom"]
        })
    ]
}

这个其实就是 Module federation 的配置了,大概能看到想表达的意思:

1、用了远程模块 app2,它叫 app2

2、用了共享模块,它叫 shared

remotes 和 shared 还是有一点区别的,我们先来看效果。

生成的 html 文件:

<html>
  <head>
    <script src="app2/remoteEntry.js"></script>
  </head>
  <body>
    <div id="root"></div>
  <script src="app1/app1.js"></script><script src="app1/main.js"></script></body>
</html>

ps:这里的 js 路径有修改,这个是可以配置的,这里只是表明从哪里加载了哪些 js 文件

app1 打包生成的文件:

app1/index.html
app1/app1.js
app1/main.js
app1/react.js
app1/react-dom.js
app1/src_bootstrap.js

ps: app2 你也需要打包,只是我没有贴 app2 的代码以及配置文件,后面需要的时候会再贴出来的

最终页面表现以及加载的 js:

从上往下加载的 js 时序其实是很有讲究的,后面将会是解密的关键:

app2/remoteEntry.js
app1/app1.js
app1/main.js
app1/react.js
app1/react-dom.js
app2/src_button_js.js
app1/src_bootstrap.js

这里最需要关注的其实还是每个文件从哪里加载,在不去分析原理之前,看文件加载我们至少有这些结论:

1、remotes 的代码自己不打包,类似 external,例如 app2/button 就是加载 app2 打包的代码

2、shared 的代码自己是有打包的

原理

在讲解原理之前,我还是放出之前的一张图,因为这是 webpack 的文件模块核心,即使升级 5,也没有发生变化

app1 和 app2 还是有自己的 modules,所以实现的关键就是两个 modules 如何同步,或者说如何注入,那我们就来看看 Module federation 如何实现的。

import 变成了什么

// import源码
import RemoteButton from 'app2/Button';
 
// import打包代码 在app1/src_bootstrap.js里面
/* harmony import */
var app2_Button__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__( /*! app2/Button */ "?ad8d");
/* harmony import */
var app2_Button__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/ __webpack_require__.n(app2_Button__WEBPACK_IMPORTED_MODULE_1__);

从这里来看,我们好像看不出什么,因为还是正常的 webpack_require,难道说它真的像我们之前所设想的那样,重写了 webpack_require 吗?

遗憾的是,从源码看这个函数是没有什么变化的,所以核心点不是这里。

但是你注意看加载的 js 顺序:

app2/remoteEntry.js
app1/app1.js
app1/main.js
app1/react.js
app1/react-dom.js
app2/src_button_js.js // app2的button竟然先加载了,比我们的自己启动文件还前面
app1/src_bootstrap.js

回想上一节我们自己的分析

所以我们需要将依赖前置,也就是说在加载 SL 模块后,它知道自己依赖哪些共享模块,然后去检测是否存在,不存在依次去加载,所以依赖就位后才开始执行自己。

所以它是不是通过依赖前置来解决的呢?

main.js 文件内容

因为 html 里面和 app1 相关的只有两个文件:app1/app1.js 以及 app1/main.js

那我们看看 main.js 到底写了啥

(() => { // webpackBootstrap
    var __webpack_modules__ = ({})
 
    var __webpack_module_cache__ = {};
 
    function __webpack_require__(moduleId) {
 
        if (__webpack_module_cache__[moduleId]) {
            return __webpack_module_cache__[moduleId].exports;
        }
        var module = __webpack_module_cache__[moduleId] = {
            exports: {}
        };
        __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
        return module.exports;
    }
    __webpack_require__.m = __webpack_modules__;
 
    __webpack_require__("./src/index.js");
})()

可以看到区别不大,只是把之前的 modules 换成了 webpack_modules,然后把这个 modules 的初始化由参数改成了内部声明变量。

那我们来看看 webpack_modules 内部的实现:

var __webpack_modules__ = ({
 
    "./src/index.js": ((__unused_webpack_module, __unused_webpack_exports, __webpack_require__) => {
        __webpack_require__.e( /*! import() */ "src_bootstrap_js").then(__webpack_require__.bind(__webpack_require__, /*! ./bootstrap */ "./src/bootstrap.js"));
    }),
 
    "container-reference/app2": ((module) => {
        "use strict";
        module.exports = app2;
    }),
 
    "?8bfd": ((module, __unused_webpack_exports, __webpack_require__) => {
        "use strict";
        var external = __webpack_require__("container-reference/app2");
        module.exports = external;
    })
});

从代码看起来就三个 module:

1、./src/index.js 这个看起来就是我们的app1/index.js,里面去动态加载bootstrap.js对应的chunk src_bootstrap_js

2、container-reference/app2 直接返回一个全局的app2,这里感觉和我们的app2有关系

3、?8bfd 这个字符串是我们上面提到的app2/button对应的文件引用id

那在加载 src_bootstrap.js 之前加载的那些 react 文件还有 app2/button 文件都是谁做的呢?通过 debug,我们发现秘密就在 webpack_require__.e("src_bootstrap_js") 这句话

在第二节解析 webpack 加载的时候,我们得知了:

实际上 webpck_require.e 就是去加载 chunk 的 js 文件 0.bundle.js,等到加载回来后它认为 bundle.js 里面的 modules 就一定会有了 0.bundle.js 包含的那些 modules

也就是说原来的 webpack_require__.e 平淡无奇,就是加载一个 script,以致于我们都不想去贴出它的代码,但是这次升级后一切变的不一样了,它成了关键中的关键!

webpack_require__.e 做了什么

__webpack_require__.e = (chunkId) => {
    return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
        __webpack_require__.f[key](chunkId, promises);
        return promises;
    }, []));
};

看代码,的确发生了变化,现在底层是去调用 webpack_require.f 上面的函数了,等到所有函数都执行完了,才执行 promise 的 then

那问题的核心又变成了 webpack_require.f 上面有哪些函数了,最后发现有三个函数:

一:overridables

/* webpack/runtime/overridables */
__webpack_require__.O = {};
var chunkMapping = {
    "src_bootstrap_js": [
        "?a75e",
        "?6365"
    ]
};
var idToNameMapping = {
    "?a75e": "react-dom",
    "?6365": "react"
};
var fallbackMapping = {
    "?a75e": () => {
        return __webpack_require__.e("vendors-node_modules_react-dom_index_js").then(() => () => __webpack_require__("./node_modules/react-dom/index.js"))
    },
    "?6365": () => {
        return __webpack_require__.e("vendors-node_modules_react_index_js").then(() => () => __webpack_require__("./node_modules/react/index.js"))
    }
};
__webpack_require__.f.overridables = (chunkId, promises) => {}

二:remotes

/* webpack/runtime/remotes loading */
var chunkMapping = {
    "src_bootstrap_js": [
        "?ad8d"
    ]
};
var idToExternalAndNameMapping = {
    "?ad8d": [
        "?8bfd",
        "Button"
    ]
};
__webpack_require__.f.remotes = (chunkId, promises) => {}

三:jsonp

/* webpack/runtime/jsonp chunk loading */
var installedChunks = {
    "main": 0
};
 
__webpack_require__.f.j = (chunkId, promises) => {}

这三个函数我把核心部分节选出来了,其实注释也写得比较清楚了,我还是解释一下:

1、overridables 可覆盖的,看代码你应该已经知道和 shared 配置有关

2、remotes 远程的,看代码非常明显是和 remotes 配置相关

3、jsonp 这个就是原有的加载 chunk 函数,对应的是以前的懒加载或者公共代码提取

加载流程

知道了核心在 webpack_require.e 以及内部实现后,不知道你脑子里是不是对整个加载流程有了一定的思路,如果没有,容我来给你解析一下

1、先加载 src_main.js,这个没什么好说的,注入在 html 里面的

2、src_main.js 里面执行 webpack_require(“./src/index.js”)

3、src/index.js 这个 module 的逻辑很简单,就是动态加载 src_bootstrap_js 这个 chunk

4、动态加载 src_bootstrap_js 这个 chunk 时,经过 overridables,发现这个 chunk 依赖了 react、react-dom,那就看是否已经加载,没有加载就去加载对应的 js 文件,地址也告诉你了

5、动态加载 src_bootstrap_js 这个 chunk 时,经过 remotes,发现这个 chunk 依赖了?ad8d,那就去加载这个 js

6、动态加载 src_bootstrap_js 这个 chunk 时,经过 jsonp,就正常加载就好了

7、所有依赖以及 chunk 都加载完成了,就去执行 then 逻辑:webpack_require src_bootstrap_js 里面的 module:./src/bootstrap.js

到此就一切都正常启动了,其实就是我们之前提到的依赖前置,先去分析,然后生成配置文件,再去加载。

看起来一切都很美好,但其实还是有一个关键信息没有解决!

如何知道 app2 的存在

上面的第 4 步加载 react 的时候,因为我们自己实际上也打包了 react 文件,所以当没有加载的时候,我们可以去加载一份,也知道地址

但是第五步的时候,当页面从来没有加载过 app2/Button 的时候,我们去什么地址加载什么文件呢?

这个时候就要用到前面我们提到的 main.js 里面的 webpack_modules 了

var __webpack_modules__ = ({
 
    "container-reference/app2": 
        ((module) => {
            "use strict";
            module.exports = app2;
        }),
 
    "?8bfd":
        ((module, __unused_webpack_exports, __webpack_require__) => {
        "use strict";
            var external = __webpack_require__("container-reference/app2");
            module.exports = external;
        })
});

这里面有三个 module,我们还有 ?8bfd、container-reference/app2 没有用到,我们再看一下 remotes 的实现

/* webpack/runtime/remotes loading */
var chunkMapping = {
    "src_bootstrap_js": [
        "?ad8d"
    ]
};
var idToExternalAndNameMapping = {
    "?ad8d": [
        "?8bfd",
        "Button"
    ]
};
__webpack_require__.f.remotes = (chunkId, promises) => {
    if (__webpack_require__.o(chunkMapping, chunkId)) {
        chunkMapping[chunkId].forEach((id) => {
            if (__webpack_modules__[id]) return;
            var data = idToExternalAndNameMapping[id];
            promises.push(Promise.resolve(__webpack_require__(data[0]).get(data[1])).then((factory) => {
                __webpack_modules__[id] = (module) => {
                    module.exports = factory();
                }
            }))
        });
    }
}

当我们加载 src_bootstrap_js 这个 chunk 时,经过 remotes,发现这个 chunk 依赖了?ad8d,那在运行时的时候:

id = "?8bfd"
data = [
   "?8bfd",
   "Button"
]
// 源码
__webpack_require__(data[0]).get(data[1])
// 运行时
__webpack_require__('?8bfd').get("Button")

结合 main.js 的 module ?8bfd 的代码,那最终就是 app2.get(“Button”)

这不就是个全局变量吗?看起来有些蹊跷啊!

再看 app2/remoteEntry.js

我们好像一直忽略了这个文件,它是第一个加载的,必然有它的作用,带着对全局 app2 有什么蹊跷的疑问,我们去看了这个文件,果然发现了玄机!

var app2;
app2 =
    (() => {
        "use strict";
        var __webpack_modules__ = ({
            "?8619": ((__unused_webpack_module, exports, __webpack_require__) => {
                var moduleMap = {
                    "Button": () => {
                        return __webpack_require__.e("src_Button_js").then(() => () => __webpack_require__( /*! ./src/Button */ "./src/Button.js"));
                    }
                };
                var get = (module) => {
                    return (
                        __webpack_require__.o(moduleMap, module) ?
                        moduleMap[module]() :
                        Promise.resolve().then(() => {
                            throw new Error("Module " + module + " does not exist in container.");
                        })
                    );
                };
                var override = (override) => {
                    Object.assign(__webpack_require__.O, override);
                }
 
                __webpack_require__.d(exports, {
                    get: () => get,
                    override: () => override
                });
            })
        });
        return __webpack_require__("?8619");
    })()

如果你细心看,就会发现,这个文件定义了全局的 app2 变量,然后提供了一个 get 函数,里面实际上就是去加载具体的模块

所以 app2.get(“Button”) 在这里就变成了 app2 内部定义的 get 函数,随后执行自己的 webpack_require

是不是有种焕然大悟的感觉!

原来它是这样在两个独立打包的应用之间,通过全局变量去建立了一座彩虹桥!

当然,app2/remoteEntry.js 是由 app2 根据配置打包出来的,里面实际上就是根据配置文件的导出模块,生成对应的内部 modules

你可能忽略的 bootstrap.js

细心的读者如果注意的话,会发现,在入口文件 index.js 和真正的文件 app.js 之间多了一个 bootstrap.js,而且里面内容就是异步加载 app.js

那这个文件是不是多余的,笔者试了一下,直接把入口换成 app.js 或者这里换成同步加载,整个应用就跑不起来了

其实从原理上分析后也是可以理解的:

因为依赖需要前置,并且等依赖加载完成后才能执行自己的入口文件,如果不把入口变成一个异步的 chunk,那如何去实现这样的依赖前置呢?毕竟实现依赖前置加载的核心是 webpack_require.e

总结

至此,Module federation 如何实现 shared 和 remotes 两个配置我相信大家都有了理解了,其实还是逃不过在第二节末尾说的问题:

1、如何解决依赖问题,这里的实现方式是重写了加载 chunk 的 webpack_require.e,从而前置加载依赖

2、如何解决 modules 的共享问题,这里是使用全局变量来 hook

整体看起来实现还是挺巧妙的,不是 webpack 核心开发者,估计不能想到这样解决,实际上改动也是蛮大的。

这种实现方式的优缺点其实也明显:

优点:做到代码的运行时加载,而且 shared 代码无需自己手动打包

缺点:对于其他应用的依赖,实际上是强依赖的,也就是说 app2 有没有按照接口实现,你是不知道的

至于网上一些其他文章所说的 app2 的包必须在代码里面异步使用,这个你看前面的 demo 以及知道原理后也知道,根本没有这样的限制!

常见问题

webapack和vite用联邦模块能互相分享各自的组件吗?

不能。

vite能引用webpack分享的组件,但webpack不能引用vite分享的组件,vite之间能互相引用。

原因:vite打包出来的chunk,浏览器请求完无法直接解析,而联邦模块说到底就是通过浏览器请求这份chunk,然后解析。

解决办法:​

  1. 使用webpack打包vite项目。
  2. 使用插件,浏览器请求完这个chunk后,通过插件去解析。

在使用 Module Federation 时,Host、Remote 必须同时配置 shared,且一致。

是的

module federation 是否可以做到与技术栈无关?

可以。假设两个应用, host 应用使用 react 技术栈, remote 应用使用 vue 技术栈,host 应用在使用 remote 应用提供的组件时,不能直接使用,需要额外执行 vue.mount(‘#xxx’) 方法,将 remote 组件挂载的指定位置。

共享依赖的版本控制

module federation 在初始化 shareScope 时,会比较 host 应用和 remote 应用之间共享依赖的版本,将 shareScope 中共享依赖的版本更新为较高版本。在加载共享依赖时,如果发现实际需要的版本和 shareScope 中共享依赖的版本不一致时,会根据 share 配置项的不同做相应处理:

  • 如果配置 singleton 为 ture,实际使用 shareScope 中的共享依赖,控制台会打印版本不一致警告;

  • 如果配置 singleton 为 ture,且 strictVersion 为 ture,即需要保证版本必须一致,会抛出异常;

  • 如果配置 singleton 为 false,那么应用不会使用 shareScope 中的共享依赖,而是加载应用自己的依赖;

综上,如果 host 应用和 remote 应用共享依赖的版本可以兼容,可将 singleton 配置为 ture;如果共享依赖版本不兼容,需要将 singleton 配置为 false。

多个应用(超过 2 个) 是否可共用一个 shareScope ?

可以,使用 module federation 功能以后,所有建立联系的应用,共用一个 shareScope。

总结

模块联邦存在问题

对于文档项目来说,实际上更需要的是目前的 shared 能力,对一些常见的公共依赖库配置 shared 后就可以解决了,但是也只是理想上的,实际上还是会遇到一些可见的问题,例如:

1、不同的版本生成的公共库 id 不同,还是会导致重复加载

2、app2 的 remotEntry 更新后如何获取最新地址

3、如何获知其他应用导出接口

4、CSS 样式污染问题,建议避免在 component 中使用全局样式

5、模块联邦并未提供沙箱能力,可能会导致 JS 变量污染

实践建议

在我们的项目中, 模块联邦是与 external-remotes-plugin 一同使用的。

1、在webpack配置文件中, 指定了每个模块对应一个 window 上的一个全局变量名,这些变量名会以特殊前缀开头,避免被其他变量或意外影响。

2、在配置中心,维护了每个模块对应的最新的remoteEntry CDN地址,这个文件名包含了打包的hash值, 方便一眼看出加载的版本是否符合预期。

3、在主应用BFF层,返回主应用HTML时,会从配置中心读取模块联邦配置,并注入到html中, 这样remoteEntry就伴随着html一同下发,主应用启动时就能加载所有远程模块。

4、发布子模块更新或回滚时,只需要发布CDN后在配置中心修改模块对应的CDN地址,页面刷新之后就能指向最新的子模块版本。

5、remoteEntry和所有子应用的文件,由于文件名中包含当此打包的hash值,所以统一设置有效期很长的强缓存头,避免刷新后重新加载或是发起协商缓存。

6、联调时,假如配置中心不支持联调环境独立配置,还可以使用代理劫持remoteEntry。通过匹配CDN地址中的模块名,将remoteEntry劫持到联调版本的remoteEntry地址,达到访问特定子模块版本的目的。

最后的话

鉴于 MF 的能力,我们可以完全实现一个去中心化的应用:每个应用是单独部署在各自的服务器,每个应用都可以引用其他应用,也能被其他应用所引用,即每个应用可以充当 Host 的角色,亦可以作为 Remote 出现,无中心应用的概念。