從微組件到代碼共享
隨著前端應(yīng)用越來越復雜,越來越龐大。前有巨石應(yīng)用像滾雪球一般不斷的疊高,后有中后臺應(yīng)用隨著歷史長河不斷地積累負債,或者急需得到改善。微前端的工程方案在前端er心中像一道曙光不斷的被提起,被實踐,多年至今終于有了比較好的指引。它在解決大型應(yīng)用之間復雜的依賴關(guān)系,或是解決我們技術(shù)棧的遷移歷史負擔,都在一定程度上扮演了極其關(guān)鍵的橋梁。
本文會先從復用組件,窺探到代碼共享。聊一聊中后臺項目在微前端的場景下,從工程化的角度下如何跨技術(shù)棧復用業(yè)務(wù)組件,再介紹一下其它的共享代碼方案。
在正文開始之前,希望讀者能對以下關(guān)鍵詞有所了解,以便后文一起交流探討
微前端 共享組件 Garfish(字節(jié)開源的微前端框架) Webpack & module federation Bit
業(yè)務(wù)背景

如上圖,我們先看這么個場景。這個 modal 被紅色框起來的部分,其實是一個業(yè)務(wù)復雜較復雜的react組件來渲染的。在這里就需要渲染出5個react組件。同時這個modal是過去用vue實現(xiàn)的代碼,我們的react組件是需要被渲染在vue代碼中的,也就是 React in Vue。
在我們的中后臺系統(tǒng)里,過去全都是vue的技術(shù)棧。而我們新的業(yè)務(wù)希望全面的往react遷移,其中不乏有比較復雜的業(yè)務(wù)組件。如下



基于微前端的工程方案,我們就可以盡可能少的修改vue的代碼。同時,我們也能達到組件級別的嵌入。
從工程的角度解決微組件共享
項目介紹
先試想一下,其實大多數(shù)中后臺項目,都是像如上的場景一般。我們可能僅是為了應(yīng)用之間的解耦,這有利于構(gòu)建,團隊獨立維護,改善項目結(jié)構(gòu),代碼復用等等。其實更需要解決的是團隊內(nèi)部自身的工程問題,基本不會涉及到跨產(chǎn)品部門的復用或業(yè)務(wù)共享。我們更多關(guān)注的是,當下在不同repo之間的代碼和在不同技術(shù)棧之間的組件,如何達到共享。那么我們需要共享微組件的職責就很清晰了。
在我們團隊的中后臺應(yīng)用有三個repo,過去的巨石應(yīng)用(vue),新建的兩個monorepo(react)。(拆了兩個是業(yè)務(wù)之間比較獨立。)

在我們有了monorepo之后,其實所有的業(yè)務(wù)組件或者業(yè)務(wù)代碼,都已經(jīng)在物理的層面上可以良好的復用。剩下的問題就在于如何跨repo(跨物理層面)在過去的技術(shù)棧(vue)中直接復用。而我們的方式就是基于微前端來做。

當我們有了master這樣的宿主介入之后,項目的可操作空間就不太一樣了。微前端為的是能在同一個應(yīng)用下,提供一個相同的運行環(huán)境。(本文不過多探討iframe的方式。)
monorepo能很好地解決我們同一個repo下的代碼復用問題。如果我們把每一個 repo 都抽象的看做一個模塊,那就只需要想辦法在這個模塊能exports東西出去,不就可以達到跨repo之間的復用?同時它也是一種解決了物理層面上無法復用的手段。

所以我們的做法就變得很清晰了,在新的react repo里,其實我們就會自然的沉淀下許許多多的基礎(chǔ)組件或者是帶有復雜業(yè)務(wù)的業(yè)務(wù)組件。比如上圖的biz-ui,每一個biz-ui里的組件,都是一個完整的業(yè)務(wù)組件。而我們最終的目標,就是想辦法把這些業(yè)務(wù)組件通過微前端的方式,給其它項目使用。
Micro-components app 子應(yīng)用,就是我們的exports,它也是一個子應(yīng)用。所有需要在當前repo exports的業(yè)務(wù)組件,都可以在這里被注冊。
利用子應(yīng)用復用微組件
從一個用法開始

