Webpack Module Federation

1,260 阅读8分钟

它是什么

Module Federation allows a JavaScript application to dynamically load code from another application and  in the process, share dependencies.

Module Federation [ˌfedəˈreɪʃn] 使 一个JavaScript 应用在运行过程中可以动态加载另一个应用的代码,并支持共享依赖。

基本配置

既然要共享依赖,那么就需要知道依赖关系。Webpack Module Federation借助ModuleFederationPlugin进行依赖关系的配置,具体配置方式如下:

被依赖方
// 引入插件
const { ModuleFederationPlugin } = require("webpack").container;

// 插件配置
new ModuleFederationPlugin({
      name: "app2", // 必传,且唯一,作为被依赖的key标志,依赖方使用方式 ${name}/${expose}
      library: { type: "var", name: "app2" },       // library用于声明一个挂载的变量名(全局),其中这里的 name 为作为 umd 的 name
      filename: "remoteEntry.js",       // 构建后被依赖部分的入口文件名称
      exposes: {        // exposes暴露对外提供的modules模块
        Button: "./src/Button",
      },
      shared: ["react", "react-dom"],       // 声明共享的第三方依赖,声明后,依赖方使用时,优先使用依赖方自己的,如果没有使用被依赖方的
}),
依赖方
// webpack module federation配置
new ModuleFederationPlugin({
      name: "app1",
      library: { type: "var", name: "app1" },
      remotes: {            // 依赖方声明需要依赖的资源,对应被依赖方的key
        app2: "app2",
      },
      shared: ["react", "react-dom"]
})

// 引用被依赖方暴露的资源文件
<script src="http://localhost:3002/remoteEntry.js"></script>

// 使用方式,通过import('远程资源包名称/模块名')的方式直接引入。
const RemoteButton = React.lazy(() => import("app2/Button"));

一个应用既可以是依赖方,也可以是被依赖方,还可以同时作为依赖方和被依赖方。

它做了些什么

讲module federation打包做了什么之前,先简单回顾下webpack的打包原理,便于理解module federation实现。

webpack的打包

核心概念
  • entry: 入口文件

  • module:模块,webpack里一切皆模块,一个模块对应一个文件

  • chunk:代码块,对应多个module,每一个打包结果都是一个chunk,每个chunk包含多个module。

  • loader:模块转换器,用于模块内容的转换。

  • plugin:插件,在构建流程中监听特定的事件来做一些处理。

和我们这次了解关系比较大的是module和chunk,chunk数量根据入口文件决定,动态加载的文件和提取的公共包也会生产新的chunk。

从小demo开始看打包代码

demo来自webpack module federation官方最简单的示例,我们先来看不使用module federation的情况下的打包结果。

demo很简单,文件列表大致如下:

// APP1
APP.js
Index.js

// APP2
Index.js
APP.js
Button.js
Test.js

对于没有引入module federation插件的情况下,APP1和APP2的打包没有区别,我们主要来看下APP2的打包。

文件关系很简单,入口文件是Index.js,内部import App.js,应用核心实现都在App.js。App.js内部引入Button.js和Test.js(动态引入)。

那么最终生成的文件为两个chunk,分别是:

  1. index.js、app.js、button.js组成的main.js
  2. test.js组成的src_test_js

来看源码,整个main.js打包后代码基本是这样:

image.png

可以看到,import 变成了 webpack_require,基本上所有的文件加载,核心都在__webpack_require__。

__webpack_require__实现

既然找到了核心,那么我们来看下__webpack_require__它是怎么实现的。

大致流程就是先看文件是否已经加载,没有加载就去加载,加载了就读取已经加载的数据,避免重复加载。

// main.js
/************************************************************************/
/******/    // The module cache,模块加载缓存
/******/    var __webpack_module_cache__ = {};
/******/    
/******/    // The require function
/******/    function __webpack_require__(moduleId) {
/******/        // Check if module is in cache,先检查模块是否已加载,如果加载了直接返回已加载内容
/******/        if(__webpack_module_cache__[moduleId]) {
/******/            return __webpack_module_cache__[moduleId].exports;
/******/        }
/******/        // Create a new module (and put it into the cache),如果是第一次加载,那执行文件加载流程
/******/        var module = __webpack_module_cache__[moduleId] = {
/******/            // no module.id needed
/******/            // no module.loaded needed
/******/            exports: {}
/******/        };
/******/    
/******/        // Execute the module function,去__webpack_modules__中查找对应的module,并加载
/******/        __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/    
/******/        // Return the exports of the module
/******/        return module.exports;
/******/    }
/******/    
/******/    // expose the modules object (__webpack_modules__)
/******/    __webpack_require__.m = __webpack_modules__;
/******/    
/************************************************************************/

