微前端場景下的代碼共享
前言
在現(xiàn)有前端應(yīng)用日益復(fù)雜化的業(yè)務(wù)場景下,將一個(gè)體積龐大的前端應(yīng)用拆分為幾個(gè)可以獨(dú)立開發(fā)、測試、部署的微應(yīng)用變得越來越普遍。微前端的這種模式這大大提高了我們的構(gòu)建效率,在每次構(gòu)建時(shí)我們不再需要去構(gòu)建一個(gè)龐大的應(yīng)用,而是構(gòu)建我們所需要構(gòu)建的某個(gè)子應(yīng)用。通常在一個(gè)微前端的架構(gòu)下應(yīng)用之間又會(huì)有許多公共的代碼,那么在此基礎(chǔ)上又如何更加靈活更加有效的共用這些代碼呢?(下面介紹的各種方案與微前端的場景并無綁定關(guān)系,只是基于這個(gè)場景更好去說明一些問題)。
Common Solutions
NPM依賴
比較常見的做法是將公有模塊作為npm包發(fā)布,然后作為每個(gè)子應(yīng)用的依賴來引入,但實(shí)際上我們只做到了代碼層面上的共享與復(fù)用,在構(gòu)建時(shí)我們?nèi)匀粫?huì)重復(fù)打包,并沒有真正實(shí)現(xiàn)共享,而且以npm包的形式引入的話也存在一些版本問題,每次公共代碼的更新都得去所有依賴方應(yīng)用升級(jí)這個(gè)版本,發(fā)布成本較大。而且從性能上考慮,若子應(yīng)用A、B、C都為依賴方,那最終頁面上會(huì)加載三份公共npm包的代碼。
Monorepo
還有一個(gè)比較常見的方法就是將公共代碼也作為Monorepo下的一個(gè)子項(xiàng)目,之后將這個(gè)package作為其他應(yīng)用的dependency來import
{
"name": "@package/a",
"dependencies": {
"@package/common": "0.0.1"
}
}
雖然不像 npm 那樣存在版本更新的問題,但是他們也有一個(gè)同樣的問題就是只做到了代碼層面上的共享與復(fù)用,實(shí)際上在構(gòu)建時(shí)還是會(huì)重復(fù)打包,在頁面性能上也會(huì)造成重復(fù)加載問題。
Webpack DLLPlugin
DLL(Dynamic Link Library)文件為動(dòng)態(tài)鏈接庫文件,在Windows中,許多應(yīng)用程序并不是一個(gè)完整的可執(zhí)行文件,它們被分割成一些相對(duì)獨(dú)立的動(dòng)態(tài)鏈接庫,即DLL文件,放置于系統(tǒng)中。當(dāng)我們執(zhí)行某一個(gè)程序時(shí),相應(yīng)的DLL文件就會(huì)被調(diào)用。
形象一點(diǎn)說就是,我們把一部分公用模塊抽離預(yù)先打包成動(dòng)態(tài)鏈接庫,每次構(gòu)建時(shí)我們只需要構(gòu)建我們的業(yè)務(wù)代碼而不需要再去打包構(gòu)建動(dòng)態(tài)庫,除非公用模塊有更新,我們才需要去對(duì)其進(jìn)行打包構(gòu)建。
這里只做簡單介紹:
將需要共享的依賴打包,通過DllPlugin生成manifest.json文件描述對(duì)應(yīng)的dll文件里保存的模塊
module.exports = {
resolve: {
extensions: [".js", ".jsx"]
},
entry: {
alpha: ["./a", "./b"]
},
output: {
path: path.join(__dirname, "dist"),
filename: "MyDll.[name].js",
library: "[name]_[fullhash]"
},
plugins: [
new webpack.DllPlugin({
path: path.join(__dirname, "dist", "[name]-manifest.json"),
name: "[name]_[fullhash]"
})
]
};
需要使用DLL模塊則通過manifest.json文件把依賴名稱映射成DLL模塊上對(duì)應(yīng)的模塊id來require
{"name":"alpha_32ae439e7568b31a353c","content":{"./a.js":{"id":1,"buildMeta":{}},"./b.js":{"id":2,"buildMeta":{}}}}
使用DLL Reference模塊來讀取manifest文件實(shí)現(xiàn)依賴的映射
// webpack.config.js
new webpack.DllReferencePlugin({
context: path.join(__dirname, "..", "dll"),
manifest: require("../dll/dist/alpha-manifest.json")
}),
在入口html文件中插入打包生成的dll文件
<body>
<!--引用dll文件-->
<script src="../dist/dll/MyDll.alpha.js" ></script>
</body>
可以看到這個(gè)配置成本還是蠻高的,實(shí)在不是很友好,但是確實(shí)解決了每次構(gòu)建重復(fù)打包的問題,但是和下文要介紹的external一樣也造成了一些問題,例如入口文件隨著dll文件的插入會(huì)越來越大導(dǎo)致性能的下降
Webpack Externals
再來看看Externals,它解決的問題與上面說到的DLLPlugin差不多,防止將某些依賴打包到bundle中,而是在運(yùn)行時(shí)再去加載這部分依賴,實(shí)現(xiàn)模塊的復(fù)用同時(shí)提高編譯速度
index.html
<script src="https://code.jquery.com/jquery-3.1.0.js"integrity="sha256-slogkvB1K3VOkzAI8QITxV3VzpOnkeNVsKvtkYLMjfk="crossorigin="anonymous"></script>
webpack.config.js
module.exports = {//...
externals: {
jquery: 'jQuery',},
};
剝離掉不需要改動(dòng)的模塊,下面代碼仍能正確運(yùn)行
import $ from 'jquery';
$('.my-element').animate(/* ... */);
可以看到Externals比之前介紹的DLLPlugin在使用上要簡單的多,同時(shí)這也是現(xiàn)在一些微前端框架依賴共享的方式(如Garfish[1])
總結(jié)
上文我們簡單聊了四種代碼共享的解決方案,看起來Externals是最能解決我們的問題的,也是目前一些微前端框架使用的依賴共享方案,既在打包構(gòu)建上解決了重復(fù)打包的問題,提高打包效率,減小了包體積,又在頁面性能上避免了公共代碼的重復(fù)加載,但是Externals并不是那么靈活,同時(shí)也有許多問題:
模塊可能獨(dú)立頁面
如果子應(yīng)用是會(huì)作為獨(dú)立頁面的話,通過external在主應(yīng)用加載依賴就不行了,而需要手動(dòng)引入
公共包不匹配
external是比較受限制的,對(duì)于那些非常通用不需要頻繁更新的依賴可以采用這種方式(如react,react-dom),其他如Redux或者M(jìn)obx等別的依賴不一定是每個(gè)子應(yīng)用都在使用的
性能差
主模塊越來越大,加載了許多該模塊不需要的代碼導(dǎo)致首屏很慢
所以我們是否可以更加動(dòng)態(tài)化得按需加載,而不是像external一樣在入口處加載所有公共代碼呢?那當(dāng)然是有的,就是下文將介紹的Webpack 5新特性Module Federation Plugin
Webpack 5 Module Federation[2]
什么是Module Federation?
官方文檔的解釋為可以讓一個(gè)應(yīng)用與其他應(yīng)用在運(yùn)行時(shí)互相提供或者消費(fèi)各自的模塊,即它可以讓一個(gè)JS應(yīng)用在進(jìn)程中動(dòng)態(tài)地去加載其他應(yīng)用的代碼。為了更加清晰一點(diǎn),我們可以將每個(gè)模塊定義為下面三種角色:
Host(消費(fèi)方): 消費(fèi)其他應(yīng)用內(nèi)容的消費(fèi)方
Remote(被消費(fèi)方):提供部分內(nèi)容給其他應(yīng)用消費(fèi)的一方
Bidirectional-hosts(既是消費(fèi)方也是被消費(fèi)方): 一個(gè)既作為host消費(fèi)其他應(yīng)用的內(nèi)容,又作為remote提供給其他應(yīng)用內(nèi)容的構(gòu)建
我們從一個(gè)官方的demo[3]開始,先簡單介紹下這個(gè)demo做的事情
complete-react-case
├─ component-app 組件層App,依賴lib-app,暴露一些組件,既是Remote也是Host
│ ├─ App.jsx
│ ├─ bootstrap.js
│ ├─ index.js
│ ├─ package.json
│ ├─ public
│ │ └─ index.html
│ ├─ src
│ │ ├─ Button.jsx
│ │ ├─ Dialog.jsx
│ │ ├─ Logo.jsx
│ │ ├─ MF.jpeg
│ │ ├─ ToolTip.jsx
│ │ └─ tool-tip.css
│ └─ webpack.config.js
├─ lib-app 底層App,暴露了一些基礎(chǔ)庫:react, react-dom,屬于一個(gè)remote
│ ├─ index.js
│ ├─ package.json
│ └─ webpack.config.js
├─ main-app 上層App,依賴了lib-app和component-app應(yīng)用,一個(gè)純粹的host
│ ├─ App.jsx
│ ├─ bootstrap.js
│ ├─ index.js
│ ├─ package.json
│ ├─ public
│ │ └─ index.html
│ └─ webpack.config.js
├─ package-lock.json
└─ package.json
我們?cè)賮砜聪耺ain_app的代碼,因?yàn)樗鳛閔ost消費(fèi)了其他兩個(gè)應(yīng)用的代碼
import React from 'lib-app/react';
import Button from 'component-app/Button';
import Dialog from 'component-app/Dialog';
import ToolTip from 'component-app/ToolTip';
export default class App extends React.Component {
constructor(props) {
super(props);
this.state = {
dialogVisible: false,
};
this.handleClick = this.handleClick.bind(this);
this.handleSwitchVisible = this.handleSwitchVisible.bind(this);
}
handleClick(ev) {
console.log(ev);
this.setState({
dialogVisible: true,
});
}
handleSwitchVisible(visible) {
this.setState({
dialogVisible: visible,
});
}
render() {
return (
<div>
<h1>Open Dev Tool And Focus On Network,checkout resources details</h1>
<p>
react、react-dom js files hosted on <strong>lib-app</strong>
</p>
<p>
components hosted on <strong>component-app</strong>
</p>
<h4>Buttons:</h4>
<Button type="primary" />
<Button type="warning" />
<h4>Dialog:</h4>
<button onClick={this.handleClick}>click me to open Dialog</button>
<Dialog switchVisible={this.handleSwitchVisible} visible={this.state.dialogVisible} />
<h4>hover me please!</h4>
<ToolTip content="hover me please" message="Hello,world!" />
</div>
);
}
}
我們可以看到main_app從lib-app里引入了依賴react,又從componet-app里引入了幾個(gè)組件,通過配置Module federation plugin實(shí)現(xiàn)了跨應(yīng)用的代碼復(fù)用,而且配置也非常簡單,下面會(huì)繼續(xù)介紹
import React from 'lib-app/react';
import Button from 'component-app/Button';
import Dialog from 'component-app/Dialog';
import ToolTip from 'component-app/ToolTip';
Module federation的配置
/**
* lib-app/webpack.config.js
*/
{
plugins: [
new ModuleFederationPlugin({
name: 'lib-app',
filename: 'remoteEntry.js',
exposes: {
'./react': 'react',
'./react-dom': 'react-dom'
}
})
],
}
/**
* component-app/webpack.config.js
*/
{
plugins: [
new ModuleFederationPlugin({
name: 'component-app',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/Button.jsx',
'./Dialog': './src/Dialog.jsx',
'./Logo': './src/Logo.jsx',
'./ToolTip': './src/ToolTip.jsx',
}
remotes: {
'lib-app': 'lib_app@http://localhost:3000/remoteEntry.js',
},
})
],
}
/**
* main-app/webpack.config.js
*/
{
plugins: [
new ModuleFederationPlugin({
name: 'main_app',
remotes: {
'lib-app': 'lib_app@http://localhost:3000/remoteEntry.js',
'component-app': 'component_app@http://localhost:3001/remoteEntry.js',
},
})
],
}
這個(gè)就是Module federation的配置,大概想表達(dá)的意思如下圖:
頁面表現(xiàn)
看到這里可能我們還沒啥感覺,不就是從一個(gè)應(yīng)用引另一個(gè)應(yīng)用的代碼嗎,但是接下來要介紹的頁面表現(xiàn)才是令人驚訝的地方,我們來看下main_app的頁面文件加載
可以看到文件的加載順序?yàn)椋?br>
main.js (主模塊)
remoteEntry.js(指向lib-app 113B)
remoteEntry.js(指向component-app 113B)
bootstrap_js.js(主模塊啟動(dòng)文件)
lib-app/react
lib-app/react-dom
component-app/Button
component-app/Dialog
component-app/Tooltip
這里我們就能看到幾個(gè)非常吸引人的點(diǎn)了
Host模塊
main_app的外部依賴不會(huì)被打包進(jìn)入主模塊的main.js,解決了隨著公共代碼使用的增多導(dǎo)致main.js越來越大的問題,其次我們可以看到lib-app暴露的兩個(gè)依賴(react, react-dom)及component-app暴露的組件(Button,Dialog及ToolTip)是分開加載的,作為不同的文件進(jìn)行了構(gòu)建(實(shí)際看build生成也是如此),在動(dòng)態(tài)加載下可以進(jìn)一步提高頁面性能
舉個(gè)例子,我們將上面main_app稍作改造,懶加載Dialog
const Dialog = React.lazy(() => import('component-app/Dialog'));
<button onClick={this.handleClick}>click me to open Dialog</button>
{this.state.dialogVisible && (
<React.Suspense fallback={null}>
<Dialog switchVisible={this.handleSwitchVisible} visible={this.state.dialogVisible} />
</React.Suspense>
)}
看下頁面表現(xiàn):
Remote模塊
那么Remote模塊的性能又會(huì)如何呢?隨著公共代碼的增多會(huì)不會(huì)影響Remote模塊的頁面性能?
暴露出去的每個(gè)外部模塊都是單獨(dú)打包,而不是全部打在remote模塊的入口文件main.js里,如果沒有使用這些代碼的話并不會(huì)加載,因此被消費(fèi)方并不會(huì)因?yàn)楣泊a的增多而影響到頁面性能
源碼解析
加載main_app的入口文件main.js
首先我們來簡單看看作為消費(fèi)方的main_app入口文件main.js里webpack_modules的定義
var __webpack_modules__ = ({
/***/ "webpack/container/reference/component-app":
/*!*********************************************************************!*\
!*** external "component_app@http://localhost:3001/remoteEntry.js" ***!
*********************************************************************/
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {
"use strict";
var __webpack_error__ = new Error();
module.exports = new Promise((resolve, reject) => {
if(typeof component_app !== "undefined") return resolve();
__webpack_require__.l("http://localhost:3001/remoteEntry.js", (event) => {
if(typeof component_app !== "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__);
}, "component_app");
}).then(() => (component_app));
/***/ "webpack/container/reference/lib-app": //.... 與component-app類似
/***/ }),
入口文件定義的webpack_modules即為兩個(gè)被消費(fèi)應(yīng)用的remoteEntry文件,這個(gè)文件看起來就像DLL Plugin里的mainfest.json文件一樣鏈接起了這些應(yīng)用,讓我們隨著頁面的文件加載流程接著往下看
加載啟動(dòng)文件bootstrap.js
(() => {
/*!******************!*\
!*** ./index.js ***!
******************/
__webpack_require__.e(/*! import() */ "bootstrap_js").then(__webpack_require__.bind(__webpack_require__, /*! ./bootstrap.js */ "./bootstrap.js"));
})();
加載完main.js文件后就要開始加載啟動(dòng)文件bootstrap.js了,從之前的加載過程我們看到remoteEntry文件是先于啟動(dòng)文件bootstrap文件的,看上面的代碼這一步應(yīng)該發(fā)生在__webpack_require__.e中,在remoteEntry文件執(zhí)行完后再執(zhí)行bootstrap.js,確保了依賴的前置
webpack_require.e
__webpack_require__.e = (chunkId) => {
return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
__webpack_require__.f[key](chunkId, promises "key");
return promises;
}, []));
};
這里實(shí)際調(diào)用了__webpack_require__.f上的函數(shù),生成一個(gè)promise數(shù)組,并resolve所有的promise
webpack_require.f
接下來看下__webpack_require__.f上的函數(shù)
webpack_require.f.remotes
/* webpack/runtime/remotes loading */
/******/ var chunkMapping = {
/******/ "bootstrap_js": [
/******/ "webpack/container/remote/lib-app/react",
/******/ "webpack/container/remote/component-app/Button",
/******/ "webpack/container/remote/component-app/Dialog",
/******/ "webpack/container/remote/component-app/ToolTip",
/******/ "webpack/container/remote/lib-app/react-dom"
/******/ ]
/******/ };
/******/ var idToExternalAndNameMapping = {
/******/ "webpack/container/remote/lib-app/react": [
/******/ "default",
/******/ "./react",
/******/ "webpack/container/reference/lib-app"
/******/ ],
/******/ "webpack/container/remote/component-app/Button": [
/******/ "default",
/******/ "./Button",
/******/ "webpack/container/reference/component-app"
/******/ ],
/******/ "webpack/container/remote/component-app/Dialog": [
/******/ "default",
/******/ "./Dialog",
/******/ "webpack/container/reference/component-app"
/******/ ],
/******/ "webpack/container/remote/component-app/ToolTip": [
/******/ "default",
/******/ "./ToolTip",
/******/ "webpack/container/reference/component-app"
/******/ ],
/******/ "webpack/container/remote/lib-app/react-dom": [
/******/ "default",
/******/ "./react-dom",
/******/ "webpack/container/reference/lib-app"
/******/ ]
/******/ };
/******/ __webpack_require__.f.remotes = (chunkId, promises) => {
/******/ 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_require__.m[id] = (module) => {
/******/ module.exports = factory();
/******/ }
/******/ };
/******/ handleFunction(__webpack_require__, data[2], 0, 0, onExternal, 1);
/******/ });
/******/ }
/******/ }
這里的代碼量比較多,簡單來說就是通過用當(dāng)前模塊id通過Map找到所依賴remote模塊id再添加具體的依賴到webpack_modules中。
以Button為例,當(dāng)我們加載 src_bootstrap_js 這個(gè) chunk 時(shí),經(jīng)過 remotes,發(fā)現(xiàn)這個(gè) chunk 依賴了component-app/Button
var chunkMapping = {
"bootstrap_js": [
"webpack/container/remote/component-app/Button",
//...
]
};
var idToExternalAndNameMapping = {
"webpack/container/remote/component-app/Button": [
"default",
"./Button",
"webpack/container/reference/component-app"
],
//...
};
// 最終結(jié)果可以簡化為下面這段代碼
__webpack_require__.m[id] = (module) => {
module.exports = __webpack_require__( "webpack/container/reference/component-app").then(componet-app => component-app.get('./Button'))
}
}
可以看出我們對(duì)遠(yuǎn)程模塊componet-app上button的使用其實(shí)就是調(diào)用component-app.get('./Button'),這貌似就是使用了個(gè)全局變量的get方法?這些又是在哪里定義的呢,就是remoteEntry文件!
remoteEntry.js
看下component-app的remoteEntry.js文件,把整個(gè)代碼簡化一下
// 定義全局變量
var component_app;
(() => { //....
(() => {
var exports = __webpack_exports__;
/*!***********************!*\
!*** container entry ***!
***********************/
// 生成暴露出去的模塊Map
var moduleMap = {
"./Button": () => {
return Promise.all([__webpack_require__.e("webpack_container_remote_lib-app_react"), __webpack_require__.e("src_Button_jsx")]).then(() => (() => ((__webpack_require__(/*! ./src/Button.jsx */ "./src/Button.jsx")))));
},
"./Dialog": () => {
return Promise.all([__webpack_require__.e("webpack_container_remote_lib-app_react"), __webpack_require__.e("src_Dialog_jsx")]).then(() => (() => ((__webpack_require__(/*! ./src/Dialog.jsx */ "./src/Dialog.jsx")))));
},
"./Logo": () => {
return Promise.all([__webpack_require__.e("webpack_container_remote_lib-app_react"), __webpack_require__.e("src_Logo_jsx")]).then(() => (() => ((__webpack_require__(/*! ./src/Logo.jsx */ "./src/Logo.jsx")))));
},
"./ToolTip": () => {
return Promise.all([__webpack_require__.e("webpack_container_remote_lib-app_react"), __webpack_require__.e("src_ToolTip_jsx")]).then(() => (() => ((__webpack_require__(/*! ./src/ToolTip.jsx */ "./src/ToolTip.jsx")))));
}
};
// 定義Get方法
var get = (module, getScope) => {
__webpack_require__.R = getScope;
getScope = (
__webpack_require__.o(moduleMap, module)
? moduleMap[module]( "module")
: Promise.resolve().then(() => {
throw new Error('Module "' + module + '" does not exist in container.');
})
);
__webpack_require__.R = undefined;
return getScope;
};
// 定義init方法
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);
};
// 在component_app上定義get, init方法
__webpack_require__.d(exports, {
get: () => (get),
init: () => (init)
});
})();
component_app = __webpack_exports__;
})();
總結(jié)來說這個(gè)文件定義了全局的 component-app變量,然后提供了一個(gè) get 函數(shù),通過get函數(shù)來加載moduleMap里具體的模塊,即通過全局變量鏈接了兩個(gè)應(yīng)用。
webpack_require.f.j
webpack_require.f上的另一個(gè)方法,也是實(shí)際加載模塊的方法,這里加載了bootstrap.js文件,這個(gè)函數(shù)沒啥好說的,就是webpackJsonp異步加載,但也正因?yàn)槿绱瞬疟WC了文件的加載順序
加載流程
加載應(yīng)用入口文件main.js
準(zhǔn)備加載啟動(dòng)文件bootstap.js
加載啟動(dòng)文件前發(fā)現(xiàn)有依賴的remotes模塊
動(dòng)態(tài)加載依賴的remotes模塊
加載執(zhí)行完所有的前置依賴后再加載bootstrap.js
所有依賴加載完成,再執(zhí)行then邏輯,即webpack_require( "./bootstrap.js")
在啟動(dòng)應(yīng)用時(shí),進(jìn)行了依賴的前置分析,通過生成的remoteEntry文件內(nèi)的全局變量來get依賴的內(nèi)容,最后再執(zhí)行業(yè)務(wù)代碼。
與微前端的結(jié)合
對(duì)比微前端框架現(xiàn)在普遍使用的Externals方式,顯然Module Federation是個(gè)更好的解決方案,
主應(yīng)用
通過主應(yīng)用可以導(dǎo)出一些公共庫及公共組件等,但并不影響主應(yīng)用頁面性能
子應(yīng)用
動(dòng)態(tài)加載主應(yīng)用暴露的公共庫及公共組件,減小了入口文件的大小,提高了頁面性能
總結(jié)
Module Federation Plugin通過提供鏈接文件remoteEntry.js的cdn地址即可將不同的應(yīng)用連接起來,不僅局限于微前端場景,即使不同的項(xiàng)目工程也可以讓我們輕松做到更細(xì)粒度的代碼共享。
作者:ELab.liuhexiang
歡迎關(guān)注微信公眾號(hào) :前端民工