如果是一個組件很簡單,也很好實現(xiàn),我們知道garfish有提供loadApp的接口,我們可以直接通過加載一個子應(yīng)用,這個子應(yīng)用渲染某個react組件。大致代碼如下
// loadApp.vue
<template>
<div :id="id"></div>
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
let id = 9999;
let beforeDestroy: (() => void) | undefined = undefined;
export default defineComponent({
props: [],
data() {
return {
id
};
},
async mounted() {
_const_ app = _await_ Garfish.loadApp('xxx', {
domGetter: () => document.getElementById(this.id),
})
_// 渲染:編譯子應(yīng)用的代碼 -> 創(chuàng)建應(yīng)用容器 -> 調(diào)用 provider.render 渲染_
_const_ success = _await_ app.mount();
},
beforeDestroy() {
console.info(this.microComponentKey, '微前端組件卸載');
beforeDestroy?.();
},
watch: {
},
});
</script>
這樣的代碼在我們系統(tǒng)里還是跑了幾個月的,沒有任何問題。但是如果有了多例就不一樣了,我們會調(diào)用多次loadApp,加載了大量子應(yīng)用的代碼,導致性能很差,甚至直接卡死。有人說加cache行不行?其實也是不可行的,上述的代碼過于簡陋,我們還需要處理props變化的情況,以及l(fā)oadApp,傳遞props給react的情況。如果單純只是cashe解決不了這樣的場景。
所以我們特意設(shè)計了一個子應(yīng)用,這個子應(yīng)用專門作為組件級別的渲染,暫且稱之為 微組件子應(yīng)用

而在vue那,我們需要保證全局只會load 一個微組件子應(yīng)用,這個子應(yīng)用的domGetter可掛在到body上,僅僅作為一個container。而我們的react組件,全通過portal的形式進行渲染到任意位置即可。
基于這個思路,我們需要去設(shè)計一個微組件渲染的數(shù)據(jù)結(jié)構(gòu)。再看一眼這個圖,我們這個數(shù)據(jù)結(jié)構(gòu)會有哪些東西

每個組件其實所需要接收的參數(shù)有domId、props和事件或其它屬性。所以我們的數(shù)據(jù)結(jié)構(gòu)其實可以大致如下。
type Meta = {
domId: string;
componentKey: string; // 為了指定由哪個組件渲染
props?: Record<any, any>;
[_key_: string]: any; // 事件和其它透傳屬性
};
有了這個結(jié)構(gòu),我們 react 的 render 函數(shù)就簡單了,統(tǒng)一渲染一個protal數(shù)組即可。
portalRender.map(_meta_ => {
const { domId, componentKey, props: _props, ...rest } = _meta_;
const container = document.getElementById(domId);
if (!container) {
return null;
}
return ReactDOM.createPortal(
<Suspense _fallback_={null}>
<Portals
_componentKey_={componentKey}
{...{ domId, ..._props, ...rest }}
/>
</Suspense>,
container,
domId,
);
})
在vue這邊,我們先設(shè)想一下應(yīng)該如何使用這樣的組件呢?當然肯定是和單純的一個vue組件沒有區(qū)別。比如這樣。


所以我們就需要封裝一個底層的vue組件去負責管理子應(yīng)用的load和props的傳遞。
// loadCMSMicro.vue 偽代碼
<template>
<div :id="id"></div>
</template>
<script>
import { microComponentManager } from '../src/MicroComponentManager';
let id = 0;
export default {
data() {
return {
id: `${++id}`,
beforeDestroy: undefined,
};
},
props: {
props: {
required: false,
default: () => ({}),
},
componentKey: {
type: String,
require: true,
},
subAppName: {
type: String,
require: true,
default: '',
},
},
async mounted() {
const { unMount, error } = await MicroComponent.loadComponent();
this.beforeDestroy = unMount;
},
beforeDestroy() {
this.beforeDestroy && this.beforeDestroy({ domId: this.id, type: 'remove' });
},
};
</script>
而MicroComponent,需要去負責保持只能load一個子應(yīng)用單例以及props的傳遞和變化。
class MicroComponent {
private _loaded = false;
private _app: any;
private _count = 0;
async loadComponent() {
try {
this._count++;
if (!this._loaded) {
this._loaded = true;
this._app = await window.Garfish.loadApp(this._subAppName, {
domGetter: () => document.body,
props: {
subAppName: this._subAppName,
},
});
await this._app.mount();
}
const unMount = (_params_: PropsChange) => {
this.emitPropsChange(_params_);
this._count--;
if (this._count === 0) {
console.info('[微組件] 子應(yīng)用卸載了');
this._app.unmount();
this._loaded = false;
this._app = null;
}
};
if (!this._app) {
return {
unMount,
};
}
console.info('[微組件] 加載完畢');
this._debounceEmitPropChange();
return {
unMount,
};
} catch (e) {
console.error(`[微組件] 子應(yīng)用加載失敗: ${e}`);
this._loaded = false;
this._app = null;
this._count = 0;
return {
error: 'CMS 加載子應(yīng)用失敗',
};
}
}
}
我們需要用兩個flag來控制mount和unmunt。為了保證只能load一個子應(yīng)用,用一個loaded開關(guān)來控制。而count是因為我們有多例其實就是個引用計數(shù),必須保證每個微組件都卸載了,才能去unmount掉我們的子應(yīng)用。
props如何傳遞呢?這里其實就是如何進行不同應(yīng)用之間的數(shù)據(jù)共享,同時他是保持一份的。我們可以通過garfish提供的API來實現(xiàn)。

