Module Federation
背景
在之前我们在跨项目之间复用组件代码或逻辑时,一般有以下三种方式:
将组件代码封装为一个 npm 包发布,各项目拉取依赖该 npm。这是一种广泛采用的做法,它优化了代码的可维护性和可复用性。然而这种方法存在效率问题:一旦 npm 包有更新,每个依赖该包的应用都需要升级版本、重新构建并部署,这在多团队协作时尤其耗时。
将代码打包为 UMD 格式,并通过 CDN 分发。这种方法可以减少重新构建和部署的需求,但随着依赖组件和逻辑的增多,可能会导致性能问题,如重复的代码块 chunk。例如,如果 A 和 B 组件都依赖 lodash,那么在 UMD 打包过程中,lodash 的代码可能会被重复打包,导致无法有效复用。
复制粘贴。复用成本最低,维护成本最高;
是否存在一种策略,能够在模块版本更新后,无需逐一重新部署依赖该模块的所有项目,而是直接在生产环境中动态加载更新后的模块?
什么是 Module Federation
多个独立的构建可以组成一个应用程序,这些独立的构建之间不应该存在依赖关系,因此可以单独开发和部署它们。
- 配置简单灵活,不需要中心应用,同时也能成为其他子应用的宿主。
- 可以独立部署、上线运行,像 npm 包一样引入使用,但支持热更新,升级无需发布通知。
- 运行时加载模块,允许应用之间远程共享依赖,减少了重复代码,提高了加载效率和性能。
Module Federation 是 Webpack 5 的新特性之一,允许在多个 webpack 编译产物之间在线共享模块、依赖、页面甚至应用,提供了一种更为轻量、灵活的微前端实现方式,通过远程共享机制,它优化了资源利用,减少了不必要的重复加载,使得各个子应用能够更加高效地协同工作。
Container
- 一个使用 ModuleFederationPlugin 构建的应用就是一个 Container,它可以加载其他的 Container,也可以被其他的 Container 加载。
Host&Remote
- 从消费者和生产者的角度看 Container,Container 可以分为 Host 和 Remote,Host 作为消费者,他可以动态加载并运行其他 Remote 的代码,Remote 作为提供方,他可以暴露出一些属性、方法或组件供 Host 使用,这里要注意的一点是一个 Container 既可以作为 Host 也可以作为 Remote。
Shared
- shared 表示共享依赖,一个应用可以将自己的依赖共享出去,比如 react、react。
案例解析
代码配置
示例项目地址github
新建两个 react 项目,分别各自导出一个 react 组件,并通过 mf 远程引入对方组件:
app1 的 App.jsx:
import React from 'react';
import Button from './src/Button';
import Logo from 'comp/Logo';
import _ from 'lodash';
export default class App extends React.Component {
constructor(props) {
super(props);
}
render() {
return (
<div>
app1 lodash的版本号:{_.VERSION}
<Button />
<Logo></Logo>
</div>
);
}
}
app2 的 App.jsx:
import React from 'react';
import Button from 'comp/Button';
import Logo from './src/Logo';
import _ from 'lodash';
export default class App extends React.Component {
constructor(props) {
super(props);
}
render() {
return (
<div>
app2 lodash的版本号:{_.VERSION}
<Button type="warning" />
<Logo></Logo>
</div>
);
}
}
app1 里的 webpack.config.js
//
new ModuleFederationPlugin({
name: 'app1',
filename: 'app1RemoteEntry.js',
remotes: {
'comp': 'app2@http://localhost:8002/app2RemoteEntry.js',
},
exposes: {
'./Button': './src/Button.jsx',
},
shared: { lodash: { singleton: true } },
}),
app2 里的 webpack.config.js
//
new ModuleFederationPlugin({
name: 'app2',
filename: 'app2RemoteEntry.js',
remotes: {
'comp': 'app1@http://localhost:8001/app1RemoteEntry.js',
},
exposes: {
'./Logo': './src/Logo.jsx',
},
shared: { lodash: { singleton: true }},
}),
效果如下:
从以上效果可以看到:
- app1/app2 通过异步下载 app1RemoteEntry/app2RemoteEntry 入口文件来完成远程组件的导入;
- app1 引入了 app2 里的 Logo 组件,app2 也可以同时引入 app1 里的 Buttom 组件;
- app1、app2 共用 8001 即 app1 的 lodash 依赖,因为其版本较高;
以上就是 MF 实现了代码动态加载以及依赖共享的功能。
配置解释
name
:- 代表当前应用的唯一别名,作为远程(remote)应用时,被宿主(host)应用引用,需要在路径前加上这个前缀。
- 例如,如果宿主应用配置为
{'comp': 'app1@http://localhost:3001/app1RemoteEntry.js'}
,则app1
是远程应用的name
。
filename
:- 指定远程应用提供给宿主应用使用的入口文件名。
- 例如,如果远程应用的
filename
设置为app1RemoteEntry
,构建产物中会包含一个app1RemoteEntry.js
文件供宿主应用加载。
exposes
:- 定义远程应用暴露给宿主应用的属性、方法和组件。是一个对象,其中键(key)是宿主应用中使用时的路径,值(value)是远程应用中暴露出的资源的路径,如:
'./Logo': './src/Logo.jsx'
。 - 宿主应用可以通过
import Logo from 'comp/Logo'
引入远程应用的Logo
组件。
- 定义远程应用暴露给宿主应用的属性、方法和组件。是一个对象,其中键(key)是宿主应用中使用时的路径,值(value)是远程应用中暴露出的资源的路径,如:
remote
:- 定义宿主应用需要使用的远程应用及其资源地址,如:
'comp': 'app1@http://localhost:8001/app1RemoteEntry.js'
- 是一个对象,键(key)是远程应用的自定义别名,值(value)是远程应用的资源地址。
- 定义宿主应用需要使用的远程应用及其资源地址,如:
shared
:指定无论当前应用是作为宿主还是远程,都可以共享的依赖。
例如,
shared: { lodash: { singleton: true }}
表示lodash
以单例模式共享。singleton
:确定是否以单例模式共享依赖。开启后,共享的依赖只加载一次,优先使用版本高的。requiredVersion
:指定共享依赖的版本。
例如,如果宿主应用的 lodash
版本为 3.8.0
,远程应用的 lodash
版本为 4.8.0
,并且 singleton
为 true
,那么两者将共用 4.8.0
版本的 lodash
。如果远程应用将 lodash
的 requiredVersion
设置为 2.0.0
,则它将使用自己的 lodash
版本,而不是共享宿主应用的版本。
通过这种配置,Module Federation
为微前端架构提供了灵活的依赖共享机制,优化了资源利用并减少了不必要的重复加载。
runtime 源码浅析
前置知识:
webpack 加载远程资源方法:
/* 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) => {
return Promise.all(
Object.keys(__webpack_require__.f).reduce((promises, key) => {
__webpack_require__.f[key](chunkId, promises);
return promises;
}, []),
);
};
})();
其中 __webpack_require__.e
将同步执行 f 的三个方法: remotes、consumes、j,并等待均执行完成后才会执行 e 的回调方法。
我们以 app2 加载 app1 的远程组件 Button 为例来展开(app1 加载 app2 同理不在赘述),在编译后 bootstrap_js 文件的 App.jsx 里有如下代码:
var comp_Button__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(
/*! comp/Button */ 'webpack/container/remote/comp/Button',
);
//...
var lodash__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(
/*! lodash */ 'webpack/sharing/consume/default/lodash/lodash',
);
该 webpack/container/remote/comp/Button
即为 import Button from 'comp/Button'
编译后的代码,webpack/sharing/consume/default/lodash/lodash
即为共享模块代码,这两个模块什么时候是什么时候引入的呢,回头看主文件 main.js 有如下加载 bootstrap_js 的代码:
Promise.all(
/*! import() */ [
__webpack_require__.e('vendors-node_modules_pnpm_react_18_2_0_node_modules_react_index_js'),
__webpack_require__.e('vendors-node_modules_pnpm_react-dom_18_2_0_react_18_2_0_node_modules_react-dom_client_js'),
__webpack_require__.e('bootstrap_js'),
],
).then(__webpack_require__.bind(__webpack_require__, /*! ./bootstrap.js */ './bootstrap.js'));
//...
可以看到需要先加载完相关依赖文件后,才会执行 bootstrap.js module 里的代码,而要加载 bootstrap_js
文件,得加载他对应的依赖,分别执行以下 3 个方法:
remotes
// remotes
(() => {
var chunkMapping = {
bootstrap_js: ['webpack/container/remote/comp/Button'],
};
var idToExternalAndNameMapping = {
'webpack/container/remote/comp/Button': ['default', './Button', 'webpack/container/reference/comp'],
};
__webpack_require__.f.remotes = (chunkId, promises) => {
//...
};
})();
__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) => {
//...
};
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) :
var onInitialized = (_, external, first) => (handleFunction(external.get, data[1], getScope, 0, onFactory, first));
var onFactory = (factory) => {
data.p = 1;
__webpack_require__.m[id] = (module) => {
module.exports = factory();
}
};
handleFunction(__webpack_require__, data[2], 0, 0, onExternal, 1);
});
}
};
该 remotes 方法为加载远程组件的核心方法,通过 idToExternalAndNameMapping 和 remote 可以看出执行逻辑:
第一次执行 handleFunction:加载该 module(webpack/container/remote/comp/Button
)对应的加载远程的【本地 module】(webpack/container/reference/comp
);
webpack/container/reference/comp
:
'use strict';
var __webpack_error__ = new Error();
module.exports = new Promise((resolve, reject) => {
if (typeof app1 !== 'undefined') return resolve();
__webpack_require__.l(
'http://localhost:8001/app1RemoteEntry.js',
(event) => {
if (typeof app1 !== 'undefined') return resolve();
//reject(__webpack_error__);
},
'app1',
);
}).then(() => app1);
app1 里的 app1RemoteEntry.js:
var app1;
(() => {
// webpackBootstrap
// ...
var __webpack_exports__ = __webpack_require__('webpack/container/entry/app1');
app1 = __webpack_exports__;
})();
app1 里的webpack/container/entry/app1
var __webpack_modules__ = {
'webpack/container/entry/app1': (__unused_webpack_module, exports, __webpack_require__) => {
var moduleMap = {
'./Button': () => {
return Promise.all([
__webpack_require__.e('vendors-node_modules_pnpm_react_18_2_0_node_modules_react_index_js'),
__webpack_require__.e('src_Button_jsx'),
]).then(() => () => __webpack_require__(/*! ./src/Button.jsx */ './src/Button.jsx'));
},
};
var get = (module, getScope) => {
__webpack_require__.R = getScope;
getScope = moduleMap[module]();
__webpack_require__.R = undefined;
return getScope;
};
var init = (shareScope, initScope) => {
if (!__webpack_require__.S) return;
var name = 'default';
//...
__webpack_require__.S[name] = shareScope;
return __webpack_require__.I(name, initScope);
};
// This exports getters to disallow modifications
__webpack_require__.d(exports, {
get: () => get,
init: () => init,
});
},
};
__webpack_require__.l
是通过 jsonp 的方式加载远程资源app1RemoteEntry.js
,加载完成后将 app1RemoteEntry.js
里的全局变量 app1
返回,传给 onExternal
方法里的第一个参数 external
;
第二次执行 handleFunction(onExternal):执行__webpack_require__.I('default')
初始化注册共享模块:
(() => {
__webpack_require__.S = {};
var initPromises = {};
var initTokens = {};
__webpack_require__.I = (name, initScope) => {
if(!initScope) initScope = [];
var initToken = initTokens[name];
if(!initToken) initToken = initTokens[name] = {};
if(initScope.indexOf(initToken) >= 0) return;
initScope.push(initToken);
if(initPromises[name]) return initPromises[name];
if(!__webpack_require__.o(__webpack_require__.S, name)) __webpack_require__.S[name] = {};
var scope = __webpack_require__.S[name];
var uniqueName = "main-app";
var register = (name, version, factory, eager) => {
var versions = scope[name] = scope[name] || {};
var activeVersion = versions[version];
if(!activeVersion || (!activeVersion.loaded && (!eager != !activeVersion.eager ? eager : uniqueName > activeVersion.from))) versions[version] = { get: factory, from: uniqueName, eager:!!eager };
};
var initExternal = (id) => {
try {
var module = __webpack_require__(id);
if(!module) return;
var initFn = (module) => (module && module.init && module.init(__webpack_require__.S[name], initScope))
if(module.then) return promises.push(module.then(initFn, handleError));
var initResult = initFn(module);
if(initResult && initResult.then) return promises.push(initResult['catch'](handleError));
} catch(err) { }
}
var promises = [];
switch(name) {
case "default": {
register("lodash", "3.8.0", () => (__webpack_require__.e("vendors-node_modules_pnpm_lodash_3_8_0_node_modules_lodash_index_js").then(() => (() => (__webpack_require__(/*! ../..node_modules/.pnpm/lodash@3.8.0/node_modules/lodash/index.js */ "../../node_modules/.pnpm/lodash@3.8.0/node_modules/lodash/index.js"))))));
initExternal("webpack/container/reference/comp");
}
break;
}
if(!promises.length) return initPromises[name] = 1;
return initPromises[name] = Promise.all(**promises**).then(() => (initPromises[name] = 1));
};
})();
__webpack_require__.I
是共享模块的核心。register 方法注册自身依赖的共享模块,initExternal 也执行加载远程的【本地 module】,得到结果即 webpack/container/entry/app1 模块的 exports 后,执行其 init 方法,方法里通过__webpack_require__.S
将 app2 的共享模块共享给了 app1(共用同一个__webpack_require__.S
引用类型),然后 app1 执行了__webpack_require__.I
同样注册自己的共享模块,这样 __webpack_require__.S
保存的信息为:
第三次执行 handleFunction(onInitialized):执行 external.get('./Button')加载远程模块 Button,加载完成后在 onFactory 里将该模块存入自身的模块系统中(webpack/container/remote/comp/Button
);
consumes
//...
// consumes
(() => {
var init = (fn) =>
function (scopeName, a, b, c) {
var promise = __webpack_require__.I(scopeName);
if (promise && promise.then)
return promise.then(fn.bind(fn, scopeName, __webpack_require__.S[scopeName], a, b, c));
return fn(scopeName, __webpack_require__.S[scopeName], a, b, c);
};
var loadSingletonVersionCheckFallback = /*#__PURE__*/ init((scopeName, scope, key, version, fallback) => {
if (!scope || !__webpack_require__.o(scope, key)) return fallback();
return getSingletonVersion(scope, scopeName, key, version);
});
var moduleToHandlerMapping = {
'webpack/sharing/consume/default/lodash/lodash': () =>
loadSingletonVersionCheckFallback('default', 'lodash', [4, 3, 8, 0], () =>
__webpack_require__
.e('vendors-node_modules_pnpm_lodash_3_8_0_node_modules_lodash_index_js')
.then(
() => () =>
__webpack_require__(/*! lodash */ '../../node_modules/.pnpm/lodash@3.8.0/node_modules/lodash/index.js'),
),
),
};
var chunkMapping = {
bootstrap_js: ['webpack/sharing/consume/default/lodash/lodash'],
};
__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]);
if (!startedInstallModules[id]) {
var onFactory = (factory) => {
installedModules[id] = 0;
__webpack_require__.m[id] = (module) => {
delete __webpack_require__.c[id];
module.exports = factory();
};
};
startedInstallModules[id] = true;
var onError = (error) => {
delete installedModules[id];
__webpack_require__.m[id] = (module) => {
delete __webpack_require__.c[id];
throw error;
};
};
try {
var promise = moduleToHandlerMapping[id]();
if (promise.then) {
promises.push((installedModules[id] = promise.then(onFactory)['catch'](onError)));
} else onFactory(promise);
} catch (e) {
onError(e);
}
}
});
}
};
})();
该 consumes 方法是确认版本并加载共享模块的核心,在 loadSingletonVersionCheckFallback->init 里,通过之前的__webpack_require__.I
收集注册所有的版本后,通过 getSingletonVersion 方法按照配置选择对于的版本(内部通过 get 方法即当初 register 的第三个参数加载对应版本)。然后通过 onFactory 将该模块存入自身的模块系统中(webpack/sharing/consume/default/lodash/lodash
)。
j
var installedChunks = {
main: 0,
};
__webpack_require__.f.j = (chunkId, promises) => {
// JSONP chunk loading for javascript
var installedChunkData = __webpack_require__.o(installedChunks, chunkId) ? installedChunks[chunkId] : undefined;
if (installedChunkData !== 0) {
if (installedChunkData) {
promises.push(installedChunkData[2]);
} else {
if (true) {
var promise = new Promise(
(resolve, reject) => (installedChunkData = installedChunks[chunkId] = [resolve, reject]),
);
promises.push((installedChunkData[2] = promise));
var url = __webpack_require__.p + __webpack_require__.u(chunkId);
var loadingEnded = (event) => {
if (__webpack_require__.o(installedChunks, chunkId)) {
installedChunkData = installedChunks[chunkId];
if (installedChunkData !== 0) installedChunks[chunkId] = undefined;
if (installedChunkData) {
// installedChunkData[1](error);
}
}
};
__webpack_require__.l(url, loadingEnded, 'chunk-' + chunkId, chunkId);
}
}
}
};
//...
var webpackJsonpCallback = (parentChunkLoadingFunction, data) => {
var [chunkIds, moreModules, runtime] = data;
var moduleId,
chunkId,
i = 0;
if (chunkIds.some((id) => installedChunks[id] !== 0)) {
for (moduleId in moreModules) {
if (__webpack_require__.o(moreModules, moduleId)) {
__webpack_require__.m[moduleId] = moreModules[moduleId];
}
}
if (runtime) var result = runtime(__webpack_require__);
}
if (parentChunkLoadingFunction) parentChunkLoadingFunction(data);
for (; i < chunkIds.length; i++) {
chunkId = chunkIds[i];
if (__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {
installedChunks[chunkId][0]();
}
installedChunks[chunkId] = 0;
}
};
var chunkLoadingGlobal = (self['webpackChunkapp1'] = self['webpackChunkapp1'] || []);
chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));
chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));
installedChunks
是一个键值对,记录了加载中和已加载的 chunk 代码块的状态,形如:
重写了 self['webpackChunkapp1']的 push 方法,使其执行 webpackJsonpCallback:
(self['webpackChunkapp1'] = self['webpackChunkapp1'] || []).push([
['vendors-node_modules_pnpm_lodash_4_8_0_node_modules_lodash_lodash_js'],
{
/***/ '../../node_modules/.pnpm/lodash@4.8.0/node_modules/lodash/lodash.js':
/*!***************************************************************************!*\
!*** ../../node_modules/.pnpm/lodash@4.8.0/node_modules/lodash/lodash.js ***!
\***************************************************************************/
/***/ function (module, exports, __webpack_require__) {
//...
},
},
]);
在 j 方法里,执行__webpack_require__.l
异步拉取 chunk;当一个新的 chunk 加载完成时,webpackJsonpCallback 函数会被调用,内部函数将新加载的模块添加到模块列表 __webpack_require__.m
中,并执行 installedChunks 对应模块 key 的回调 resolve;当加载失败时,执行 loadingEnded 即 installedChunks 对应模块 key 的回调 reject,完成__webpack_require__.j
的执行。
当__webpack_require__.f
里的remotes
、consumes
、j
均执行完成后,即可执行__webpack_require__.e
的回调即返回或执行对应模块。
总结
app2 会先执行入口文件 main.js,然后加载 bootstrap_js chunk 块,执行 3 个 f 方法 remotes、consumes、j,分别对应其远程模块依赖加载、共享模块依赖加载、该 chunk 文件异步加载;均加载完成后,即加载 bootstrap.js
module。
- remotes:依赖了远程模块
webpack/container/remote/component-app/Button
,那么先会去下载远程模块webpack/container/remote/component-app
,即remoteEntry.js
,然后返回component_app
这个全局变量,然后执行component-app.get('./xxx')
去获取对应的组件,等远程应用的资源以及bootstrap_js
资源全部下载完成后再执行bootstrap.js
module; - consumes:共享依赖的模块在加载前都会先调用
__webpack_require__.I
去初始化共享依赖,使用__webpack_require__.S
对象来保存着每个应用的共享依赖版本信息,每个应用引用共享依赖时,会根据不同的自己配制的规则从__webpack_require__.S
获取到适合的依赖版本,__webpack_require__.S
是应用间共享依赖的桥梁; - j: 通过
__webpack_require__.l
加载 chunk 块;
应用场景
- 远程代码(组件、逻辑)依赖
- 公共模块共享
缺点
注: 沙箱隔离(js、css)不属于微模块的职责,不算缺点。
- 缺乏远程模块类型提示
- 缺乏版本管理
- 与 webpack runtime 强绑定,必须使用 webpack5
MF 总结
什么是微前端? 微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。
目前包含 3 种方案:
- 基座(容器)模式:通过搭建基座、配置中心来管理子应用。如基于 SIngle Spa 的偏通用的乾坤方案,也有基于本身团队业务量身定制的方案。 qiankun wujie microapp
- 自组织模式: 通过约定进行互调,但会遇到处理第三方依赖等问题。 nginx
- 去中心(微模块)模式: 脱离基座模式,每个应用之间都可以彼此分享资源。如基于 Webpack 5 Module Federation 实现的 EMP 微前端方案,可以实现多个应用彼此共享资源分享。 MF emp hel
模块联邦(微模块方案)是一个定义,即一种支持模块独立开发与独立部署,并在多个项目间运行时共享的技术方案,webpack5 MF、empjs 、vite-federation-plugin、hel-micro 都是其实现。