查看上述源码,会发现所有的模块加载,来自于__webpack_modules__,查找一下main.js,发现文件最上方有列出所有的__webpack_modules__,包含所有的依赖。

image.png大致简化一下:

/// main.js
var __webpack_modules__ = ({
  "./src/App.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {}),
    "./src/Button.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {}),
  "./node_modules/ajax/lib/ajax.js": ...,
  "...": ...
})

查看"./src/App.js",能看到内部实现和main.js类似,其中有一段引入test.js不太一样。

// app.js
__webpack_require__.e(/*! import() */ "src_test_js").then(__webpack_require__.t.bind(__webpack_require__, /*! ./test.js */ "./src/test.js", 7));

实际上是套了一层__webpack_require__.e,用于加载src_test_js,然后执行完成后执行__webpack_require__。

那怎么把test.js里的modules放到main.js中呢?来看下test.js的打包结果。

// src_test_js
(window["webpackJsonp_basic_host_remote_app2"] = window["webpackJsonp_basic_host_remote_app2"] || []).push([["src_test_js"],{

/***/ "./src/test.js":
/*!*********************!*\
  !*** ./src/test.js ***!
  \*********************/
/*! unknown exports (runtime-defined) */
/*! exports [maybe provided (runtime-defined)] [maybe used (runtime-defined)] */
/*! runtime requirements:  */
/***/ (() => {

console.log('this is App2');

/***/ })

}]);

有一个全局变量webpackJsonp_basic_host_remote_app2,数组内部包含src_test_js,及其对应的实现。

再来看main.js中,对webpackJsonp_basic_host_remote_app2的处理。

image.png

可以看到,它对push函数进行了重新定义,test.js加载完成后,会执行callback,拿到所有的module放在main中。

module federation

简单了解完基础的打包模块加载,我们来看下module federation。

webpack配置加上ModuleFederationPlugin,实现App1内部import App2的Button并展示。

其实module federation的核心也就是动态加载文件,但是基本的webpack打包结果,动态加载的module是存在于内部的,不能被外部访问。

如果我们需要在应用A获取应用B内部的模块,那就需要把B内部的模块暴露出来,加载过程中添加到应用A中。

接下来我们从打包结果来看下怎么实现的。

App2文件增加了remoteEntry.js,button.js和一依赖的文件包。

image.png

首先看App1引入App2的部分,App.js。

image.png

可以看到和正常的动态加载打包结果没什么区别。

那我们再来看main.js,发现webpack_require.f多了remotes,overridables这两个函数。加载过程中核心实现也就在这两部分。

  • remotes:远程加载,和remotes配置有关。
// main.js(App1)
/******/        var installedModules = {}; // 缓存加载的module
/******/        var chunkMapping = {    // 依赖的chunk和其内部依赖关系,告诉我们需要的button来自webpack/container/remote/app2/Button
/******/            "webpack_container_remote_app2_Button": [
/******/                "webpack/container/remote/app2/Button"
/******/            ]
/******/        };
/******/        var idToExternalAndNameMapping = { // 需要模块的所有依赖
/******/            "webpack/container/remote/app2/Button": [
/******/                "webpack/container/remote-overrides/a46c3e",
/******/                "webpack/container/reference/app2",
/******/                "Button"
/******/            ]
/******/        };
/******/        __webpack_require__.f.remotes = (chunkId, promises) => {
/******/            if(__webpack_require__.o(chunkMapping, chunkId)) { // 检查当前需要加载的chunk是否在配置项中声明为remote远程资源,如果是,则push
/******/                chunkMapping[chunkId].forEach((id) => {
/******/                    if(__webpack_require__.o(installedModules, id)) return installedModules[id] && promises.push(installedModules[id]);
/******/                    var data = idToExternalAndNameMapping[id];  // 根据资源map映射,找到其他需要加载的模块
/******/                    var onError = (error) => {
/******/                        if(error && typeof error.message === "string") error.message += '\nwhile loading "' + data[2] + '" from ' + data[1];
/******/                        __webpack_modules__[id] = () => {
/******/                            throw error;
/******/                        }
/******/                        delete installedModules[id];
/******/                    };
/******/                    var onFactory = (factory) => {
/******/                        __webpack_modules__[id] = (module) => {
/******/                            module.exports = factory(); // 直接export当前加载的模块
/******/                        }
/******/                    };
/******/                    try { // 如果通过get方法能在另一个应用中找到对应modules,则异步加载远程资源使用,并缓存到当前__webpack_modules__对象上
/******/                        var promise = __webpack_require__(data[0])(__webpack_require__(data[1])).get(data[2]);
/******/                        if(promise && promise.then) {
/******/                            promises.push(installedModules[id] = promise.then(onFactory, onError));
/******/                        } else {
/******/                            onFactory(promise);
/******/                        }
/******/                    } catch(error) {
/******/                        onError(error);
/******/                    }
/******/                });
/******/            }
/******/        }
/******/    })();
  • overridables:覆盖加载,和shared有关。从源码也可以看到,shared模式会优先考虑当前已经拥有的资源,没有的话再去加载。