基于這2個API,我們可以在garfish上構(gòu)建出這么個對象來傳遞我們的數(shù)據(jù)。在之前提到過,我們可能是多個子應(yīng)用export出來的組件,其實這部分的數(shù)據(jù)存儲就是一個二維結(jié)構(gòu)。
garfish[subAppName][domId] = {
domId: 1,
props: {},
...rest,
}
當我們初始化一個vue的組件時,就需要把對應(yīng)的meta數(shù)據(jù)掛載到garfish上。修改一下我們剛剛上面的組件代碼
...
export default {
...
async mounted() {
const formatEvents = Object.keys(this.$listeners).reduce((_pre_, _cur_) => {
_pre_[toUpper(_cur_)] = this.$listeners[_cur_];
return _pre_;
}, {});
microComponentManager.setMeta(this.subAppName, this.id, {
...formatEvents,
...this.$props,
});
const module = microComponentManager.getSubApp(this.subAppName);
const { unMount, error } = await module.loadComponent();
this.beforeDestroy = unMount;
},
...
};
</script>
因為需要保持每一個子應(yīng)用都是唯一的單例,我們繼續(xù)引入microComponentManager來幫我們管理所有的子應(yīng)用實例。
搞定了初始化和數(shù)據(jù)傳遞的的問題后,我們來思考一下props change的問題。其實也很簡單,只要三個步驟。
監(jiān)聽vue組件的props變化,重新修改數(shù)據(jù)set到garfish上 發(fā)送事件,通知react獲取最新的數(shù)據(jù) React rerender
<script>
// vue
export default {
...
watch: {
props: {
immediate: true,
deep: true,
handler(_newProps_) {
const module = microComponentManager.getSubApp(this.subAppName);
microComponentManager.setMeta(this.subAppName, this.id, {
...microComponentManager.getMeta(this.subAppName, this.id),
..._newProps_,
});
// 發(fā)送事件通知react
module.emitPropsChange({ domId: this.id, type: 'new' });
},
},
},
};
</script>
react組件則接收到事件后,對數(shù)據(jù)進行更新,重新渲染
// react
export const MicroContainer = (_props_: Props) => {
const { subAppName, microComponents } = _props_;
const [portalRender, setPortalRender] = useState<Meta[]>([]);
const pendingUpdate = useRef<Meta[]>([]);
const { run } = useDebounceFn(() => {
setPortalRender([...pendingUpdate.current]);
}, 10);
const onChange = (_params_: PropsChange[]) => {
const removeIds = _params_.filter(_item_ => _item_.type === 'remove');
const updateIds = _params_.filter(_item_ => _item_.type === 'new');
if (removeIds.length > 0) {
pendingUpdate.current = pendingUpdate.current.filter(_item_ => {
return removeIds.find(_elm_ => _elm_.domId !== _item_.domId);
});
}
updateIds.forEach(({ _domId_ }) => {
const meta = microComponentManager.getMeta(subAppName, _domId_);
const { componentKey, ...rest } = meta;
const target = pendingUpdate.current.find(_item_ => _item_.domId === _domId_);
if (target) {
Object.assign(target, rest);
} else {
pendingUpdate.current.push({
...rest,
domId,
componentKey,
});
}
});
run();
};
useEffect(() => {
microComponentManager.on(
MICRO_COMPONENT_EVENTS.PROPS_CHANGE,
onChange,
subAppName,
);
return () => {
microComponentManager.off(
MICRO_COMPONENT_EVENTS.PROPS_CHANGE,
onChange,
subAppName,
);
};
}, []);
return (
...
);
};
我們的MicroComponent也需要增加相應(yīng)的事件發(fā)送代碼。
export class MicroComponent {
private _loaded: boolean = false;
private _app: any;
private readonly _subAppName: string;
private _count: number = 0;
private _pendingPropsChange: PropsChange[] = [];
private readonly _debounceEmitPropChange: (..._args_: any[]) => void;
constructor(_subAppName_: string) {
this._subAppName = _subAppName_;
this._debounceEmitPropChange = debounce(
() => this._checkPendingProps(),
50,
);
}
async loadComponent() { ... }
emitPropsChange(_params_: PropsChange) {
this._pendingPropsChange.push(_params_);
this._debounceEmitPropChange();
}
private _checkPendingProps() {
setTimeout(() => {
_// 放到下一個 macrotask 里執(zhí)行,等待微前端框架和子應(yīng)用渲染完畢_
if (this._pendingPropsChange.length === 0 || !this._app) {
return;
}
this.emit(MICRO_COMPONENT_EVENTS.PROPS_CHANGE, this._pendingPropsChange);
this._pendingPropsChange = [];
});
}
emit(_event_: keyof typeof MICRO_COMPONENT_EVENTS, _params_?: PropsChange[]) {
window.Garfish.channel.emit(genEventKey(this._subAppName, _event_), _params_);
}
}
我們用一個pending隊列來存放所有的事件,這是避免一瞬間發(fā)送過多事件導致無意義的開銷。比如一個列表的頁面,可能同時創(chuàng)建了100個微組件,此時如果不做一次debounce則會一瞬間發(fā)送100次。一個優(yōu)化的小細節(jié)。
另外需要注意的是注意到我們發(fā)送事件的地方用了個setTimeout,這是由于我們的app.mount,其實僅僅只是把子應(yīng)用給渲染完了,此時不代表react的組件被渲染完畢,我們在react里的useEffect還是沒有執(zhí)行的。所以我們需要放到下一個macroTask來發(fā)送事件,為了保證react里先監(jiān)聽。
以上其實就是整套方案的核心代碼了
總結(jié)
總的來說,我們的實現(xiàn)方案就是基于loadApp,把一個子應(yīng)用僅僅當做多應(yīng)用之間渲染和通信的媒介掛在在了body上。所有的組件都通過portal的方式,掛載到指定的dom位置上。
優(yōu)勢
原理代碼實現(xiàn)簡單輕量,復用便捷,開發(fā)高效,無關(guān)技術(shù)棧 接入簡單,可以實現(xiàn)ReactInVue,VueInReact 無論需要復用多少個組件,都只需要load1個子應(yīng)用,開銷低 可以掛載到任何garfish的應(yīng)用里,組件復用,達到跨團隊級別的復用 只需要發(fā)布一次,所有地方全都生效且最新版本 可以跨repo搭建自己需要共享的組件子應(yīng)用
劣勢
無法對組件版本進行管理 需要基于garfish的環(huán)境才能達到共享 需要創(chuàng)建一個子項目,相比共享組件的方案更重 keep-alive場景下可能有問題 依賴管理不方便控制(React,組件庫等)
可以看出這個方案也有一個最大的局限性。版本不可控,在我們的業(yè)務(wù)里是不需要對這樣需要共享的組件進行版本管理的。以下介紹的方案大家需要注意下,如果你的共享組件需要版本管理則不可采用這種方案。所以,我們再來看看,現(xiàn)在共享組件的標準實現(xiàn)方案。
運行時組件市場
我們上述的方案,其實是通過組件復用的場景細分采用工程化的方案來解決物理隔離,技術(shù)棧不同的組件復用。而如果我們需要一個更加通用化的微組件方案,必然會需要平臺的支持,版本的支持,loader的支持。所以我們來看看現(xiàn)有的組件市場的發(fā)展方向。
Garfish 提供了 loadComponent[1] 的API,可以直接遠程加載一個組件資源。在現(xiàn)有的設(shè)計下,大多數(shù)這個資源都是一個已經(jīng)被編譯好的umd的js文件。


不過在字節(jié)內(nèi)部的另一個微前端框架有另外一種設(shè)計,使用的API與 federation 非常相似。
以上的例子無論是哪種API的設(shè)計,都不妨礙我們深入理解微組件。不難發(fā)現(xiàn),需要抽象一個微組件必須具備的API需要有
Load(指定資源,無論是key還是url) mount/unmout (生命周期) update (props change)
當組件的API被合理的設(shè)計好之后,我們還有一個關(guān)鍵就在于如何管理這些組件。于是「組件市場」就這么誕生了。組件市場必須具備的職責只需要兩點
組件的上傳與下架 可以是以name的方式或者url的方式下載代碼
以往我們已經(jīng)現(xiàn)有的物料平臺或者是區(qū)塊平臺,都可以很簡單且自然的支持這兩個功能。

共享代碼
其實上面講了兩種微組件的方案。我們可以擴展性的思考一下,共享組件其實就是共享代碼的一種細分,解決了共享代碼,我們就順便解決了共享組件的問題。而往往共享代碼會有更大的使用場景。
Module Federation
概念
Module Federation(以下簡稱MF)的中文直譯為“模塊聯(lián)邦”,從Webpack官網(wǎng)中我們可以找到使用其的動機:
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想要達到的目的就是把多個無相互依賴、單獨部署的應(yīng)用合并為一個應(yīng)用,即MF提供了在某個應(yīng)用中可以遠程加載其他服務(wù)器上應(yīng)用的能力。對于MF來說,有兩種角色:
Host:引用了其他應(yīng)用的應(yīng)用 Remote:被其他應(yīng)用所使用的應(yīng)用



