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

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



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

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

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

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

如果是一個(gè)組件很簡(jiǎn)單,也很好實(shí)現(xiàn),我們知道garfish有提供loadApp的接口,我們可以直接通過(guò)加載一個(gè)子應(yīng)用,這個(gè)子應(yīng)用渲染某個(gè)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)里還是跑了幾個(gè)月的,沒(méi)有任何問(wèn)題。但是如果有了多例就不一樣了,我們會(huì)調(diào)用多次loadApp,加載了大量子應(yīng)用的代碼,導(dǎo)致性能很差,甚至直接卡死。有人說(shuō)加cache行不行?其實(shí)也是不可行的,上述的代碼過(guò)于簡(jiǎn)陋,我們還需要處理props變化的情況,以及l(fā)oadApp,傳遞props給react的情況。如果單純只是cashe解決不了這樣的場(chǎng)景。
所以我們特意設(shè)計(jì)了一個(gè)子應(yīng)用,這個(gè)子應(yīng)用專門作為組件級(jí)別的渲染,暫且稱之為 微組件子應(yīng)用

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

每個(gè)組件其實(shí)所需要接收的參數(shù)有domId、props和事件或其它屬性。所以我們的數(shù)據(jù)結(jié)構(gòu)其實(shí)可以大致如下。
type Meta = {
domId: string;
componentKey: string; // 為了指定由哪個(gè)組件渲染
props?: Record<any, any>;
[_key_: string]: any; // 事件和其它透?jìng)鲗傩?nbsp;
};
有了這個(gè)結(jié)構(gòu),我們 react 的 render 函數(shù)就簡(jiǎn)單了,統(tǒng)一渲染一個(gè)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)該如何使用這樣的組件呢?當(dāng)然肯定是和單純的一個(gè)vue組件沒(méi)有區(qū)別。比如這樣。


所以我們就需要封裝一個(gè)底層的vue組件去負(fù)責(zé)管理子應(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,需要去負(fù)責(zé)保持只能load一個(gè)子應(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)用失敗',
};
}
}
}
我們需要用兩個(gè)flag來(lái)控制mount和unmunt。為了保證只能load一個(gè)子應(yīng)用,用一個(gè)loaded開關(guān)來(lái)控制。而count是因?yàn)槲覀冇卸嗬鋵?shí)就是個(gè)引用計(jì)數(shù),必須保證每個(gè)微組件都卸載了,才能去unmount掉我們的子應(yīng)用。
props如何傳遞呢?這里其實(shí)就是如何進(jìn)行不同應(yīng)用之間的數(shù)據(jù)共享,同時(shí)他是保持一份的。我們可以通過(guò)garfish提供的API來(lái)實(shí)現(xiàn)。

