何为Module Federation

Module Federation是webpack 5 新提出的,一种远程加载模块的策略。

Module Federation 通常译作“模块联邦”,是 Webpack 5 新引入的一种远程模块动态加载、运行技术。Module Federation允许我们将原本单个巨大应用按我们理想的方式拆分成多个体积更小、职责更内聚的小应用形式,理想情况下各个应用能够实现独立部署、独立开发(不同应用甚至允许使用不同技术栈)、团队自治,从而降低系统与团队协作的复杂度 —— 没错,这正是所谓的微前端架构。

image.png
他的特点就是:

  • 导出若干模块,这些模块最终会被单独打成模块包;
  • 运行时基于 HTTP(S) 协议动态加载其它应用暴露的模块;

其实webpack衍生出Module Federation是顺理成章的非常自然的事情,webpack之前加载模块就是基于运行时加载本地的模块,而webpack Module Federation无非就是基于运行时,把加载模块的功能升级为支持加载远程模块了。相当于一个基于webpack v5的远程模块的导入/导出协议。

使用Module Federation

基本使用

这里有两个react项目做实验

  • 导出组件的项目remote ```javascript const { ModuleFederationPlugin } = require(“webpack”).container;

module.exports = { mode: “development”, entry: “./src/index.jsx”, output: { filename: “[name].js”, path: path.join(__dirname, “./dist”), // 指定产物的完整路径 publicPath: http://localhost:9009/dist/, }, resolve: { extensions: [“.js”, “.jsx”, “.ts”, “.tsx”], }, module: { rules: [ // … jsx ], }, plugins: [ new HtmlWebpackPlugin({ //}),

  1. new ModuleFederationPlugin({
  2. // 应用名称
  3. name: "appRemote",
  4. // 模块入口,可以理解为该应用的资源清单
  5. filename: `remoteEntry.js`,
  6. // 定义应用导出哪些模块
  7. exposes: {
  8. "./hello": "./src/comp/hello.jsx",
  9. },
  10. }),

],

devServer: { //}, };

  1. 可以看到导出组件的项目需要使用ModuleFederationPlugin,声明应用名称,入口文件(可以理解为该应用的资源清单),导出模块和文件的对应关系,这里的导出模块: ./hello(注意key前面有 ./)。
  2. - 导入组件的项目app
  3. ```javascript
  4. const path = require("path");
  5. const HtmlWebpackPlugin = require("html-webpack-plugin");
  6. const { ModuleFederationPlugin } = require("webpack").container;
  7. module.exports = {
  8. mode: "development",
  9. entry: "./src/index.jsx",
  10. output: {
  11. filename: "[name].js",
  12. path: path.join(__dirname, "./dist"),
  13. },
  14. resolve: {
  15. extensions: [".js", ".jsx", ".ts", ".tsx"],
  16. },
  17. module: {
  18. rules: [
  19. // ... jsx
  20. ],
  21. },
  22. plugins: [
  23. new HtmlWebpackPlugin({ /* ... */}),
  24. // 使用 ModuleFederationPlugin
  25. new ModuleFederationPlugin({
  26. // 使用 remotes 属性声明远程模块列表
  27. remotes: {
  28. // 地址需要指向导出方生成的应用入口文件
  29. RemoteApp: "appRemote@http://localhost:9009/dist/remoteEntry.js",
  30. },
  31. }),
  32. ],
  33. devServer: {
  34. // ...
  35. },
  36. };

使用方也需要配置ModuleFederationPlugin插件,同时指明远程地址,这个地址是这样的:“appRemote@http://localhost:9009/dist/remoteEntry.js”,@ 将这个字符串分成两部分,之前是暴露方应用名称,之后是请求路径,注意remoteEntry.js,是入口文件,需要在这里指定。
有了上面的配置支持,在app中可以这么使用远程模块(React v18):

  1. import React, { useEffect } from 'react'
  2. // 加载远程模块
  3. const importRemote = () => (
  4. import('RemoteApp/hello').catch(() => (import('./failed')))
  5. )
  6. // 远程组件
  7. const RemoteComp = React.lazy(() => {
  8. return new Promise((resolve) => {
  9. setTimeout (() => resolve(importRemote()), 3000)
  10. })
  11. })
  12. const Loading = () => <div style={{ margin: 'auto' }}>loading...remote component</div>
  13. export default function Index () {
  14. useEffect(() => {
  15. console.log('App mounted')
  16. }, [])
  17. return (
  18. <div>
  19. React app
  20. <React.Suspense fallback={(<Loading />)}>
  21. <RemoteComp />
  22. </React.Suspense>
  23. </div>
  24. )
  25. }

如上所示,import支持远程模块的异步加载:import(‘RemoteApp/hello’),即import(‘远程APP名称/暴露模块’),remote、app都启动起来就能正常运行了。
总体来说,使用起来还是比较简单的,配置不难理解,无非就是:
暴露方设置:

  • 应用名称:name: “appRemote”
  • 文件入口: filename: remoteEntry.js
  • 暴露模块和文件对应关系:”./hello”: “./src/comp/hello.jsx”