同時,一個應(yīng)用既可以作為host也可以作為remote,即可以利用MF實現(xiàn)一個去中心化的應(yīng)用部署群。并且,MF允許應(yīng)用之間共享依賴實例,例如:host使用了react,remote也使用了react,remote經(jīng)過配置后,可以在被host加載運行時優(yōu)先使用host的react實例,而不會重復加載,這樣可以做到在一個應(yīng)用中只存在一個react實例。
示例
我們將使用Webpack官網(wǎng)[2]給出的demo[3]作為示例,向大家展示如何使host應(yīng)用(app1)在運行時動態(tài)加載并使用remote應(yīng)用(app2)的內(nèi)容。先來看看demo中的文件結(jié)構(gòu):
app1 App.js(react頁面入口) bootstrap.js(項目啟動文件) index.js(項目入口文件) src webpack.config.js(webpack配置文件) app2 App.js(react頁面入口) Button.js(Button Component) bootstrap.js(項目啟動文件) index.js(項目入口文件) src webpack.config.js(webpack配置文件)
app1和app2是兩個獨立部署的應(yīng)用。
下面來看看app1中的具體代碼內(nèi)容:
// app1 index.js
import bootstrap from "./bootstrap";
bootstrap(() => {});
// app1 bootstrap.js
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
ReactDOM.render(<App />, document.getElementById("root"));
// app1 App.js
import React from "react";
const RemoteButton = React.lazy(() => import("app2/Button"));
const App = () => (
<div>
<h1>Basic Host-Remote</h1>
<h2>App 1</h2>
<React.Suspense fallback="Loading Button">
<RemoteButton />
</React.Suspense>
</div>
);
export default App;
可以發(fā)現(xiàn)App.js中有一行非常關(guān)鍵的代碼:
const RemoteButton = React.lazy(() => import("app2/Button"));
那么問題來了:
這個 app2/Button是從哪里來的呢?這一段引用的組件代碼長啥樣?
我們先來看看app2項目中的webpack配置(這里我們就不貼app2的代碼內(nèi)容了,因為沒有什么特別的地方并且在這里并不需要關(guān)心):
// app2 webpack.config.js
// ...
new ModuleFederationPlugin({
// 作為remote時的模塊名
name: "app2",
library: { type: "var", name: "app2" },
// export的內(nèi)容被打成包時的文件名
filename: "remoteEntry.js",
// 作為remote時,export哪些內(nèi)容被host消費
exposes: {
"./Button": "./src/Button",
},
// 作為remote時,優(yōu)先使用host的這些依賴,若host沒有,則再用自己的
shared: { react: { singleton: true }, "react-dom": { singleton: true } },
}),
// ...
從上面配置可以知道:
app2項目作為remote時的模塊名是app2; export的內(nèi)容是Button組件; 要export的內(nèi)容會獨立打包成一個名叫remoteEntry.js的文件; export的內(nèi)容在被host消費時,會優(yōu)先使用host的react和react-dom實例。
那么app1中又是如何配置使用app2模塊的內(nèi)容的呢,下面我們來看看app1的webpack配置中關(guān)于MF的部分:
// app1 webpack.config.js
// ...
new ModuleFederationPlugin({
// 作為remote時的模塊名
name: "app1",
// 作為host時會消費哪些remote的資源
remotes: {
app2: 'app2@localhost://3002',
},
// 作為remote時,優(yōu)先使用host的這些依賴,若host沒有,則再用自己的
shared: {
react: { singleton: true },
"react-dom": { singleton: true }
},
}),
// ...
從上面配置中中可以知道app1中使用了跑在localhost:3002上的app2模塊內(nèi)容。至此,在app1如何配置使用app2內(nèi)容的問題就解決了。
把項目跑起來,可以看到app1的頁面,從前面的代碼可以知道,App2 Button組件是來自app2中的。