基于這2個(gè)API,我們可以在garfish上構(gòu)建出這么個(gè)對(duì)象來(lái)傳遞我們的數(shù)據(jù)。在之前提到過(guò),我們可能是多個(gè)子應(yīng)用export出來(lái)的組件,其實(shí)這部分的數(shù)據(jù)存儲(chǔ)就是一個(gè)二維結(jié)構(gòu)。
garfish[subAppName][domId] = {
domId: 1,
props: {},
...rest,
}
當(dāng)我們初始化一個(gè)vue的組件時(shí),就需要把對(duì)應(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àn)樾枰3置恳粋€(gè)子應(yīng)用都是唯一的單例,我們繼續(xù)引入microComponentManager來(lái)幫我們管理所有的子應(yīng)用實(shí)例。
搞定了初始化和數(shù)據(jù)傳遞的的問(wèn)題后,我們來(lái)思考一下props change的問(wèn)題。其實(shí)也很簡(jiǎn)單,只要三個(gè)步驟。
監(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組件則接收到事件后,對(duì)數(shù)據(jù)進(jìn)行更新,重新渲染
// 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(() => {
_// 放到下一個(gè) 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_);
}
}
我們用一個(gè)pending隊(duì)列來(lái)存放所有的事件,這是避免一瞬間發(fā)送過(guò)多事件導(dǎo)致無(wú)意義的開銷。比如一個(gè)列表的頁(yè)面,可能同時(shí)創(chuàng)建了100個(gè)微組件,此時(shí)如果不做一次debounce則會(huì)一瞬間發(fā)送100次。一個(gè)優(yōu)化的小細(xì)節(jié)。
另外需要注意的是注意到我們發(fā)送事件的地方用了個(gè)setTimeout,這是由于我們的app.mount,其實(shí)僅僅只是把子應(yīng)用給渲染完了,此時(shí)不代表react的組件被渲染完畢,我們?cè)趓eact里的useEffect還是沒(méi)有執(zhí)行的。所以我們需要放到下一個(gè)macroTask來(lái)發(fā)送事件,為了保證react里先監(jiān)聽。
以上其實(shí)就是整套方案的核心代碼了
總結(jié)
總的來(lái)說(shuō),我們的實(shí)現(xiàn)方案就是基于loadApp,把一個(gè)子應(yīng)用僅僅當(dāng)做多應(yīng)用之間渲染和通信的媒介掛在在了body上。所有的組件都通過(guò)portal的方式,掛載到指定的dom位置上。
優(yōu)勢(shì)
原理代碼實(shí)現(xiàn)簡(jiǎn)單輕量,復(fù)用便捷,開發(fā)高效,無(wú)關(guān)技術(shù)棧 接入簡(jiǎn)單,可以實(shí)現(xiàn)ReactInVue,VueInReact 無(wú)論需要復(fù)用多少個(gè)組件,都只需要load1個(gè)子應(yīng)用,開銷低 可以掛載到任何garfish的應(yīng)用里,組件復(fù)用,達(dá)到跨團(tuán)隊(duì)級(jí)別的復(fù)用 只需要發(fā)布一次,所有地方全都生效且最新版本 可以跨repo搭建自己需要共享的組件子應(yīng)用
劣勢(shì)
無(wú)法對(duì)組件版本進(jìn)行管理 需要基于garfish的環(huán)境才能達(dá)到共享 需要?jiǎng)?chuàng)建一個(gè)子項(xiàng)目,相比共享組件的方案更重 keep-alive場(chǎng)景下可能有問(wèn)題 依賴管理不方便控制(React,組件庫(kù)等)
可以看出這個(gè)方案也有一個(gè)最大的局限性。版本不可控,在我們的業(yè)務(wù)里是不需要對(duì)這樣需要共享的組件進(jìn)行版本管理的。以下介紹的方案大家需要注意下,如果你的共享組件需要版本管理則不可采用這種方案。所以,我們?cè)賮?lái)看看,現(xiàn)在共享組件的標(biāo)準(zhǔn)實(shí)現(xiàn)方案。
運(yùn)行時(shí)組件市場(chǎng)
我們上述的方案,其實(shí)是通過(guò)組件復(fù)用的場(chǎng)景細(xì)分采用工程化的方案來(lái)解決物理隔離,技術(shù)棧不同的組件復(fù)用。而如果我們需要一個(gè)更加通用化的微組件方案,必然會(huì)需要平臺(tái)的支持,版本的支持,loader的支持。所以我們來(lái)看看現(xiàn)有的組件市場(chǎng)的發(fā)展方向。
Garfish 提供了 loadComponent[1] 的API,可以直接遠(yuǎn)程加載一個(gè)組件資源。在現(xiàn)有的設(shè)計(jì)下,大多數(shù)這個(gè)資源都是一個(gè)已經(jīng)被編譯好的umd的js文件。


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

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



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

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

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

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

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

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

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

after:

不難發(fā)現(xiàn),bit的好處就在于。我們?nèi)我庖粋€(gè)項(xiàng)目都可以非常方便的“download”(import)組件同時(shí),在當(dāng)前項(xiàng)目下很方便的直接發(fā)布(export)新版本。bit不僅僅支持了組件的形式,其實(shí)還支持了普通的js/ts代碼。在團(tuán)隊(duì)內(nèi)部的業(yè)務(wù)下,如果有這樣跨repo級(jí)別共享代碼的需求就會(huì)非常方便。
總結(jié)
本文介紹在微前端項(xiàng)目中我們是如何跨項(xiàng)目跨技術(shù)棧復(fù)用組件的的使用場(chǎng)景,進(jìn)而思考到其他工具的是如何復(fù)用代碼的原理和更廣泛的適用范圍。
其中較為重要的個(gè)人認(rèn)為是去熟悉內(nèi)在的一些思想。深入的思考分層和抽象搭建新的“橋梁”,如何去尋找“橋梁”把不同的模塊組織起來(lái)。會(huì)發(fā)現(xiàn)前文所說(shuō)的工程角度來(lái)解決組件的共享,其實(shí)就是基于garfish這個(gè)橋梁,對(duì)共享的數(shù)據(jù)進(jìn)行了一些同步,這就和webpack的__webpack__require__有異曲同工之處。而把repo抽象為模塊,針對(duì)性的進(jìn)行exports,也是從federation中借鑒了靈感。
參考鏈接
https://garfish.top/
探索 webpack5 新特性 Module federation 在騰訊文檔的應(yīng)用[7]
webpack 打包的代碼怎么在瀏覽器跑起來(lái)的[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)建時(shí)長(zhǎng): 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 打包的代碼怎么在瀏覽器跑起來(lái)的: https://segmentfault.com/a/1190000022669224
?? 謝謝支持
以上便是本次分享的全部?jī)?nèi)容,希望對(duì)你有所幫助^_^
喜歡的話別忘了 分享、點(diǎn)贊、收藏 三連哦~。
歡迎關(guān)注公眾號(hào) 前端Sharing 收貨大廠一手好文章~
