一文看透 Module Federation
前言
一直在聽說 Webpack5 的新特性 Module Federation 可以很好解決代碼共享的問題,但其實在這兩年並沒有在團隊中使用起來,一方面是現有的項目都不是 Webpack5 的,小範圍項目落地又有侷限性,另一方面是團隊在微前端的方案探索中,在如何解決跨子應用代碼共享的問題中也有了比較好的解決方案。
目前爲了探索 Module Federation 與微前端方案結合起來的可能性,決定深入瞭解一下它的底層原理。
概念
Module Federation
什麼是 Module Federation (下面簡稱 MF) 呢,我們來看看 Webpack 官網裏的描述:
Multiple separate builds should form a single application. These separate builds should not have dependencies between each other, so they can be developed and deployed individually. This is often known as Micro-Frontends, but is not limited to that.
簡單翻譯就是,“一個應用可以由多個獨立的構建組成。這些獨立的構建之間沒有依賴關係,他們可以獨立開發、部署。這就是常被認爲的微前端,但不侷限於此。”
不難發現,MF 想做的事和微前端想解決的問題是類似的,把一個應用進行拆分成多個應用,每個應用可獨立開發,獨立部署,一個應用可以動態加載並運行另一個應用的代碼,並實現應用之間的依賴共享。
爲了實現這樣的功能, MF 在設計上提出了這幾個核心概念。
Container
一個被 ModuleFederationPlugin 打包出來的模塊被稱爲 Container。
通俗點講就是,如果我們的一個應用使用了 ModuleFederationPlugin 構建,那麼它就成爲一個 Container,它可以加載其他的 Container,可以被其他的 Container 所加載。
Host&Remote
從消費者和生產者的角度看 Container,Container 又可被稱作 Host 或 Remote。
-
Host:消費方,它動態加載並運行其他 Container 的代碼。
-
Remote:提供方,它暴露屬性(如組件、方法等)供 Host 使用
可以知道,這裏的 Host 和 Remote 是相對的,因爲 一個 Container 既可以作爲 Host,也可以作爲 Remote。
Shared
一個 Container 可以 Shared 它的依賴(如 react、react-dom)給其他 Container 使用,也就是共享依賴。
使用實踐
下面以一個簡單的例子來介紹一下如何使用 MF 的功能。
效果演示
有兩個應用分別爲 app1
和 app2
,app2
共享它的 Hello 組件給 app1
使用,它們共享一份 react 和 react-dom 依賴,下面我們來看看核心代碼。
完整代碼可下載 webpack5demo(https://github.com/beyondxgb/webpack5demo) 運行查看。
app1/src/app.js
import React from 'react';
import App2Hello from 'app2/Hello';
const RootComponent = () => {
return (
<div>
<div>app1</div>
<App2Hello />
</div>
);
};
export default RootComponent;
app1/src/bootstrap.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './app';
ReactDOM.render(<App />, document.getElementById('app'));
app1/src/index.js
import('./bootstrap');
app2/src/Hello.js
import React from 'react';
const Hello = () => {
return (
<div>app2 hello</div>
)
};
export default Hello;
效果如下:
可以看到,因爲app1
引用了 app2
的 Hello 組件,在渲染的時候異步下載了app2
的遠程模塊入口代碼和 Hello 組件的代碼,並且只下載了 app1
的 react 和 react-dom 代碼,app2
直接使用 app1
提供的依賴,這樣就實現了一個應用動態加載並運行另一個應用的代碼,並實現應用之間的依賴共享。
如何配置插件?
實現跨應用代碼共享,主要藉助了 Webapck5 提供的一個插件 ModuleFederationPlugin。
在上面的例子,很明顯,app1
使用了 app2
的 Hello 組件,app1
爲消費方,app2
爲提供方。
app2
作爲提供方(Remote),它會把 Hello 組件暴露出來給消費方(Host)使用。
app2/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
...
plugins: [
new ModuleFederationPlugin({
name: 'app2',
filename: 'app2RemoteEntry.js',
exposes: {
'./Hello': './src/Hello',
},
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),
]
}
同理**app1
作爲消費方(Host)** ,定義需要消費 **app2** 並指定它的資源地址。
app1/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
...
plugins: [
new ModuleFederationPlugin({
name: 'app1',
filename: 'app1RemoteEntry.js',
remotes: {
'app2': 'app2@http://127.0.0.1:8002/app2RemoteEntry.js',
},
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
})
]
}
下面來解釋下上面幾個核心字段配置。
name
當前應用的別名,當應用作爲 Remote 給 host 使用的時候,作爲引用前綴,import xx from name/expose。
filename
當前應用作爲 Remote 給 Host 使用的時候,提供的遠程模塊入口文件名,比如上面 app1
在使用 app2
的時候,會先下載 app2RemoteEntry.js
文件。
exposes
當前應用作爲 Remote 的時候,可提供哪些屬性(如組件、方法,甚至是一個值)可消費。
new ModuleFederationPlugin({
name: 'app2',
...
exposes: {
'./Hello': './src/Hello',
},
}
它是一個對象,它的 key 爲在被 Host 使用的時候的相對路徑,value 爲當前應用暴露的屬性的相對路徑。
如上面的配置,可以這樣提供給 Host 同步引用:
import App2Hello from 'app2/Hello';
當然,也可以異步加載引用:
const App2Hello = React.lazy(() => import('app2/App1Hello'));
remotes
當前應用作爲 Host 的時候,需要消費哪些 Remote 應用。
new ModuleFederationPlugin({
name: 'app1',
...
remotes: {
'app2': 'app2@http://127.0.0.1:8002/app2RemoteEntry.js',
},
})
它是一個對象,它的 key 爲 Remote 應用定義的別名(name),value 爲 Remote 應用的資源地址,使用 Remote 應用的格式爲_ import * from {name}{path}
_。
import App2Hello from 'app2/Hello';
注意的是,這裏的 name
是引用別名,可以跟 Remote 應用定義的 name
不一致的。
比如我們定義 app2
的別名爲 @remote/app2
new ModuleFederationPlugin({
name: 'app1',
...
remotes: {
'@remote/app2': 'app2@http://127.0.0.1:8002/app2RemoteEntry.js',
},
})
那麼,使用的時候則可以這樣子:
import App2Hello from '@remote/app2/Hello';
shared
當前應用無論是作爲 Host 還是 Remote,可以共享的三方庫依賴有哪些。
new ModuleFederationPlugin({
name: 'app1',
...
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
})
這是一個對象,它的 key 爲三方依賴的 name,value 則爲該三方依賴的屬性配置項。常用的有 singleton 或 requiredVersion。
-
singleton:是否開啓單例模式,如果開啓的話,共享的依賴則只會加載一次(優先取版本高的)。
-
requiredVersion:指定共享依賴的版本。
比如 singleton 爲 true,app1
的 react 版本爲 16.13.0,app2
的 react 版本爲 16.14.0,那麼 app1
和 app2
將會共同使用 16.14.0 的 react 版本,也就是 app2
提供的 react。
如果這時 app1
配置的 react 版本 requiredVersion 爲 16.13.0,那麼 app1
將會使用 16.13.0,app2
將會使用 16.14.0,相當於它們都沒有共享依賴,各自下載自己的 react 版本。
工作原理
從上面的一個簡單例子可以快速知道 MF 的使用方法,下面來介紹下具體的工作原理。
這部分內容有點枯燥,如果不想了解的話可快速跳過這一節,如果繼續瞭解的話,建議對照着運行代碼來查看
構建上有什麼不同?
在沒有使用 MF 之前,app1
和 app2
的構建如下:
使用 MF 之後,對應的構建如下:
對比兩張圖,我們可以看出打包文件發生了變化,在新的打包文件中,我們發現新增了 remoteEntry-chunk
、shared-chunk
、expose-chunk
以及 async-chunk
。
其中remoteEntry-chunk
、shared-chunk
、expose-chunk
都是因爲配置了 ModuleFederationPlugin 而生成的,async-chunk
卻是人爲分割文件而生成的。
我們來對照着 app2
的插件配置介紹一下每個 chunk 的生成。
app2/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
...
plugins: [
new ModuleFederationPlugin({
name: 'app2',
filename: 'app2RemoteEntry.js',
exposes: {
'./Hello': './src/Hello',
},
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),
]
}
remoteEntry-chunk
是當前應用作爲遠程應用(Remote)被調用的時候請求的文件,對應的文件名爲插件裏配置的 filename,比如會生成 app1RemoteEntry.js
、app2RemoteEntry.js
。
shared-chunk
是當前應用開啓了 shared(共享依賴)功能後生成的,比如 shared 指定共享 react 和 react-dom,那麼在構建的時候 react 模塊和 react-dom 模塊會被分離爲新的 shared-chunk
,比如vendors-node_modules__react_16_14_0_react_index_js.js
和 vendors-node_modules__react-dom_16_14_0_react-dom_index_js.js
。
expose-chunk
是當前應用暴露某些屬性提供給外部使用的時候生成的,在構建的時候會根據 exposes 配置項,生成一個或多個 expose-chunk
,比如 app2
生成了 Hello 這個 chunk。
最後講下 async-chunk
,這裏指的是src_bootstrap_tsx.js
,爲什麼會有這個異步文件呢?
我們來看看上面提到的 app1
的文件:
app1/src/bootstrap.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './app';
ReactDOM.render(<App />, document.getElementById('app'));
app1/src/index.js
import('./bootstrap');
這裏有一個 bootstrap.js
文件,它裏面的代碼原本是在放在index.js
入口文件裏,爲什麼單獨分離出來,並且在 index.js
使用 import('bootstrap') 來異步加載 bootstrap.js
呢?
這就是要實現 MF 功能的限制了,我們來看看這段代碼:
app1/src/app.js
import React from 'react';
import App2Hello from 'app2/Hello';
const RootComponent = () => {
return (
<div>
<div>app1</div>
<App2Hello />
</div>
);
};
export default RootComponent;
如果 bootstrap.js
不是異步加載的話,而是直接打包在 main.js
裏面,那麼import App2Hello from 'app2/Hello';
這語句就被立刻執行了,這時會因 app2
的資源根本沒有被下載而報錯了。
如果開啓了 shared 功能的話,那麼 import React from 'react';
這語句被同步執行也是會報錯的,因爲這時候還沒有初始化好共享依賴,所以經常會出現下面這個報錯。
所以必須必須必須把原本的入口代碼放到 bootstrap.js
裏面,index.js
使用了 import('bootstrap') 來異步加載 bootstrap.js
,這樣就可以實現先加載 main.js
,然後在異步加載 src_bootstrap_tsx.js
的時候,前置先加載好遠程應用的資源以及初始化好共享依賴,最後再執行 bootstrap.js
模塊。
如何加載遠程模塊?
app1/src/app.js
import App2Hello from 'app2/Hello';
如上面,我們看到 app1
裏是這樣引用 app2
的 Hello 組件的,背後發生了什麼呢?
我們來看看這段代碼的構建結果:
可以看到 src_bootstrap_tsx
的編譯結果裏 src/app.tsx
模塊引用了模塊 webpack/container/remote/app2/Hello
,也就是我們代碼寫的 app2/Hello
,但 webpack/container/remote/app2/Hello
又是在哪呢,我們從 app1
的主入口文件 main.js
的構建結果可以搜索到它。
/******/ /* webpack/runtime/remotes loading */
/******/ (() => {
/******/ var chunkMapping = {
/******/ "src_bootstrap_tsx": [
/******/ "webpack/container/remote/app2/Hello"
/******/ ]
/******/ };
/******/ var idToExternalAndNameMapping = {
/******/ "webpack/container/remote/app2/Hello": [
/******/ "default",
/******/ "./Hello",
/******/ "webpack/container/reference/app2"
/******/ ]
/******/ };
/******/ __webpack_require__.f.remotes = (chunkId, promises) => {
/******/ if(__webpack_require__.o(chunkMapping, chunkId)) {
/******/ chunkMapping[chunkId].forEach((id) => {
/******/ var data = idToExternalAndNameMapping[id];
/******/ 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);
/******/ });
/******/ }
/******/ }
/******/ })();
這裏的 __webpack_require__.f.remotes
則是加載遠程模塊的核心。代碼中有個 chunkMapping
對象,這個對象保存的是當前應用有哪些模塊依賴了遠程模塊,比如 src_bootstrap_tsx
依賴了遠程模塊webpack/container/remote/app2/Hello
。
那麼加載 src_bootstrap_tsx
的時候必須先加載完遠程應用的資源,從最後 handleFuncion 語句可以看到,__webpack_require__(data[2])
,也就是去加載 webpack/container/reference/app2
。
/***/ "webpack/container/reference/app2":
/*!****************************************************************!*\
!*** external "app2@http://127.0.0.1:8002/app2RemoteEntry.js" ***!
\****************************************************************/
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
"use strict";
var __webpack_error__ = new Error();
module.exports = new Promise((resolve, reject) => {
if(typeof app2 !== "undefined") return resolve();
__webpack_require__.l("http://127.0.0.1:8002/app2RemoteEntry.js", (event) => {
if(typeof app2 !== "undefined") return resolve();
...
}, "app2");
}).then(() => (app2));
/***/ })
我們找到這個模塊的定義,這裏會去異步加載 app2RemoteEntry.js
,也就是我們在配置 app1
ModuleFederationPlugin 的時候指定的 app2
遠程模塊入口文件的資源地址,加載完後返回 app2 這個全局變量作爲 webpack/container/reference/app2
模塊的輸出值。
但這只是獲取到了app2
遠程入口模塊的輸出值,怎麼獲取到 Hello 組件呢?
我們來看下 app2RemoteEntry.js
的具體內容:
var moduleMap = {
"./Hello": () => {
return Promise.all([__webpack_require__.e("webpack_sharing_consume_default_react_react-_091a"), __webpack_require__.e("src_Hello_tsx")]).then(() => (() => ((__webpack_require__(/*! ./src/Hello */ "./src/Hello.tsx")))));
}
};
var get = (module, getScope) => {
__webpack_require__.R = getScope;
getScope = (
__webpack_require__.o(moduleMap, module)
? moduleMap[module]()
: Promise.resolve().then(() => {
throw new Error('Module "' + module + '" does not exist in container.');
})
);
__webpack_require__.R = undefined;
return getScope;
};
var init = (shareScope, initScope) => {
....
};
// This exports getters to disallow modifications
__webpack_require__.d(exports, {
get: () => (get),
init: () => (init)
});
它暴露了 get
和 init
方法,我們回到上面 __webpack_require__.f.remotes
裏的一個方法:
var onInitialized = (_, external, first) => (handleFunction(external.get, data[1], getScope, 0, onFactory, first));
在加載完遠程模塊入口文件後,返回了 app2 全局變量,最後執行 app2.get('./Hello') 來異步獲取 Hello 組件。
總結一下流程,app1
加載 src_bootstrap_tsx
模塊,判斷它依賴了遠程模塊 webpack/container/remote/app2/Hello
,那麼先去下載遠程模塊 webpack/container/reference/app2
,也就是app2RemoteEntry.js
,返回 app2 全局變量,執行 app2.get('./Hello') 來異步獲取 Hello 組件,遠程應用的資源以及 src_bootstrap_tsx
資源全部下載完成,最後再執行 src_bootstrap_tsx
模塊。
它們如何共享依賴?
在 webpack 的構建中,每個構建結果其實都是隔離的,那麼它是如何打破這個隔離,實現應用間共享依賴呢?
這裏的關鍵在於 sharedScope,共享作用域,在 Host 和 Remote 應用之間建立一個可共享的 sharedScope,它包含了所有可共享的依賴,大家都按一定規則往 sharedScope 裏獲取對應的依賴。
app1/src/bootstrap.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './app';
ReactDOM.render(<App />, document.getElementById('app'));
如上面,我們知道,react 和 react-dom 已經被配置爲 shared 的,在 bootsrap.js
引用 react 和 react-dom 的時候,背後會是怎麼引用這兩個模塊呢?我們來看看 app1
的主入口文件 main.js
的構建結果。
/******/ var moduleToHandlerMapping = {
/******/ "webpack/sharing/consume/default/react/react?923c": () => (loadSingletonVersionCheckFallback("default", "react", [4,16,14,0], () => (Promise.all([__webpack_require__.e("vendors-node_modules__react_16_14_0_react_index_js"), __webpack_require__.e("node_modules__object-assign_4_1_1_object-assign_index_js-node_modules__prop-types_15_8_1_prop-5b9d13")]).then(() => (() => (__webpack_require__(/*! react */ "./node_modules/_react@16.14.0@react/index.js"))))))),
/******/ "webpack/sharing/consume/default/react-dom/react-dom": () => (loadSingletonVersionCheckFallback("default", "react-dom", [4,16,14,0], () => (Promise.all([__webpack_require__.e("vendors-node_modules__react-dom_16_14_0_react-dom_index_js"), __webpack_require__.e("webpack_sharing_consume_default_react_react")]).then(() => (() => (__webpack_require__(/*! react-dom */ "./node_modules/_react-dom@16.14.0@react-dom/index.js"))))))),
/******/ "webpack/sharing/consume/default/react/react?20fb": () => (loadSingletonVersionCheckFallback("default", "react", [1,16,14,0], () => (__webpack_require__.e("vendors-node_modules__react_16_14_0_react_index_js").then(() => (() => (__webpack_require__(/*! react */ "./node_modules/_react@16.14.0@react/index.js")))))))
/******/ };
/******/ var chunkMapping = {
/******/ "src_bootstrap_tsx": [
/******/ "webpack/sharing/consume/default/react/react?923c",
/******/ "webpack/sharing/consume/default/react-dom/react-dom"
/******/ ],
/******/ "webpack_sharing_consume_default_react_react": [
/******/ "webpack/sharing/consume/default/react/react?20fb"
/******/ ]
/******/ };
/******/ __webpack_require__.f.consumes = (chunkId, promises) => {
/******/ if(__webpack_require__.o(chunkMapping, chunkId)) {
/******/ chunkMapping[chunkId].forEach((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); }
/******/ });
/******/ }
/******/ }
/******/ })();
開啓了 shared 功能後,app1
構建代碼會多了 __webpack_require__.f.consumes
這段代碼邏輯,代碼中有個chunkMapping
對象,這個對象保存的是**當前應用有哪些模塊依賴了共享依賴,比如 src_bootstrap_tsx
模塊依賴了 react 和 react-dom 這兩個共享依賴。
那麼加載 src_bootstrap_tsx
的時候必須先加載完這些共享依賴的資源,也就是webpack/sharing/consume/default/react/react?923c
和 webpack/sharing/consume/default/react-dom/react-dom
這兩個模塊,它們是通過 loadSingletonVersionCheckFallback
來獲取值的。
/******/ 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);
/******/ });
在執行 loadSingletonVersionCheckFallback
之前,首先要執行了 init
方法,init
方法調用了 __webpack_require__.I
,這纔來到了共享依賴的重點方法。
/******/ /* webpack/runtime/sharing */
/******/ (() => {
/******/ __webpack_require__.S = {};
/******/ __webpack_require__.I = (name, initScope) => {
/******/ if(!__webpack_require__.o(__webpack_require__.S, name)) __webpack_require__.S[name] = {};
/******/ // runs all init snippets from all modules reachable
/******/ var scope = __webpack_require__.S[name];
/******/ var uniqueName = "atom-workbench-app1";
/******/ 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) => {
/******/ var handleError = (err) => (warn("Initialization of sharing external failed: " + err));
/******/ 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) { handleError(err); }
/******/ }
/******/ var promises = [];
/******/ switch(name) {
/******/ case "default": {
/******/ register("react-dom", "16.14.0", () => (Promise.all([__webpack_require__.e("vendors-node_modules__react-dom_16_14_0_react-dom_index_js"), __webpack_require__.e("webpack_sharing_consume_default_react_react")]).then(() => (() => (__webpack_require__(/*! ./node_modules/_react-dom@16.14.0@react-dom/index.js */ "./node_modules/_react-dom@16.14.0@react-dom/index.js"))))));
/******/ register("react", "16.14.0", () => (Promise.all([__webpack_require__.e("vendors-node_modules__react_16_14_0_react_index_js"), __webpack_require__.e("node_modules__object-assign_4_1_1_object-assign_index_js-node_modules__prop-types_15_8_1_prop-5b9d13")]).then(() => (() => (__webpack_require__(/*! ./node_modules/_react@16.14.0@react/index.js */ "./node_modules/_react@16.14.0@react/index.js"))))));
/******/ initExternal("webpack/container/reference/app2");
/******/ }
/******/ break;
/******/ }
/******/ if(!promises.length) return initPromises[name] = 1;
/******/ return initPromises[name] = Promise.all(promises).then(() => (initPromises[name] = 1));
/******/ };
/******/ })();
這裏的 __webpack_require__.S
就是保存共享依賴的信息,它是應用間共享依賴的橋樑。在經過 register 方法後,可以看到 webpack_require.S 保存的信息。
其中 default 爲 sharedScope 的名稱,react、react-dom 爲對應在 shared 配置項中的共享依賴,共享依賴保存着每個版本的信息,每個版本的 from 代表這個共享依賴來自哪個應用,get 則爲共享依賴的獲取方法。
最後調用 initExternal 方法,加載依賴的遠程應用 webpack/container/reference/app2
,也就是加載 app2RemoteEntry.js
,加載完後調用這個遠程入口文件模塊的 init 方法。
var initFn = (module) => (module && module.init && module.init(__webpack_require__.S[name], initScope))
我們再看看 app2
的app2RemoteEntry.js
var get = (module, getScope) => {
...
};
var init = (shareScope, initScope) => {
if (!__webpack_require__.S) return;
var name = "default"
var oldScope = __webpack_require__.S[name];
if(oldScope && oldScope !== shareScope) throw new Error("Container initialization failed as it has already been initialized with a different share scope");
__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)
});
可以看到,init
方法會使用 app1
的 webpack_require.S 初始化 app2
webpack_require.S!由於這是引用關係,所以 app1
和 app2
共用了一個的 sharedScope。
這裏注意的是 app2
也調用了自己的 __webpack_require__.I
,也會 register 自己的共享依賴,那麼最終的 webpack_require.S 會是怎樣呢?
如果 app2
也是使用 16.14.0 版本的 react 的話,那麼 webpack_require.S 是不變的,還是跟上面 app1
的一樣,如果 app2
使用的是 16.13.0 版本的 react 的話,那麼會增加一個版本信息。
webpack_require.S 已經初始化好了,那麼在 app1
和app2
在使用 react 或 react-dom 的時候究竟取哪個版本呢?這就要回到 loadSingletonVersionCheckFallback
方法了。
app1/main.js
/******/ 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/react/react?923c": () => (loadSingletonVersionCheckFallback("default", "react", [4,16,14,0], () => (Promise.all([__webpack_require__.e("vendors-node_modules__react_16_14_0_react_index_js"), __webpack_require__.e("node_modules__object-assign_4_1_1_object-assign_index_js-node_modules__prop-types_15_8_1_prop-5b9d13")]).then(() => (() => (__webpack_require__(/*! react */ "./node_modules/_react@16.14.0@react/index.js"))))))),
/******/ "webpack/sharing/consume/default/react-dom/react-dom": () => (loadSingletonVersionCheckFallback("default", "react-dom", [4,16,14,0], () => (Promise.all([__webpack_require__.e("vendors-node_modules__react-dom_16_14_0_react-dom_index_js"), __webpack_require__.e("webpack_sharing_consume_default_react_react")]).then(() => (() => (__webpack_require__(/*! react-dom */ "./node_modules/_react-dom@16.14.0@react-dom/index.js"))))))),
/******/ "webpack/sharing/consume/default/react/react?20fb": () => (loadSingletonVersionCheckFallback("default", "react", [1,16,14,0], () => (__webpack_require__.e("vendors-node_modules__react_16_14_0_react_index_js").then(() => (() => (__webpack_require__(/*! react */ "./node_modules/_react@16.14.0@react/index.js")))))))
/******/ };
比如 app1
在獲取 webpack/sharing/consume/default/react/react?923c
的時候,也就是獲取 react 的 16.14.0 版本,在 loadSingletonVersionCheckFallback
方法裏判斷了 scope 裏是不是有 react
這個共享依賴,如果沒有的話就走 fallback
方法,也就是共享依賴沒有可取的,那麼就去下載當前應用打包的 react 模塊,如果有的話,那麼就調用 getSingletonVersion
方法。
app1/main.js
/******/ var getSingletonVersion = (scope, scopeName, key, requiredVersion) => {
/******/ var version = findSingletonVersionKey(scope, key);
/******/ if (!satisfy(requiredVersion, version)) typeof console !== "undefined" && console.warn && console.warn(getInvalidSingletonVersionMessage(scope, key, version, requiredVersion));
/******/ return get(scope[key][version]);
/******/ };
這裏面其實在是 webpack_require.S 尋找適合的版本,在這裏是會取最高的 react 版本。
其實這裏不一定是調用 getSingletonVersion
方法的,取決於我們在配置 shared 的時候如何配置。
app1/webpack.config.js
new ModuleFederationPlugin({
...
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),
這裏我們配置了 singleton: true
,所以才調用 getSingletonVersion
方法,如果配置了requiredVersion
的話,則會調用 findValidVersion
方法,會去尋找特定的版本。
app2/webapck.config.js
new ModuleFederationPlugin({
...
shared: { react: { requiredVersion: '16.13.0' }, 'react-dom': { requiredVersion: '16.13.0' } },
}),
app2/app2RemoteEntry.js
/******/ var loadStrictVersionCheckFallback = /*#__PURE__*/ init((scopeName, scope, key, version, fallback) => {
/******/ var entry = scope && __webpack_require__.o(scope, key) && findValidVersion(scope, key, version);
/******/ return entry ? get(entry) : fallback();
/******/ });
/******/ var moduleToHandlerMapping = {
/******/ "webpack/sharing/consume/default/react/react": () => (loadStrictVersionCheckFallback("default", "react", [4,16,13,0], () => (__webpack_require__.e("vendors-node_modules__react_16_13_0_react_index_js").then(() => (() => (__webpack_require__(/*! react */ "./node_modules/_react@16.13.0@react/index.js")))))))
/******/ };
比如 app2
配置了 react 需要特定的 16.13.0 版本,那麼它會調用 findValidVersion
在 webpack_require.S 裏尋找 16.13.0 的版本,而不會像 getSingletonVersion
一樣,匹配到最高的版本 16.14.0。
總結一下流程,如果應用配置了 shared
共享依賴後,那麼依賴了這些共享依賴的模塊,在加載前都會調用 __webpack_require__.I
先初始化好共享依賴,使用__webpack_require__.S
對象來保存着每個應用的共享依賴版本信息,在每個應用引用共享依賴的時候,根據不同的規則從__webpack_require__.S
獲取到適合的共享依賴版本,__webpack_require__.S
是應用間共享依賴的橋樑。
應用場景
代碼共享
以前的遇到一個應用需要引用另一個應用的代碼的時候,有三種解法:
-
直接複製代碼,鄙視。
-
建立一個庫存放公用代碼併發布到 npm 上,低效。
-
使用微前端 MicroApp 異步加載子應用並定位到對應的組件,優雅但不成標準。
現在可以使用 MF 來解決這個問題,任何一個應用要想暴露組件、方法甚至一個值,只需要配置一下 exposes 即可,使用方則需要配置一下 remotes 就可以引用另一個應用的暴露屬性。
可以使用同步或異步兩種方式來引用,比如有個 optimus
應用暴露 ServiceInfo 組件。
同步引用
import ServiceInfo from 'optimus/ServiceInfo';
異步引用
const ServiceInfo = React.lazy(() => import('optimus/ServiceInfo'));
同步引用,頁面 chunk 會等待 optimusRemoteEntry.js
下載完成再執行,異步引用,頁面 chunk 下載完成立即執行,然後異步下載 optimusRemoteEntry.js
。
更爲大膽的用法,optimus
應用暴露一個值或方法:
import getServiceTagDage from 'optimus/utils/getServiceTagDage;
import ServiceStatus from 'optimus/ServiceStatus';
這也給業務組件庫的實現提供另一種方式,而且不需要藉助 babel-plugin-import 就可以實現按需加載!
比如一個業務組件庫 tracks,它有 PageHeader、Address、Empty 等組件,它可以像以前一樣正常開發組件,最後只需要在構建文件裏配置一下 ModuleFederationPlugin 即可。
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
...
plugins: [
new ModuleFederationPlugin({
name: 'tracks',
filename: 'tracksRemoteEntry.js',
exposes: {
'./PageHeader': './src/components/PageHeader',
'./Address': './src/components/Address',
'./Empty': './src/components/Empty',
...
},
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),
]
}
使用的時候可以根據需要同步或異步加載組件,所以它不僅可以實現按需加載,還可以實現懶加載!
import PageHeader from 'tracks/PageHeader';
const PageHeader = React.lazy(() => import('tracks/PageHeader'));
看到這裏,是否覺得 MF 解決了以前很多痛點問題,但這新的開發模式也帶來兩個核心問題。
第一個問題,在引用 Remote 應用的時候,缺乏了類型提示。即使 Remote 應用生成了類型文件,但在 Host 引用它的時候,只是建立一個引用關係,所以根本獲取不到它對應的類型文件。
第二個問題,沒有工具支持多個應用同時啓動、同時開發。在這種開發模式普遍起來後,一個頁面涉及到多個應用的代碼是必然存在的,需要有對應的開發工具來支持。
但是問題都是比較好解決的,可以自行開發對應的工具來解決,但仍期待 Webpack 官方後續能提供標準的方案,理論上解決了第二個問題,第一個問題就迎刃而解了,
公共依賴
公共依賴的處理一直是大家在做性能優化必須考慮的事情,以前主要有兩種解法:
解法一,傳統 webpack externals 方案,提前把需要的公共依賴腳本放置頁面上,暴露全局變量提供應用使用。這種做法的弊端在於所有依賴是全量加載的(Webpack5 可做到按需加載了),而且依賴順序需要人工保證,對於公共依賴有多個版本共存的情況也無法支持。
解法二,微前端的方案,比如它有自己的一套模塊管理,子應用聲明需要的公共依賴,在加載子應用的時候先加載完全部公共依賴方可執行子應用。這種做法其實就是以前 seajs 做的事情,顯示聲明依賴,可靈活控制加載順序,它雖然解決了按需加載、依賴管理和多個版本共存的問題,但自身的模塊管理並不成標準,無法與社區的其他方案融合,而且需要成套的技術體系來支撐。
那利用 MF 的特性可以怎麼更優雅解決這個問題呢?其實跟微前端方案同樣的思路,只不過應用間的依賴關係以及應用的異步加載全交給 Webpack 去實現了,如下圖所示。
所有公共依賴均可作爲一個應用,子應用依賴公共依賴,公共依賴之間也會相互依賴,比如 ReactDom 依賴 React,Antd 依賴 React 和 ReactDom,比如 React16 作爲一個應用,它可這樣暴露值出去:
index.js
import * as React from 'react';
export default React;
webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
...
plugins: [
new ModuleFederationPlugin({
name: 'react16',
filename: 'react16RemoteEntry.js',
exposes: {
'./index': './src/index',
},
}),
]
}
那麼其他引用使用它的時候,則可以這樣子:
app.js
import React from 'react16/index';
const RootComponent = () => {
return (
<div class>
...
</div>
);
};
export default RootComponent;
webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
...
plugins: [
new ModuleFederationPlugin({
name: 'optimus',
remotes: {
'react16': 'react16@http://{cdnUrl}/react16RemoteEntry.js',
},
}),
]
}
這樣做的話也有兩個核心問題需要解決:
-
依賴別名問題,可以看到上面爲了使用 react 公共依賴,寫了 import React from 'react16/index' ,肯定不能要求使用者這樣寫的,體驗上需要做到無感知地引用。
-
性能問題,每個公共依賴一個應用,那麼啓動的時候需要異步下載非常多的資源,因爲每個公共依賴都有一個
reamoteEntry.js
和一個對應的依賴.js
。
第一個問題相信比較好解決,只要有約定規範,使用 babel 插件是可以做到自動替換的。
第二個問題目前來看也沒有比較好的辦法,但也有一個折衷的辦法可以把 reamoteEntry.js
的數量降至爲只有一個,也就是建一個庫應用存放所有的公共依賴,缺陷就是解決不了依賴有多個版本並存的問題,因爲在庫應用裏裝不了兩個版本的依賴,如果不需要解決多版本的問題,這種方式比較好一點,這也是目前在極致優化本地項目構建速度的時候採取的方案,依賴關係如下圖所示。
總結
從上面的內容,已經知道了如何使用 MF,清楚了它的原理,瞭解了它的應用場景,現在總結一下它的優缺點。
優點
-
解決方案與框架無關,提供了一種拆分巨石應用的快速方式。
-
解決了多個應用間共享代碼的問題,一個應用可以很方便共享模塊給其他應用使用。
-
提供了一套依賴共享機制,並且支持多版本的依賴共存。
-
基於 Webpack 的生態,學習成本、改造成本、實施成本都比較低。
缺點
-
爲了實現依賴共享,資源需要各種異步加載,可能會對頁面的性能造成負面影響。
-
依賴的遠程應用需要顯式配置其資源路徑,存在版本控制的話,存在和 NPM 包管理一樣的問題。
-
引用遠程應用模塊的時候,沒有類型提示,存在代碼質量問題。
-
缺乏官方工具支持多個應用一起啓動、一起開發。
思考
MF 極致地發揮出模塊動態加載與依賴自動管理的優勢,使得我們對於應用的拆分和代碼的複用有了新的思路。
回到前言,它與微前端方案結合起來的可能性有嗎?答案是必須有的,而且是互補的。MF 專注於在應用間的代碼共享和依賴共享,從原生構建上解決模塊之間的依賴關係無可質疑是最適合的,任何一個框架做都不會完美。而微前端更專注於從宏觀角度上構建一套完整的解決方案,如有對應的框架做應用動態模塊加載、生命週期管理、沙箱管理等,有對應的研發平臺做應用的版本管理,有對應的開發工具解決應用本地開發的問題。
從上面的分析也可以看到,即使 MF 的新特性或許能給我們帶來新的思路,解決了以前比較難解的問題,但也存在一些缺陷,而且某些缺陷可能就成爲技術選型的絆腳石。目前還處於相對不穩定、不完善的階段吧,長期來說,相信官方也會持續優化,從這兩年的改變就能看出來,優化還是蠻大的,值得長期關注。但也並不是只能等待官方來解決這些缺陷,因爲這些問題都是可以解決的,要不要在 MF 的基礎上自行解決,這就要考慮投入產出比的問題了。
無論怎樣,MF 絕對是值得長期關注並投入時間去探索,相信它會與微前端很好地結合起來。
Alibaba F2E 阿里巴巴前端官方公衆號
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/WCQvPbd_w8P-Tn36Sc0SXQ