并且可以看到,app1下載了app2的remoteEntry.js文件,并使用了里面的相關(guān)內(nèi)容,共享代碼成功。
實現(xiàn)原理
在講MF的實現(xiàn)原理之前,我們先來簡單簡單講下webpack的模塊打包原理,這對理解MF的模塊原理至關(guān)重要,如果你對這部分內(nèi)容已經(jīng)熟知,可以跳過。
先看個簡單的栗子??(webpack配置沒有什么特殊的,這里就不貼了):
// moduleA.js
export function aFn() {console.log('A')};
// moduleB.js
export function bFn() {console.log('A')};
// index.js 項目主入口文件
import { aFn } from './ModuleA';
// 或動態(tài)import
import('./ModuleB').then((module) => module.bFn());
經(jīng)過webpack打包后形成兩個chunk文件:
main.js (其中包含index.js和ModuleA.js的內(nèi)容) src_ModuleB_js.js
來看看main.js里的內(nèi)容(簡化過后):
// main.js
(() => {
// 保存著main chunk中的所有模塊,key是module id,value是模塊內(nèi)容
// __unused_webpack_module: 當前模塊
// __webpack_exports__: 模塊的導出
// __webpack_require__: 模塊加載對象
var __webpack_modules__ = ({
"./src/ModuleA.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { /**內(nèi)容省略**/ }),
"./src/index.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { /**內(nèi)容省略**/ }),
});
// 保存已加載的模塊
var __webpack_module_cache__ = {};
// 模塊加載方法
function __webpack_require__(moduleId) {
// 檢查是否已加載過該模塊,若是則直接返回模塊的exports對象
var cachedModule = __webpack_module_cache__[moduleId];
if (cachedModule !== undefined) {
return cachedModule.exports;
}
// 創(chuàng)建一個模塊緩存,并放進__webpack_module_cache__中
var module = __webpack_module_cache__[moduleId] = {
id: moduleId,
loaded: false,
exports: {}
};
// 執(zhí)行模塊加載方法,并將模塊內(nèi)容掛在到module.exports上
__webpack_modules__[moduleId].call(module.exports, module, module.exports, __webpack_require__);
// 標記該模塊已加載
module.loaded = true;
// 返回模塊的exports對象
return module.exports;
}
// expose the modules object (__webpack_modules__)
__webpack_require__.m = __webpack_modules__;
// startup
// Load entry module and return exports
__webpack_require__("./src/index.js");
})();
這就是整個項目的啟動文件,其實就是一個IIFE。
其中內(nèi)部變量__webpack_modules__維護了一個該chunk所包含的所有modules的map,key就是module id,value就是模塊內(nèi)容。
從上面的main.js中可以知道其實__webpack_require__模塊加載的核心所在,主要做了兩件事:
先從緩存的模塊列表中尋找,若找到直接返回該模塊的內(nèi)容; 若在緩存模塊列表中未找到,則執(zhí)行該模塊的加載函數(shù)并加入緩存列表中。
當我們是動態(tài)import時則會調(diào)用__webpack_require__.e。
var _ModuleA__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./ModuleA */ "./src/ModuleA.js");
__webpack_require__.e(/*! import() */ "src_ModuleB_js").then(
__webpack_require__.bind(__webpack_require__, /*! ./ModuleB */ "./src/ModuleB.js")
).then(
module => module.bFn()
);
至此可以發(fā)現(xiàn)__webpack_require__.e只是返回了一個promise,然后再執(zhí)行了__webpack_require__方法。可見,在__webpack_require__.e執(zhí)行完成后,main chunk中的__webpack_modules__就會有ModuleB的內(nèi)容,這是怎么做到的呢:
簡單來說就是main chunk中維護了一個__webpack_modules__的map,用于維護該chunk中有哪些module,而其他的chunk,也會將自己內(nèi)部的modules加到main chunk的__webpack_modules__。
講到這里,想必那么MF的實現(xiàn)方式,會不會也是將下載好的遠程模塊放進主chunk所維護的模塊列表,從而實現(xiàn)代碼共享 ??。
仔細看了上面的MF Demo打包后的結(jié)果,發(fā)現(xiàn)果真如此。下面讓我們來簡單看看下面兩個問題:
app1如何下載和使用app2的代碼; app1與app2如何實現(xiàn)依賴共享。
來看看從app2的remoteEntry.js里的實現(xiàn),它了一個全局變量 app2,它的值為一個包含init和get方法的對象:
// app2/remoteEntry.js
var app2
(() => {
var moduleMap = {
"./Button": () => {
return Promise.all([
__webpack_require__.e("webpack_sharing_consume_default_react_react-_2849"),
__webpack_require__.e("src_Button_js")]).then(() => (
() => ((__webpack_require__(/*! ./src/Button */ "./src/Button.js")))
));
}};
var get = (module, getScope) => {
// 內(nèi)容省略
};
var init = (shareScope, initScope) => {
// 內(nèi)容省略
};
// app2的賦值過程遠比這個復雜,這里為了便于讀者理解刪去了許多代碼
app2 = {
get: () => (get),
init: () => (init),
};
})()
既然要從app2下載代碼,那么main.js中的__webpack_modules__必然維護著app2/remoteEntry.js的模塊加載方法:
var __webpack_modules__ = [
// ...
{
"webpack/container/reference/app2": ((module, __unused_webpack_exports, __webpack_require__) => {
"use strict";
module.exports = new Promise((resolve, reject) => {
if(typeof app2 !== "undefined") return resolve();
__webpack_require__.l("//localhost:3002/remoteEntry.js", (event) => {
if(typeof app2 !== "undefined") return resolve();
// 省略一堆error定義
reject(new Error());
}, "app2");
}).then(() => (app2));
})
},
// ...
]
其中調(diào)用了__webpack_require__.l來下載app2/remoteEntry.js文件,具體代碼不貼了,簡單講講這個方法做了那些事情:
新建一個script標簽 src設(shè)置為app2/remoteEntry.js的地址 將script標簽添加到document中 下載結(jié)束后執(zhí)行回調(diào)方法(第二個參數(shù))
而federation實現(xiàn)的核心在于加載器的變化__webpack_require__.e。通過之前的介紹,我們知道它的功能就是異步加載模塊。但是在federation中它就完全不一樣了,他會作為remote的加載器!
__webpack_require__.e = (chunkId) => {
return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
__webpack_require__.f[key](chunkId, promises "key");
return promises;
}, []));
};
核心關(guān)鍵就在于__webpack_require__.f對象 我們可以把f理解為federation的縮寫。在.f上掛了3個方法分別為
webpack_require.f.j 負責創(chuàng)建script加載代碼
webpack_require.f.consumes 負責執(zhí)行 app2.init
webpack_require.f.remotes 負責執(zhí)行 app2.get