加载方设置:

  • 找谁/去哪:appRemote@http://localhost:9009/dist/remoteEntry.js
  • 用那个模块:import(‘RemoteApp/hello’)
    设置shared共享
    上面两个例子里面都用了react,所以按道理讲,react应该是可以被两个应用共享的模块。shared配置就是为了解决此问题:
    给两个APP都增加shared配置:
    1. {
    2. // ...
    3. plugins: [
    4. // ...
    5. new ModuleFederationPlugin({
    6. remotes: {
    7. RemoteApp: "appRemote@http://localhost:9009/dist/remoteEntry.js",
    8. },
    9. // 把react共享出来
    10. shared: ['react']
    11. }),
    12. ]
    13. }

添加shared之后,webpack会独立打包shared的依赖。
在看请求remote的hello组件,就发现比较清爽,而且检查network也没有看见从远端获取react依赖,表示hello确实用的是共享的依赖:

  1. "use strict";
  2. /*
  3. * ATTENTION: The "eval" devtool has been used (maybe by default in mode: "development").
  4. * This devtool is neither made for production nor for readable output files.
  5. * It uses "eval()" calls to create a separate source file in the browser devtools.
  6. * If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
  7. * or disable the default devtool with "devtool: false".
  8. * If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
  9. */
  10. (self["webpackChunkapp"] = self["webpackChunkapp"] || []).push([["src_comp_hello_jsx"], {
  11. /***/
  12. "./src/comp/hello.jsx": /*!****************************!*\
  13. !*** ./src/comp/hello.jsx ***!
  14. \****************************/
  15. /***/
  16. ((__unused_webpack_module,__webpack_exports__,__webpack_require__)=>{
  17. eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ \"default\": () => (__WEBPACK_DEFAULT_EXPORT__)\n/* harmony export */ });\n/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! react */ \"webpack/sharing/consume/default/react/react\");\n/* harmony import */ var react__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(react__WEBPACK_IMPORTED_MODULE_0__);\n\nconst style = {\n width: '50%',\n height: 40,\n backgroundColor: 'pink',\n lineHeight: '40px',\n textAlign: 'center'\n};\n/* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (() => {\n return /*#__PURE__*/react__WEBPACK_IMPORTED_MODULE_0___default().createElement(\"div\", {\n style: style\n }, \"Hi~\");\n});\n\n//# sourceURL=webpack://app/./src/comp/hello.jsx?");
  18. /***/
  19. }
  20. )
  21. }]);

其中react的依赖是被这样注入的:

  1. var react__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(
  2. /*! react */ \"webpack/sharing/consume/default/react/react\"
  3. );

不过,把react设置为共享依赖,应该会遇到这个问题:Uncaught Error: Shared module is not available for eager consumption
这是因为设置了shared的模块是独立的,所以,其的加载顺序无法保证,所以当主文件需要react的时候,react文件还没有加载好,这就出现问题了。
有两种解决办法:

  1. 主文件入口提出一个bootstrap,然后异步加载这个bootstrap

之前的index.jsx

  1. import React from 'react';
  2. import ReactDOM from 'react-dom';
  3. import App from './App';
  4. ReactDOM.render(<App />, document.getElementById('root'));

修改之后:

  1. import('./bootstrap')
  1. import React from 'react';
  2. import ReactDOM from 'react-dom';
  3. import App from './App';
  4. ReactDOM.render(<App />, document.getElementById('root'));

这是第一种解法

  1. 设置shared eager:参考这里:

这个方法我没有搞成功,先不纠结~

还有个问题,上面说了,需要把remote和app都设置shared,那么如果只设置一个呢,比如只设置了remote端的react为shared,会加载失败吗?
实践发现不会,会成功。但远程的hello组件是需要react依赖的,那么react是从哪获取的呢?查看network之后发现,这种情况下,hello组件还是独立的文件没错,但在加载hello之前,另有一个独立的react依赖文件从远程被下载下来了。
所以确实需要两端都设置shared才能真正的实现共享依赖的功能。

Module Federation运行时简析

不深究~做了哪些事情呢,简单总结下:

  1. Module Federation会让webpack以filename作为文件名生成文件;
  2. 文件中以var的形式暴露了一个名为name的全局变量,其中包含了exposes以及shared中配置的内容;
  3. 作为host时,先通过remote的init方法将自身shared写入remote中,再通过get获取remote中expose的组件;
  4. 而作为remote时,判断host中是否有可用的共享依赖,若有,则加载host的这部分依赖,若无,则加载自身依赖。

参考这里:

微前端和Module Federation

广义上可以把Module Federation理解成一种微前端的实现方式。但是其机制和原理和我们通常认为的微前端(国内比如qiankun、icestark)非常不同,所以有必要对比下:
微前端:

  • 必须预定义接口方法(mounted、unmount 等)来实现微应用的动态挂载和卸载等功能;
  • 共享某个子应用的模块给其它微应用使用,需要把该模块独立出去;
  • 有基座应用和子应用的概念,子应用切换通常由路由状态改变来触发的;

Module Federation:

  • 没有特定的生命周期和接口方法;
  • 不需要拆分应用,每个应用允许暴露多个接口,其它应用可以在动态远程加载该应用后,直接使用其接口;
  • 没有明确的主、子应用的概念,和路由没有任何关联;

不过我还有问题需要继续探究探究,现在可能无法给出答案:

  • Module Federation 的沙箱隔离问题,虽然webpack的模块都是函数作用域,但是对全局变量的操作仍然是相互影响的;那么使用起来是不是要学习qiankun那样用一个proxy代理window呢?

参考: