如何使用webpack5的模块联邦特性落地微前端

如何使用webpack5的模块联邦特性落地微前端_第1张图片

如何使用webpack5的模块联邦特性落地微前端_第2张图片

微前端现有的落地方案可以分为三类,自组织模式、基座模式以及模块加载模式。

与基座模式相比,模块加载模式没有中心容器,这就意味着,我们可以将任意一个微应用当作项目入口,整个项目的微应用与微应用之间相互串联,打破项目的固定加载模式,彻底释放项目的灵活机动性,这样的模式,也被称为去中心化模式。

其实这个方案在微前端的架构理念中早已提及,但直到 2020 年 10 月 Webpack 5 正式发布之后才被真正落地应用。因为 Webpack 5 带来了一个全新特性:Module Federation,这是我们使用模块加载模式实现微前端架构的核心特性。

Module Federation 是什么

在官方文档中,关于 Module Federation 的动机中,有这样一段介绍:

多个独立的构建可以组成一个应用程序,这些独立的构建之间不应该存在依赖关系,因此可以单独开发和部署它们。

这通常被称作微前端,但并不仅限于此。

Module Federation 中文直译为“模块联邦”,为了方便我们这里简称为 mf。如果你去 Webpack 官方文档中查看,最多可以从前面的“动机”中看到模糊的解释,而对于“模块联邦”准确的定义,其实并没有给出。

但是,根据 “动机”的描述,不难看出,mf 实际想要做的事,便是把多个无相互依赖、单独部署的应用合并为一个。通俗点讲,mf 提供了能在当前应用中加载其他应用的能力。所以,在 mf 中,如果一个模块想要载入其他模块,就需要一个“引入”的动作;同样如果想让其他模块使用,就需要一个“导出”的动作。

对此,可以引出下面两个概念。

  • expose:导出应用,被其他应用导入。

  • remote:引入其他应用。

一个模块既可以导出给其他模块使用,又可以导入一个其他模块,这与“基座模式”完全不同。要知道,无论是 single-spa 还是 qiankun,加载不同模块,都需要有一个容器中心来承载;而在 mf 中,没有且也不需要容器中心。

鉴于 mf 的能力,我们完全可以实现一个去中心化的应用部署群:多个微应用单独部署在各自的服务器中,而每个微应用都可以引用其他应用,也能被其他应用导入使用,即每个应用都可以导出又导入,也就没有了容器中心的概念。

Module Federation 如何使用

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 数组中,实例化时,在传入的对象中,设置不同的属性参数。为了方便你记忆,我把前面用到的不同参数的含义整理在下面这张表格中:

如何使用webpack5的模块联邦特性落地微前端_第3张图片

其中,remotes 代表导入远程模块,exposes 表示导出了当前模块,这样就完成了模块的导入和导出,这就是前面介绍的去中心化的体现——一个模块既可以导出又可以导入,不需要通过中心基座在各个微应用之间连接,任何一个微应用都可以当作一个中心,也都可以被其他模块导入。

通过以上简单的应用,我们对 mf 有了一个初步的认识,而上面的配置在 Webpack 打包时会执行怎样的操作呢?打包后的结果代码,是如何加载远程模块的?自己的模块又是如何导出提供给其他应用导入的呢?这就需要我们阅读打包之后的代码去一探究竟了。

Module Federation 的构建解析

我们分别在 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时,先判断是否有可用的共享依赖。若有,则加载这部分依赖;如果没有,则加载自身依赖。

Module Federation 的应用场景

英雄也怕无用武之地,让我们看看 mf 的应用场景有哪些。

最明显的就是微前端的应用,通过 remote 和expose 可以将一个应用作为微应用导入导出,微应用之间相互独立,也可以互相导入导出,没有中心基座的限制。而由 YY 业务中台 Web 前端组团队自主研发的 EMP 微前端方案就是基于 mf 的能力而实现的。

再就是资源复用,以减少编译体积,可以将多个应用的通用组件进行单独部署,通过 mf 的功能在运行时引入到其他项目中,这样组件代码就不会编译到项目中,同时也能满足多个项目同时使用的需求,一举两得。

总结

我们通过对 Module Federation 特性的解读,简单了解了 Webpack 通过插件的方式导入导出一个模块,实现一个微前端架构应用。从一定程度上来说,Module Federation 是目前去中心化模式唯一落地的技术,通过简单的配置就能够很轻松地完成一个微前端架构的基本模型。而去中心化的方案,让我们脱离了固定的中心基座,极大地增加了项目的灵活性。

但是,如果换一个角度想,没有了统一管理的基座中心,每一个微应用的管理维护就显得极其重要了,这也对我们开发者团队提出了挑战。此外,随着项目数量和规模越来越大,一个项目下的微应用必然增加,如果管理不到位,极有可能带来致命的混乱。

- EOF -

如何使用webpack5的模块联邦特性落地微前端_第4张图片

点赞和在看就是最大的支持❤️

你可能感兴趣的:(web前端,webpack,前端)