到這里基本我們就明白了,federation基于__webpack__require__這個對象作為window上的runtime,而f這個對象管理了其它應(yīng)用的依賴和初始化。在federation下,每一個模塊(main.js 或 remoteEntry.js)其實都是一個__webpack_modules__,是一個不斷套娃的過程。
總結(jié)一下,federation給我們前端模塊化和應(yīng)用模塊化打開了一種新的思路,他基于window(實際上是__webpack_require__)這個橋梁作為不同的模塊和應(yīng)用之間的通信媒介。而host和expose本身就是一種場景的設(shè)計,不難發(fā)現(xiàn),我們前文所述的微組件解決方案也是基于這種抽象的思維(基于微前端把repo直接作為host和expose)來實現(xiàn)的。
而 federation 也有一些局限性,比如我們必須要求新項目都是webpack5以上,我們的技術(shù)棧需要保持一致,共享代碼時在runtime下如何解決單例問題,在TS中的話,還需要去考慮如何共享類型的問題等等。
應(yīng)用場景
federation 還有許多實用的場景
一、當我們是一個巨大的應(yīng)用想要拆分獨立部署和構(gòu)建,但是host和subapp之間又有應(yīng)用之間的依賴需要共享同時我們的依賴是有狀態(tài)關(guān)系的。我們可以人為的抽離一個shared層,把需要復用的api或組件放在這個shared層上,不同的sub之間直接互相使用。

