微前端与webpack 5 Module Federation

微前端目前的落地方案可分为:自组织模式、基座模式、模块加载模式。

image.png

与基座模式相比,模块加载模式没有中心容器(去中心化模式),这就意味着任何一个微应用都可以当作模块入口,整个项目的微应用与微应用之间相互串联。具体的代表库就是 qiankun vs EMP

实现模块加载模式需要依赖于 webpack5 的 Module Federation 功能。

Module Federation 是什么?

多个独立的构建可以组成一个应用程序。这些独立构建之间不应该存在依赖关系,因此可以单独开发和部署他们,通常被称为微前端,但是它的功能绝不仅于此!通俗点讲,Module Federation 提供了能在当前应用加载其他应用的能力。

所以,当前模块想要加载其他模块,就要有一个引入动作,同样,如果想让其他模块使用,就需要有一个导出动作。

因此,就引出webapck配置的两个概念:

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

remote:引入其他应用

这与基座模式完全不同,像single-spa和qiankun都是需要一个基座(中心容器)去加载其他子应用。而 Module Federation 任意一个模块都可以引用其他应用和也可以导出被其他应用使用,这就没有了容器中心的概念。

Module Federation 配置解析

使用Module Federation 需要引入内置插件ModuleFederationPlugin,exposes参数指定哪个模块需要导出,remotes中配置要导入的应用。

如下示例代码,以vue3为例:

comsumer

const ModuleFederationPlugin = require('webpack').container.ModuleFederationPlugin
module.exports = {
    plugins: [
         new ModuleFederationPlugin({
          // 唯一ID,当前微应用名称
          name: "comsumer",
          filename: "remoteEntry.js",
          // 导入模块
          remotes: {
          // 导入后给模块起个别名:“微应用名称@地址/导出的文件名”
            home: "home@http://localhost:3002/remoteEntry.js",
          },
          exposes: {},
          shared: ['vue']
        })
    ]
}
复制代码

home

const ModuleFederationPlugin = require('webpack').container.ModuleFederationPlugin
module.exports = {
    plugins: [
      new ModuleFederationPlugin({
          // 唯一ID,当前微应用名称
          name: "home",
          // 对外提供的打包后的文件名(引入时使用)
          filename: "remoteEntry.js",
          // 暴露的应用内具体模块
          exposes: {
          // 名称: 代码路径
            "./Content": "./src/components/Content",
            "./Button": "./src/components/Button",
          },
          shared: ['vue']
        })
    ],
     devServer: {
        port: 3002,
      },
}
复制代码

在微应用中使用导入内容

引用微应用返回的是一个Promise,最终会返回一个“模块对象”的结果,default则是默认导出的内容结果。comsumer应用加载home应用的代码如下:

import { createApp, defineAsyncComponent } from "vue";
import Layout from "./Layout.vue";
// 加载远程Content组件
const Content = defineAsyncComponent(() => import("home/Content"));
// 加载远程Buttom组件
const Button = defineAsyncComponent(() => import("home/Button"));

const app = createApp(Layout);

app.component("content-element", Content);
app.component("button-element", Button);

app.mount("#app");

复制代码

Module Federation的构建解析

webpack的mf配置在webpack打包时会执行什么样的操作?打包后的结果代码,是如何加载远程模块的?自己的模块又是如何导出提供给其他应用导入?

首先看comsumer应用导入home应用的代码,截取了部分代码,comsumer应用要import home应用的两个远程组件,首先会加载148模块,即remoteEntry.js

app.component("content-element", Content);
app.component("button-element", Button);
复制代码

main.js

var chunkMapping = {
  "186": [
    186
  ],
  "190": [
    190
  ]
};
var idToExternalAndNameMapping = {
  "186": [
    "default",
    "./Content",
    148
  ],
  "190": [
    "default",
    "./Button",
    148
  ]
};
__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);
            // 首先加载148模块,即remoteEntry.js
            handleFunction(__webpack_require__, data[2], 0, 0, onExternal, 1);
        });
    }
};
 148: ((module, __unused_webpack_exports, __webpack_require__) => {
    "use strict";
    var __webpack_error__ = new Error();
    module.exports = new Promise((resolve, reject) => {
        if(typeof home !== "undefined") return resolve();
        __webpack_require__.l("http://localhost:3002/remoteEntry.js", (event) => {}, "home");
    }).then(() => (home));
 })
复制代码

再看home模块remoteEntry.js代码,其中摘取部分代码:

var moduleMap = {
	"./Content": () => {
		return Promise.all([__webpack_require__.e("webpack_sharing_consume_default_vue_vue"), __webpack_require__.e("src_components_Content_vue-_56df0")]).then(() => (() => ((__webpack_require__(/*! ./src/components/Content */ "./src/components/Content.vue")))));
	},
	"./Button": () => {
		return Promise.all([__webpack_require__.e("webpack_sharing_consume_default_vue_vue"), __webpack_require__.e("src_components_Button_js-_e56a0")]).then(() => (() => ((__webpack_require__(/*! ./src/components/Button */ "./src/components/Button.js")))));
	}
};
复制代码

我们看moduleMap,返回对应组件前,先通过__webpack_require__.e加载了其对应的依赖,我们看__webpack_require__.e其实就是并行执行了__webpack_require__.f中的方法:

/* webpack/runtime/ensure chunk */
(() => {
	__webpack_require__.f = {};
	// This file contains only the entry chunk.
	// The chunk loading function for additional chunks
	__webpack_require__.e = (chunkId) => {	
        // __webpack_require__.f中的所有方法并行执行
            return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
                    __webpack_require__.f[key](chunkId, promises);
                    return promises;
            }, []));
	};
})();
复制代码