// main.js(App2)
/******/        __webpack_require__.f.overridables = (chunkId, promises) => {
/******/            if(__webpack_require__.o(chunkMapping, chunkId)) { // 判断当前加载的chunk是否在配置中声明为shard共享资源,如果能在__webpack_require__.o上找到,则使用当前资源,找不到则请求加载(
/******/                chunkMapping[chunkId].forEach((id) => {
/******/                    promises.push(__webpack_require__.o(installedModules, id) ? installedModules[id] : installedModules[id] = Promise.resolve((__webpack_require__.O[idToNameMapping[id]] || fallbackMapping[id])()).then((factory) => {
/******/                        installedModules[id] = 0;
/******/                        __webpack_modules__[id] = (module) => {
/******/                            module.exports = factory();
/******/                        }
/******/                    }))
/******/                });
/******/            }
/******/        }
/******/    })();

能看到当前App1依赖模块webpack/container/reference/app2,模块内容如下,直接了当的export了app2,

/***/ "webpack/container/reference/app2":
/*!***********************!*\
  !*** external "app2" ***!
  \***********************/
/*! unknown exports (runtime-defined) */
/*! exports [maybe provided (runtime-defined)] [maybe used (runtime-defined)] */
/*! runtime requirements: module */
/***/ ((module) => {

module.exports = app2;

/***/ }),

运行时大致简化一下就是:

__webpack_require__('webpack/container/remote-overrides/a46c3e')(__webpack_require__('webpack/container/reference/app2')).get('Button')

所以,最终就是app2.get('Button');那怎么知道app2下面有这些东西呢?核心就在我们一开始加载的remoteEntry.js,核心代码如下:

// remoteEntry.js
// 定义app2的变量
var app2;app2 =
/******/ (() => { // webpackBootstrap
/******/    "use strict";
/******/    var __webpack_modules__ = ({

/***/ "webpack/container/entry/app2":
/*!***********************!*\
  !*** container entry ***!
  \***********************/
/*! unknown exports (runtime-defined) */
/*! exports [maybe provided (runtime-defined)] [maybe used (runtime-defined)] */
/*! runtime requirements: __webpack_require__.d, __webpack_require__.o, __webpack_exports__, __webpack_require__.e, __webpack_require__, __webpack_require__.* */
/***/ ((__unused_webpack_module, exports, __webpack_require__) => {

  // 提供模块关系
var moduleMap = {
    "Button": () => {
        return Promise.all([__webpack_require__.e("vendors-node_modules_ajax_lib_ajax_js"), __webpack_require__.e("src_Button_js")]).then(() => () => __webpack_require__(/*! ./src/Button */ "./src/Button.js"));
    }
};
  // 提供get函数,用来加载具体模块
var get = (module) => {
    return (
        __webpack_require__.o(moduleMap, module)
            ? moduleMap[module]()
            : Promise.resolve().then(() => {
                throw new Error('Module "' + module + '" does not exist in container.');
            })
    );
};
  // override用于shared的模块覆盖
var override = (override) => {
    Object.assign(__webpack_require__.O, override);
};

// This exports getters to disallow modifications
__webpack_require__.d(exports, {
    get: () => get,
    override: () => override
});

/***/ })

/******/    });

so,总结一下,核心主要是:

  • 重新webpack_require.e,引入webpack_require.f上的overrides、remotes两个方法,实现不同应用直接的依赖加载、共享。
  • 不同应用之间模块共享的本质是window全局变量,通过get和override方法来连接。

流程

App1的整个加载流程如下:

![](https://intranetproxy.alipay.com/skylark/lark/0/2020/png/244694/1597992503700-523a4fef-f114-4c0b-978d-14bfd1ab0375.png)

它能干什么

前端应用经常有重复的需求,我们来看下发展史(from某twitter上讲module federation的视频,地址查找ING):

Library时代->ESI(网页缓存)->MEF(微前端)->fedrated modules

image.png

有了Module federation后,可以很好的解决依赖和共享的问题,为应用之间相互依赖和加载提供了一种思路。但是依然有一些问题,比如:

  1. 包需要被export出来,才能使用,不是所有的包都支持umd,有一定成本。
  2. 不同的版本生成的公共库 id 不同,还是会导致重复加载
  3. 需要手动修改html里的引用,有维护成本。例如,app2 的 remotEntry 更新后如何获取最新地址
  4. 所有的应用在使用时,需要知道应用可被依赖的接口。

最后来看一个对比图,来自developer.aliyun.com/article/755…

image.png

参考