二、另外在某些微前端的場景下,我們的路由配置表其實是可以通過federation直接進行共享,無需統(tǒng)一配置在master上。
三、federation還能解決構(gòu)建時長的問題。比如Umi甚至通過federation帶來的靈感解決了構(gòu)建時長[4]的問題。有興趣的可以點擊鏈接看一看。

Bit
一句話介紹Bit:是一個集成了npm + git功能,組件文檔,可視化,CI/CD一站式的標準化的組件管理平臺
提到代碼復用,就不得不說一下bit這個平臺。bit整體使用上手都非常簡單,由于篇幅原因就不過多介紹。首先跟著官網(wǎng)教程[5]走一遍,初始化一個bit 組件庫workspace并且發(fā)布好一個組件。
我們在任意一個已有的項目下,我們通過bit init 即可初始化我們的workspace。再通過 bit import 來“download”一個組件,比如我們這里就 bit import meckodo.test/ui/button

修改一下這個默認的組件代碼。比如這里我們把div換成了button
// before
export function Button({ _text_ }: ButtonProps) {
return <div>{_text_}</div>;
}
// after
export function Button({ _text_ }: ButtonProps) {
return <button _type_="button">{_text_}</button>;
}
修改完成后,做一個類似git一樣的提交 bit tag --all --message "change to button" 再通過bit export 發(fā)布一個新版本

到官網(wǎng)上就可以預覽到我們更新的組件了
before:

after:

不難發(fā)現(xiàn),bit的好處就在于。我們?nèi)我庖粋€項目都可以非常方便的“download”(import)組件同時,在當前項目下很方便的直接發(fā)布(export)新版本。bit不僅僅支持了組件的形式,其實還支持了普通的js/ts代碼。在團隊內(nèi)部的業(yè)務(wù)下,如果有這樣跨repo級別共享代碼的需求就會非常方便。
總結(jié)
本文介紹在微前端項目中我們是如何跨項目跨技術(shù)棧復用組件的的使用場景,進而思考到其他工具的是如何復用代碼的原理和更廣泛的適用范圍。
其中較為重要的個人認為是去熟悉內(nèi)在的一些思想。深入的思考分層和抽象搭建新的“橋梁”,如何去尋找“橋梁”把不同的模塊組織起來。會發(fā)現(xiàn)前文所說的工程角度來解決組件的共享,其實就是基于garfish這個橋梁,對共享的數(shù)據(jù)進行了一些同步,這就和webpack的__webpack__require__有異曲同工之處。而把repo抽象為模塊,針對性的進行exports,也是從federation中借鑒了靈感。
參考鏈接
https://garfish.top/
探索 webpack5 新特性 Module federation 在騰訊文檔的應(yīng)用[7]
webpack 打包的代碼怎么在瀏覽器跑起來的[7]
https://mp.weixin.qq.com/s/zhkgudIjeuKWi536U5pJhw
參考資料
loadComponent: https://github.com/bytedance/garfish/wiki/API#loadcomponentname-string-options-loadcomponentoptions--component
[2]Webpack官網(wǎng): https://webpack.js.org/concepts/module-federation/
[3]demo: https://github.com/module-federation/module-federation-examples/tree/master/basic-host-remote
[4]構(gòu)建時長: https://umijs.org/zh-CN/docs/mfsu
[5]官網(wǎng)教程: https://harmony-docs.bit.dev/getting-started/initializing-workspace
[6]探索 webpack5 新特性 Module federation 在騰訊文檔的應(yīng)用: http://www.alloyteam.com/2020/04/14338/
[7]webpack 打包的代碼怎么在瀏覽器跑起來的: https://segmentfault.com/a/1190000022669224
內(nèi)推社群
我組建了一個氛圍特別好的騰訊內(nèi)推社群,如果你對加入騰訊感興趣的話(后續(xù)有計劃也可以),我們可以一起進行面試相關(guān)的答疑、聊聊面試的故事、并且在你準備好的時候隨時幫你內(nèi)推。下方加 winty 好友回復「面試」即可。