我们再看看__webpack_require__.f上有哪些函数,最后发现有三个:remotes、consumes、j

__webpack_require__.o也就是指代Object.prototype.hasOwnProperty

  • remotes:与remotes相关的加载,本例子中home模块没有remotes
  • consumes:与shared相关的加载,本例子中有vue模块
// no consumes in initial chunks
var chunkMapping = {
    webpack_sharing_consume_default_vue_vue: [
        "webpack/sharing/consume/default/vue/vue",
    ],
};
__webpack_require__.f.consumes = (chunkId, promises) => {
    if (__webpack_require__.o(chunkMapping, chunkId)) {
        chunkMapping[chunkId].forEach((id) => {
            if (__webpack_require__.o(installedModules, id))
                return promises.push(installedModules[id]);
            try {
                var promise = moduleToHandlerMapping[id]();
                if (promise.then) {
                    promises.push(
                        (installedModules[id] = promise.then(onFactory).catch(onError))
                    );
                } else onFactory(promise);
            } catch (e) {
                onError(e);
            }
        });
    }
};
复制代码
  • j:加载JSONP的chunk,如果已经加载过了,则不加载,还没有加载的则调用__webpack_require__.l函数用script标签加载。如下是简化过的代码:
var installedChunks = {
    home: 0,
};
__webpack_require__.f.j = (chunkId, promises) => {
    // JSONP chunk 加载
    var installedChunkData = __webpack_require__.o(installedChunks, chunkId)
            ? installedChunks[chunkId]
            : undefined;
    // 0 表示已经加载过了
    if (installedChunkData !== 0) {
        // Promise 表示正在加载
        if (installedChunkData) {
                promises.push(installedChunkData[2]);
        } else {
            // 加载chunk,但是排除了webpack_sharing_consume_default_vue_vue这个共享的包
            if ("webpack_sharing_consume_default_vue_vue" != chunkId) {
                __webpack_require__.l(url, loadingEnded, "chunk-" + chunkId, chunkId);
            } else installedChunks[chunkId] = 0;
        }
    }
};
复制代码

总结一下:

  1. 先加载 main.js,注入在 html 里面的
  2. main.js 里面,需要动态加载远程的ContentButton组件,则需要先加载remoteEntry.js
  3. remoteEntry.js中,通过__webpack_require__.e加载moduleMap里的构建
  4. 遍历执行了__webpack_require__.f中的函数,需要先加载构建依赖webpack_sharing_consume_default_vue_vue
  5. 加载完shard的依赖后,再加载ContentButton组件

我对于Module Federarion 的理解

目前官方文档给出的几个用例,我认为日后一定会成为前端发展的大势。module federation 可以应用于微前端,但却不止于此。

可以大胆想象,当我们可以

  • 独立部署一个单页面应用的每一页
  • 可以把一个庞大的组件库,拆分为一个个独立部署的组件,组件更新只要部署自己这一个组件就行了

想想就觉得很激动。

Module Federation 更优雅的解决了公共依赖加载共享的问题,这也是基座模式无法很好处理的地方。

还有很多Module Federation的demo用例,比如

  • 实现在vue3的项目中加载vue的组件
  • 实现ssr

大家可以在代码仓库中查看具体实现。

但是还有好多问题想吐槽

试了一下module federation的功能,就有各种报错,让人奔溃...不得不各种找解决方案,以下是我碰到的一些问题:

vue3+ vue cli + webpack ^5.61.0

Uncaught (in promise) ScriptExternalLoadError: Loading script failed.
(missing: http://localhost:8000/remote.js)
while loading "./HelloWorld" from webpack/container/reference/app1
复制代码

截屏2022-01-07 下午10.30.55.png 从加载的截图上来看,远程的remote.js是加载了的,但是,并没有加载从remote.js里分包出去的src_components_HelloWorld_vue.js。但是不用vue cli,直接自己写配置是没有问题的,估计还是vue cli的支持问题。

解决方案是:去掉分包的配置,但是这也是暂时的解决方案,肯定不好,但是给大家体验功能还是可以的。

chainWebpack: (config) => {
        config.optimization.delete("splitChunks");
},
复制代码

共享公共库(shared)

比如我们共享的库vue,如果不异步加载入口文件的内容,导致报错如下:

Uncaught Error: Shared module is not available for eager consumption: webpack/sharing/consume/default/vue/vue
复制代码

截屏2022-01-07 下午10.20.11.png

截屏2022-01-08 下午6.07.54.png

解决方案是:新增一个bootstrap.js文件,里面的内容是原入口js文件的内容,然后,再在入口文件异步加载bootstrap.js文件,这样就可以正常运行代码了。

截屏2022-01-08 下午5.45.47.png 具体代码如下:

入口文件:main.js

// 必须是异步加载
import("./bootstrap"); 
复制代码

bootstrap.js:内容是原入口文件的内容

import { createApp, defineAsyncComponent } from "vue";
import Layout from "./Layout.vue";

const Content = defineAsyncComponent(() => import("home/Content"));
const Button = defineAsyncComponent(() => import("home/Button"));

const app = createApp(Layout);

app.component("content-element", Content);
app.component("button-element", Button);

app.mount("#app");

复制代码

这是因为,远程的remoteEntry.js文件需要优先于src_bootstrap_js.js中的内容加载执行。如果不异步加载bootstrap.js,而直接执行原入口代码,但是原入口代码依赖于远程js的代码,远程js的代码又还没有被加载,就报错了。直观从上图的js文件大小可看出,也就是从原main.js中移了一部分代码到src_bootstrap_js.js中,而在src_bootstrap_js.js执行之前,先执行了remoteEntry.js

おすすめ

転載: juejin.im/post/7051086216594194462