微前端现有的落地方案可以分为三类,自组织模式、基座模式以及模块加载模式。
与基座模式相比,模块加载模式没有中心容器,这就意味着,我们可以将任意一个微应用当作项目入口,整个项目的微应用与微应用之间相互串联,打破项目的固定加载模式,彻底释放项目的灵活机动性,这样的模式,也被称为去中心化模式。
其实这个方案在微前端的架构理念中早已提及,但直到 2020 年 10 月 Webpack 5 正式发布之后才被真正落地应用。因为 Webpack 5 带来了一个全新特性:Module Federation,这是我们使用模块加载模式实现微前端架构的核心特性。
在官方文档中,关于 Module Federation 的动机中,有这样一段介绍:
多个独立的构建可以组成一个应用程序,这些独立的构建之间不应该存在依赖关系,因此可以单独开发和部署它们。
这通常被称作微前端,但并不仅限于此。
Module Federation 中文直译为“模块联邦”,为了方便我们这里简称为 mf。如果你去 Webpack 官方文档中查看,最多可以从前面的“动机”中看到模糊的解释,而对于“模块联邦”准确的定义,其实并没有给出。
但是,根据 “动机”的描述,不难看出,mf 实际想要做的事,便是把多个无相互依赖、单独部署的应用合并为一个。通俗点讲,mf 提供了能在当前应用中加载其他应用的能力。所以,在 mf 中,如果一个模块想要载入其他模块,就需要一个“引入”的动作;同样如果想让其他模块使用,就需要一个“导出”的动作。
对此,可以引出下面两个概念。
expose:导出应用,被其他应用导入。
remote:引入其他应用。
一个模块既可以导出给其他模块使用,又可以导入一个其他模块,这与“基座模式”完全不同。要知道,无论是 single-spa 还是 qiankun,加载不同模块,都需要有一个容器中心来承载;而在 mf 中,没有且也不需要容器中心。
鉴于 mf 的能力,我们完全可以实现一个去中心化的应用部署群:多个微应用单独部署在各自的服务器中,而每个微应用都可以引用其他应用,也能被其他应用导入使用,即每个应用都可以导出又导入,也就没有了容器中心的概念。
Module Federation 是 Webpack 5 中新增的特性,所以,我们需要安装对应版本的 Webpack 及所需的工具。因为是多个应用之间互相导入导出,因此,我们这里需要创建至少两个应用,来展示相关配置操作,然后分别在两个应用下安装相关工具,这里我以 remoteApp 和 exposeApp 两个应用为例进行展示
Webpack 等工具的安装命令如下:
npm install webpack webpack-cli html-webpack-plugin webpack-dev-server -D
因为都是开发时所需工具,不需要被打包,因此,所有工具的安装都是开发依赖的安装方式。当然,在你看到这里的时候,版本很可能已经更新了,所以我把我当前安装后的 devDependencies 贴在下面,给你做个参考:
"devDependencies": {
"html-webpack-plugin": "^5.3.1",
"webpack": "^5.42.0",
"webpack-cli": "^4.7.2",
"webpack-dev-server": "^3.11.2"
}
接着我们就可以写配置文件了,基础配置内容如下
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
// entry 入口,output出口,module模块,plugins 插件 mode工作模式,devServer开发服务器
// mode 工作模式
mode: 'development', // production 、 development、none
// 入口
entry:'./src/index.js',
// 出口
output:{
filename:'./bundle.js',
path:path.resolve(__dirname,'dist')
},
// 模块
module:{
rules:[]
},
// 服务器
devServer:{
// remoteApp 3002 ; exposeApp 3001
port:3001,
open:true
},
// 插件
plugins:[
new HtmlWebpackPlugin({
template:'./src/index.html'
})
]
}
需要注意的是,为了防止开发服务器的端口冲突,我这里将两个应用端口分别设置了 3001 及3002 。
使用 mf,需要在配置文件中引入
ModuleFederationPlugin
,从名字就可以看出这是一个插件。因为是内置插件,所以也不需要单独安装,直接通过 require 关键字引入即可,这和一个普通插件的引入方式并没有区别。
因为导出应用中的模块是具体可选的,因此需要将导出的模块进行单独文件的打包。参数filename 就是指定具体导出的模块文件名;name 参数则代表当前导出的应用名称;exposes 参数是一个对象,在这里具体指定哪个模块需要导出,对象中的属性表示具体导出模块的名字,值则是指定具体导出模块的路径及文件名。
总体来说,我们导出的是一个微应用,而组成微应用的,便是当前应用下的某些模块,可以是一个,也可以指定多个。
使用 remoteApp 作为导入应用,具体配置如下:
plugins: [
// ……Code……
new Mfp({
// 导⼊模块
remotes: {
// 导⼊后给模块起个别名:“微应⽤名称@地址/导出的⽂件名”
appone: 'em@http://localhost:3001/em.js'
}
})
]
如果只作为导入其他应用的微前端配置,其实非常简单,只需要在 remotes 中具体配置要导入的应用即可,具体的导入规则是:
导入应用别名:' 微应⽤名称@应用远程地址/导出的⽂件名 '
导入的相关配置完成后,接下来如何在应用中使用导入的内容呢?我们在 remoteApp 的 src/index.js 中引入使用,具体代码如下
import('appone/exposesModule').then(res => {
const emBack = res.default('remote use')
console.log(emBack)
})
通过上面的代码你能够发现,引入微应用返回的是一个 Promise,最终会返回一个 "模块对象" 的结果,default 则是默认导出的内容结果。
我们前面说过,mf 是去中心化的,一个微应用,既可以导出也可以导入,如果你想将 remoteApp 作为一个微应用导出,那么,你可以在配置中继续添加导出的配置选项,比如:
new Mfp({
// 导⼊模块
remotes: {
// 导⼊后给模块起个别名:“微应⽤名称@地址/导出的⽂件名”
appone: 'em@http://localhost:3001/em.js'
},
// 作为微应用导出
filename: 'rm.js',
name: 'rm',
exposes: {
'./remoteAppModule': './src/remoteAppModule.js',
}
})
最后,不管是导入还是导出,绝大多数插件模块都需要实例化对象,我们这里的 ModuleFederationPlugin 也不例外,使用它,就是将这个实例对象,放入 plugins 数组中,实例化时,在传入的对象中,设置不同的属性参数。为了方便你记忆,我把前面用到的不同参数的含义整理在下面这张表格中:
其中,remotes 代表导入远程模块,exposes 表示导出了当前模块,这样就完成了模块的导入和导出,这就是前面介绍的去中心化的体现——一个模块既可以导出又可以导入,不需要通过中心基座在各个微应用之间连接,任何一个微应用都可以当作一个中心,也都可以被其他模块导入。
通过以上简单的应用,我们对 mf 有了一个初步的认识,而上面的配置在 Webpack 打包时会执行怎样的操作呢?打包后的结果代码,是如何加载远程模块的?自己的模块又是如何导出提供给其他应用导入的呢?这就需要我们阅读打包之后的代码去一探究竟了。
我们分别在 exposeApp 和 remoteApp 中, 找到打包后的 dist 目录。其中 exposeApp 中有 em.js 文件,就是我们指定的打包后的结果文件,具体如下所示:
eval("var moduleMap = {\n\t\"./exposesModule\": () => {\n\t\treturn __webpack_require__.e(\"src_exposesModule_js\").then(() => (() => ((__webpack_require__(/*! ./src/exposesModule.js */ \"./src/exposesModule.js\")))));\n\t}\n};\nvar get = (module, getScope) => {\n\t__webpack_require__.R = getScope;\n\tgetScope = (\n\t\t__webpack_require__.o(moduleMap, module)\n\t\t\t? moduleMap[module]()\n\t\t\t: Promise.resolve().then(() => {\n\t\t\t\tthrow new Error('Module \"' + module + '\" does not exist in container.');\n\t\t\t})\n\t);\n\t__webpack_require__.R = undefined;\n\treturn getScope;\n};\nvar init = (shareScope, initScope) => {\n\tif (!__webpack_require__.S) return;\n\tvar oldScope = __webpack_require__.S[\"default\"];\n\tvar name = \"default\"\n\tif(oldScope && oldScope !== shareScope) throw new Error(\"Container initialization failed as it has already been initialized with a different share scope\");\n\t__webpack_require__.S[name] = shareScope;\n\treturn __webpack_require__.I(name, initScope);\n};\n\n// This exports getters to disallow modifications\n__webpack_require__.d(exports, {\n\tget: () => (get),\n\tinit: () => (init)\n});\n\n//# sourceURL=webpack://exposeApp/container_entry?");
/***/ })
…………code…………
/******/ (() => {
/******/ // This function allow to reference async chunks
/******/ __webpack_require__.u = (chunkId) => {
/******/ // return url for filenames based on template
/******/ return "./" + chunkId + ".bundle.js";
/******/ };
/******/ })();
…………code………
var __webpack_exports__ = __webpack_require__("webpack/container/entry/em");
em = __webpack_exports__;
moduleMap:通过 exposes 生成的模块集合。
get:通过 chunkId 加载具体的应用代码片段。
而最关键的则是导入部分,在 remoteApp 中,我们找到打包后的 bundle.js 文件,其中具体代码如下:
eval("// import em from 'appone/exposesModule'\r\n\r\n__webpack_require__.e(/*! import() */ \"webpack_container_remote_appone_exposesModule\").then(__webpack_require__.t.bind(__webpack_require__, /*! appone/exposesModule */ \"webpack/container/remote/appone/exposesModule\", 23)).then(res => {\r\n console.log(res)\r\n const emBack = res.default('remote use')\r\n console.log(emBack)\r\n})\r\n\n\n//# sourceURL=webpack://remoteApp/./src/index.js?");
继续往下,我们能到 Promise 方式的代码加载,具体代码如下所示:
module.exports = new Promise((resolve, reject) => {
if(typeof em !== "undefined") return resolve();
__webpack_require__.l("http://localhost:3001/em.js", (event) => {
if(typeof em !== "undefined") return resolve();
var errorType = event && (event.type === 'load' ? 'missing' : event.type);
var realSrc = event && event.target && event.target.src;
__webpack_error__.message = 'Loading script failed.\n(' + errorType + ': ' + realSrc + ')';
__webpack_error__.name = 'ScriptExternalLoadError';
__webpack_error__.type = errorType;
__webpack_error__.request = realSrc;
reject(__webpack_error__);
}, "em");
}).then(() => (em));
其实 Promise 的作用就是加载了指定 URL 路径的 em.js,这就是我们导出的模块文件名。
继续往下看,我们能够看到 webpack_require.f.remotes 的处理:
/******/ __webpack_require__.f.remotes = (chunkId, promises) => {
/******/ if(__webpack_require__.o(chunkMapping, chunkId)) {
/******/ chunkMapping[chunkId].forEach((id) => {
/******/ var getScope = __webpack_require__.R;
/******/ 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_modules__[id] = () => {
/******/ throw error;
/******/ }
/******/ data.p = 0;
/******/ };
/******/ 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);
/******/ }
/******/ }
/******/ var onExternal = (external, _, first) => (external ? handleFunction(__webpack_require__.I, data[0], 0, external, onInitialized, first) : onError());
/******/ var onInitialized = (_, external, first) => (handleFunction(external.get, data[1], getScope, 0, onFactory, first));
/******/ var onFactory = (factory) => {
/******/ data.p = 1;
/******/ __webpack_modules__[id] = (module) => {
/******/ module.exports = factory();
/******/ }
/******/ };
/******/ handleFunction(__webpack_require__, data[2], 0, 0, onExternal, 1);
/******/ });
/******/ }
/******/ }
首先,mf 会让 Webpack 以filename作为文件名生成文件,并将具体代码打包到 xxx,bundle.js 文件中。
其次,文件中以 var 的形式暴露了一个名为name的全局变量,其中包含exposes中配置的内容。
最后,先通过remote的init方法将自身写入remote中,再通过get获取remote中expose的组件;而作为remote时,先判断是否有可用的共享依赖。若有,则加载这部分依赖;如果没有,则加载自身依赖。
英雄也怕无用武之地,让我们看看 mf 的应用场景有哪些。
最明显的就是微前端的应用,通过 remote 和expose 可以将一个应用作为微应用导入导出,微应用之间相互独立,也可以互相导入导出,没有中心基座的限制。而由 YY 业务中台 Web 前端组团队自主研发的 EMP 微前端方案就是基于 mf 的能力而实现的。
再就是资源复用,以减少编译体积,可以将多个应用的通用组件进行单独部署,通过 mf 的功能在运行时引入到其他项目中,这样组件代码就不会编译到项目中,同时也能满足多个项目同时使用的需求,一举两得。
我们通过对 Module Federation 特性的解读,简单了解了 Webpack 通过插件的方式导入导出一个模块,实现一个微前端架构应用。从一定程度上来说,Module Federation 是目前去中心化模式唯一落地的技术,通过简单的配置就能够很轻松地完成一个微前端架构的基本模型。而去中心化的方案,让我们脱离了固定的中心基座,极大地增加了项目的灵活性。
但是,如果换一个角度想,没有了统一管理的基座中心,每一个微应用的管理维护就显得极其重要了,这也对我们开发者团队提出了挑战。此外,随着项目数量和规模越来越大,一个项目下的微应用必然增加,如果管理不到位,极有可能带来致命的混乱。
- EOF -
点赞和在看就是最大的支持❤️