本文內(nèi)容是自己對微前端的一些淺見以及對最近寫的一個微前端框架技術(shù)實現(xiàn)的總結(jié)。作者水平有限,歡迎大家多多指錯,多提意見~
源碼地址:microcosmos:一個寫著玩的微前端框架然后謝謝大家的star,pr當(dāng)然就更歡迎了~
微前端是什么
? 我第一次聽說微前端這個概念是在一年前左右偶然看到了美團(tuán)的一篇技術(shù)博客:用微前端的方式搭建單頁應(yīng)用。然而那時候我連單頁面應(yīng)用是什么都還不知道,自然是看的一頭霧水了。目前大家普遍認(rèn)為微前端的概念由ThoughtWorks在2016年提出。四年的時間,飛速發(fā)展,目前我們已經(jīng)能看到很多優(yōu)秀的開源作品,如single-spa、qiankun、icestark、Micro Frontends etc.
? 那微前端到底是什么呢?其實換個問題會更好的幫助我們認(rèn)識:為什么需要微前端?
? 你可能不知道微前端,但你應(yīng)該知道微服務(wù)。
維基百科上的解釋是這樣的:
微服務(wù)是一種軟件開發(fā)技術(shù)- 面向服務(wù)的體系結(jié)構(gòu)(SOA)架構(gòu)樣式的一種變體,將應(yīng)用程序構(gòu)造為一組松散耦合的服務(wù)。在微服務(wù)體系結(jié)構(gòu)中,服務(wù)是細(xì)粒度的,協(xié)議是輕量級的微服務(wù)是一種以業(yè)務(wù)功能為主的服務(wù)設(shè)計概念,每一個服務(wù)都具有自主運行的業(yè)務(wù)功能,對外開放不受語言限制的 API (最常用的是 HTTP),應(yīng)用程序則是由一個或多個微服務(wù)組成。
? 說白了微服務(wù)的出現(xiàn)主要是為了解決單體應(yīng)用過于龐大過于復(fù)雜帶來的一系列問題。微前端亦然。當(dāng)大家發(fā)現(xiàn)傳統(tǒng)的SPA在不斷的迭代中慢慢進(jìn)化成了巨石應(yīng)用,使得應(yīng)用的開發(fā)、部署、維護(hù)都變得異常困難。我們就迫切的需要一種方式將前端應(yīng)用進(jìn)行拆分,以此來分解復(fù)雜度。
? 又或者單純的分久必合合久必分罷了?
? 我想,這個時候你一定想到了另一個概念,組件化。那微前端和組件化開發(fā)有什么區(qū)別呢?和組件化的區(qū)別?我覺得它們的設(shè)計思想都是一樣的,包括前面說的微服務(wù)。在以前,我們提出組件化開發(fā)的概念,但它在我們?nèi)缃竦钠谕媲安粔蛴昧?。誠然組件化的主要目的是追求更好的可復(fù)用和維護(hù)性,這點和微前端類似。但它對應(yīng)用拆分的粒度是組件。微前端則是將前端應(yīng)用分解成能夠獨立開發(fā)、測試、部署的子應(yīng)用,而在用戶看來仍然是內(nèi)聚的單個產(chǎn)品,粒度是app,并且,因為獨立開發(fā),我們期望技術(shù)棧無關(guān),這是非常重要的。我還沒有工作經(jīng)驗,在這方面難談太多,qiankun開發(fā)者的這篇文章很好的回答了為什么技術(shù)棧無關(guān)在微前端中如此重要。微前端的核心價值
理想的微前端是什么樣呢?和聲的觀點我蠻贊同,那就是子工程是不知道自己是作為子工程在工作的。不過應(yīng)用間通信的場景還是有的,不然大家也不會總是強調(diào)父子通信了。
為了實現(xiàn)我們的愿景,我們需要將多個獨立的前端應(yīng)用集成到一起,實現(xiàn)的方式當(dāng)然有很多。
從前端的角度來說,主要是兩種。構(gòu)建時集成和運行時集成。
構(gòu)建時集成,也就是代碼分割。什么意思呢,我們可以把不同的app放到一起開發(fā),給webpack配置多個入口,最后打包生成多個出口文件,以實現(xiàn)代碼分割。這種方式目前來說只是看上去可行,但是沒辦法上沙箱,而且你還是沒有實現(xiàn)獨立開發(fā),獨立部署。
運行時集成主要是兩種方案。一種,我想大家肯定都知道,iframe。實際上,如果不考慮用戶體驗,我覺得iframe就是一個完美的微前端方案。但是沒辦法,iframe帶來的問題,使得我們沒辦法優(yōu)先考慮它。比如iframe每次都會重新加載,在移動端兼容性差,并且還需要服務(wù)端幫忙,不然會有跨域問題。
在這里,我們要談的是另一種方案,即實現(xiàn)一種容器,容器承載著主應(yīng)用,通過在主應(yīng)用中注冊子應(yīng)用的方式來實現(xiàn)微前端。
? 下面是我用microcosmos寫的一個微前端demo,主應(yīng)用中包含了一個vue app和react app。

我想你應(yīng)該已經(jīng)知道微前端是什么了。接下來,讓我們看看microcosmos的技術(shù)實現(xiàn)。
Microcosmos實現(xiàn)
整體架構(gòu)
咕咕咕,下面這張圖就是microcosmos的架構(gòu)了,整體的架構(gòu)很簡單,你從對應(yīng)的表情能看出來我對各個部分實現(xiàn)的滿意程度。下面分別介紹。

相關(guān)API
引入
npm i microcosmos
import { start, register,initCosmosStore } from 'microcosmos';
注冊子應(yīng)用
register([
{
name: 'sub-react',
entry: "http://localhost:3001",
container: "sub-react",
matchRouter: "/sub-react"
},
{
name: 'sub-vue',
entry: "http://localhost:3002",
container: "sub-vue",
matchRouter: "/sub-vue"
}
])
開始
start()
主應(yīng)用路由方式
function App() {
function goto(title, href) {
window.history.pushState(href, title, href);
}
return (
<div>
<nav>
<ol>
<li onClick={(e) => goto('sub-vue', '/sub-vue')}>子應(yīng)用一</li>
<li onClick={(e) => goto('sub-react', '/sub-react')}>子應(yīng)用二</li>
</ol>
</nav>
<div id="sub-vue"></div>
<div id="sub-react"></div>
</div>
)
}
子應(yīng)用必須導(dǎo)出生命周期鉤子函數(shù)
bootstrap、mount、unmount
export async function bootstrap() {
console.log('react bootstrap')
}
export async function mount() {
console.log('react mount')
ReactDOM.render(<App />, document.getElementById('app-react'))
}
export async function unmount() {
console.log('react unmout')
let root = document.getElementById('sub-react');
root.innerHTML = ''
}
全局狀態(tài)通信/存儲
應(yīng)用之間通信的場景是有,但絕大多數(shù)情況下數(shù)據(jù)量少,頻度低,所以全局Store設(shè)計的也很簡單。
在主應(yīng)用中:
initCosmosStore:初始化store
subscribeStore:監(jiān)聽store變化
changeStore:給store派發(fā)新值
getStore:獲取store當(dāng)前快照
let store = initCosmosStore({ name: 'chuifengji' })
store.subscribeStore((newValue, oldValue) => {
console.log(newValue, oldValue);
})
store.changeStore({ name: 'wzx' })
store.getStore();
在子應(yīng)用中:
export async function mount(rootStore) {
rootStore.subscribeStore((newValue, oldValue) => {
console.log(newValue, oldValue);
}
rootStore.changeStore({ name: 'xjp' }).then(res => console.log(res))
rootStore.getStore();
instance = new Vue({
router,
store,
render: h => h(App)
}).$mount('#app-vue')
}
html-loader
html-loader是通過獲取頁面的html,來獲取app的信息,相對的一種方法是JS-loader,Js-loader和子應(yīng)用的耦合性要高一點,子應(yīng)用得和主應(yīng)用約定好承載容器不是。
那html-loader是如何工作的呢?其實很簡單,就是通過應(yīng)用的入口地址,如:http://localhost:3001, 再調(diào)用fetch函數(shù)。獲取到html的text格式信息后,我們需要從中取出我們需要的部分掛載到子應(yīng)用承載點上。下面這張圖是上面那個微前端demo的element結(jié)構(gòu)。你可以看到子應(yīng)用被掛在id為sub-react的標(biāo)簽下。

如何來做呢?
我想你的第一反應(yīng)可能是正則,我一開始也是用正則來處理的,但是我后來發(fā)現(xiàn),正則太難完備了(原諒我這個正則盲)我總能寫出示例讓我自己的正則導(dǎo)出錯誤的結(jié)果。并且用正則來寫,代碼看著確實挺亂的,后期維護(hù)也不太方便。既然是html字符串,為什么我們不用dom api來處理呢?第一反應(yīng)又是iframe,直接新建一個iframe,利用src屬性加載iframe。問題來了,我怎么知道iframe什么時候加載好了?onload嗎,顯然不行,我們只是為了取出數(shù)據(jù)而已。DOMContentLoaded?像下面這樣,寫一個ready函數(shù),還是不行,DOMContentLoaded會等待js執(zhí)行完才回調(diào)。對SPA來說,這時間可能有點長了。
function iframeReady(iframe: HTMLIFrameElement, iframeName: string): Promise<Document> {
return new Promise(function (resolve, reject) {
window.frames[iframeName].addEventListener('DOMContentLoaded', () => {
let html = iframe.contentDocument || (iframe.contentWindow as Window).document;
resolve(html);
});
});
}
沒辦法,只好想別的辦法,寫定時函數(shù)來判斷dom中是否存在body節(jié)點,通過適當(dāng)調(diào)整定時函數(shù)的執(zhí)行周期,好像可以,但我們無法知道子應(yīng)用的結(jié)構(gòu),依賴于body還是不行的,太不可靠了。
function iframeReady(iframe: HTMLIFrameElement): Promise<Document> {
return new Promise(function (resolve, reject) {
(function isiframeReady() {
if (iframe.contentDocument.body || (iframe.contentWindow as Window).document.body) {
resolve(iframe.contentDocument || (iframe.contentWindow as Window).document)
} else {
setInterval(isiframeReady, 10)
}
})()
})
}
而且要獲取到iframe的contentWindow的話你需要將iframe掛在到dom上,確實,可以設(shè)置為display:none,但太不優(yōu)雅了。怎么看怎么不舒服。
srcdoc?是個不錯的選擇,可惜IE不支持這個新屬性。
那就將正則和DOM API結(jié)合吧。我們通過正則獲取head和body節(jié)點下的內(nèi)容,這兩個正則還是挺容易完備的,再將它們innerHtml到createElement出的一個div節(jié)點中,通過DOM API來遍歷。DOM的結(jié)構(gòu)是穩(wěn)定的,我們可以輕松可靠的獲取我們想要的內(nèi)容,即html結(jié)構(gòu)信息和js。
js隔離
微前端沙箱沒有完美實踐?
微前端中既然存在多個獨立開發(fā)的應(yīng)用,自然需要隔離js,采取的方式是構(gòu)建沙箱。在瀏覽器當(dāng)中,沙箱隔離了操作系統(tǒng)和瀏覽器渲染引擎,限制進(jìn)程對操作系統(tǒng)資源的訪問和修改。實際上,如果我們需要的app需要執(zhí)行一些信任度不高的外部js的時候你也是需要沙箱的。一般情況下,我們說的沙箱強調(diào)的是兩層,隔離和安全。js沙箱本身是個蠻大的坑,好在大部分情況下代碼安全都不是微前端要考慮的問題,主應(yīng)用對接入的子應(yīng)用不能信任這樣的情況還是比較少。微前端中的沙箱要考慮的是第一層,完全的隔離反而會帶來問題。
如果不考慮全局對象,不考慮DOM和BOM,我們要做的事情其實非常簡單。使用new Function,這樣子應(yīng)用之間的變量都運行在函數(shù)作用域中,自然不會沖突了,但是我們還是得考慮全局變量,考慮DOM和BOM。特別是那些個框架大多都改了原生對象。那我們?nèi)绾螌崿F(xiàn)window的隔離呢?
主要的思路有三種:
快照沙箱:
快照沙箱實際上就是在應(yīng)用mount時激活生成快照,在unmount時失活恢復(fù)原有環(huán)境。比如app A掛載時修改了一個全局變量window.appName = 'vue',那我就可以記錄下當(dāng)前的快照(修改前的屬性值)。當(dāng)app A卸載時,我就可以把當(dāng)前的快照和當(dāng)前環(huán)境進(jìn)行比對,獲知原有環(huán)境從而恢復(fù)運行環(huán)境。
class SnapshotSandbox {
constructor() {
this.proxy = window;
this.modifyPropsMap = {}; // 修改了哪些屬性
this.active();
}
active() {
this.windowSnapshot = {}; // window對象的快照
for (const prop in window) {
if (window.hasOwnProperty(prop)) {
// 將window上的屬性進(jìn)行拍照
this.windowSnapshot[prop] = window[prop];
}
}
Object.keys(this.modifyPropsMap).forEach(p => {
window[p] = this.modifyPropsMap[p];
});
}
inactive() {
for (const prop in window) { // diff 差異
if (window.hasOwnProperty(prop)) {
// 將上次拍照的結(jié)果和本次window屬性做對比
if (window[prop] !== this.windowSnapshot[prop]) {
// 保存修改后的結(jié)果
this.modifyPropsMap[prop] = window[prop];
// 還原window
window[prop] = this.windowSnapshot[prop];
}
}
}
}
}
let sandbox = new SnapshotSandbox();
((window) => {
window.a = 1;
window.b = 2;
window.c = 3
console.log(a,b,c)
sandbox.inactive();
console.log(a,b,c)
})(sandbox.proxy);
快照沙箱的思路很簡單,也很容易做到子應(yīng)用的狀態(tài)保持,但是顯然快照沙箱只能支持單實例的場景,對于多實例共存的場景,它就無能為力了。
借用iframe:
啊這個,也太沒逼格了。開玩笑,其實iframe也不好做,雖然我們通過它可以拿到完全隔離的 window、document 等上下文。但還是不能直接加以使用的,你得通過postMessage,建立iframe和主應(yīng)用之間的通信。不然路由啥的還玩?zhèn)€錘子。
proxy代理:
class ProxySandbox {
constructor() {
const rawWindow = window;
const fakeWindow = {}
const proxy = new Proxy(fakeWindow, {
set(target, p, value) {
target[p] = value;
return true
},
get(target, p) {
return target[p] || rawWindow[p];
}
});
this.proxy = proxy
}
}
let sandbox1 = new ProxySandbox();
let sandbox2 = new ProxySandbox();
window.a = 1;
((window) => {
window.a = {a:'ldl'};
console.log(window.a)
})(sandbox1.proxy);a:'ldl'
((window) => {
window.a = 'world';
console.log(window.a)
})(sandbox2.proxy);
上面這個proxy是很簡單了,讀時優(yōu)先獲取"拷貝值",沒有就代理到原值,寫時代理到“拷貝值”。但它存在著諸多問題,且不說各種惡意代碼,如果全局對象使用self、this、globalThis,代理就無效了,只代理get和set也是不夠的。最重要的,只是在一定程度上隔離了全局變量而已,window的原生對象和方法,全部失效。
function getOwnPropertyDescriptors(target: any) {
const res: any = {}
Reflect.ownKeys(target).forEach(key => {
res[key] = Object.getOwnPropertyDescriptor(target, key)
})
return res
}
export function copyProp(target: any, source: any) {
if (Array.isArray(target)) {
for (let i = 0; i < source.length; i++) {
if (!(i in target)) {
target[i] = source[i];
}
}
}
else {
const descriptors = getOwnPropertyDescriptors(source)
//delete descriptors[DRAFT_STATE as any]
let keys = Reflect.ownKeys(descriptors)
for (let i = 0; i < keys.length; i++) {
const key: any = keys[i]
const desc = descriptors[key]
if (desc.writable === false) {
desc.writable = true
desc.configurable = true
}
if (desc.get || desc.set)
descriptors[key] = {
configurable: true,
writable: true,
enumerable: desc.enumerable,
value: source[key]
}
}
target = Object.create(Object.getPrototypeOf(source), descriptors)
console.log(target)
}
}
export function copyOnWrite(draftState: {
originalValue: {
[key: string]: any;
};
draftValue: any;
onWrite: any;
mutated: boolean;
}) {
const { originalValue, draftValue, mutated, onWrite } = draftState;
if (!mutated) {
draftState.mutated = true;
if (onWrite) {
onWrite(draftValue);
}
copyProp(draftValue, originalValue);
}
}
沙箱難做的原因是,是因為有矛盾點,那就是我們既希望能做到盡可能的隔離,但你又不應(yīng)當(dāng)做到完全的隔離。在這個界限之間,就會有沖突。
microcosmos的沙箱就是用proxy實現(xiàn)的,目前的做法是通過copy-on-write實現(xiàn)部分window部分下對象的拷貝,window下的方法還是bind到原方法上的,這個確實沒什么好辦法。如果怕造成沖突,可以通過添加黑白名單的方式,限制子應(yīng)用對某些方法的訪問,或者自己模擬實現(xiàn)一些方法,再進(jìn)行通信。不管哪種方案,都不夠優(yōu)雅。
我對這個部分的實現(xiàn)很不滿意, 代碼參考自immer,這個庫實在有太多可以借鑒的東西。
感興趣的可以自己研究下:immer
css隔離
我們需要在微前端容器設(shè)計中考慮隔離CSS嗎?
其實我個人覺得這不是微前端容器要考慮的內(nèi)容,因為這個問題和微前端無關(guān),幾乎是在有css起,我們就在遭遇這樣的問題,SPA時代更是已經(jīng)成了必須要考慮的問題。所以在microcosmos中我沒有去解決css隔離的問題。你依然要像開發(fā)SPA一樣,采取 BEM(Block Element Modifier) 約定項目前綴,css module,css-in-js等方案。
而像qiankun所說的 Dynamic Stylesheet其實蠻無聊的(我自己也加了hh),子應(yīng)用的裝卸自然包含著css的裝卸,但是這不能保證子應(yīng)用與主應(yīng)用之間沒有沖突,更不用說還可能存在多個子應(yīng)用并行的情況。(當(dāng)然了,他們現(xiàn)在也提出了其他方案,值得期待?。?/p>
那你可能會說,Why not shadow dom?
shadow dom確實天生隔離樣式,我們很多的開源組件庫都使用了shadow dom。但是要把整個應(yīng)用掛在shadow dom風(fēng)險還是太大了。會出現(xiàn)各種各樣的問題。
比如React17之前,為了減少 DOM 上的事件對象來節(jié)省內(nèi)存,優(yōu)化頁面性能,同時也為了實現(xiàn)事件調(diào)度機(jī)制,所有的事件都代理到document元素上。 而shadow dom 里觸發(fā)的事件,在外層拿到 event.target的時候,只會拿到 host(宿主元素),所以導(dǎo)致了 react 的事件調(diào)度出現(xiàn)問題。

如果你不了解react,我解釋一下。
在React 的「合成事件機(jī)制」中「事件」并不會直接綁定到具體的 DOM 元素上,而是通過在 document 上綁定的 ReactEventListener來管理, 當(dāng)時元素被單擊或觸發(fā)其他事件時,事件被 dispatch 到 document 時將由 React 進(jìn)行處理并觸發(fā)相應(yīng)合成事件的執(zhí)行。
對于shadow dom,因為主文檔內(nèi)部的腳本并不了解 shadow dom 內(nèi)部,尤其是當(dāng)組件來自于第三方庫,所以,為了保持細(xì)節(jié)簡單,瀏覽器會重新定位(retarget)事件。當(dāng)事件在組件外部捕獲時,shadow DOM 中發(fā)生的事件將會以 host 元素作為目標(biāo)。
這將讓 React 在處理合成事件時,不認(rèn)為 ShadowDOM 中元素基于 JSX 語法綁定的事件被觸發(fā)了。
當(dāng)然了,更大的問題是shadow dom只是隔離了內(nèi)部與外部,內(nèi)部還是會有沖突的可能呀。
life-cycle
生命周期循環(huán)是個大遍歷。
每次路由發(fā)生有效改變的時候我們需要觸發(fā)lifeCycle,對已經(jīng)注冊的app進(jìn)行遍歷,該卸載的卸載,該注入的注入。
lifeCycle會遍歷子應(yīng)用列表,依次執(zhí)行它們的生命周期函數(shù),這里有個小問題,子應(yīng)用的生命周期函數(shù)是如何被主應(yīng)用獲取到的,如果你和我一樣不熟悉webpack,或許會陷入這樣的困惑,事實上,webpack以umd格式進(jìn)行打包的話,require函數(shù)會將export出的函數(shù)合成一個model掛到window上。這樣我們就可以獲取啦。
本身倒沒有什么問題,只是我寫的lifeCycle,對于應(yīng)用狀態(tài)依賴有點弱。。比如說,第一次進(jìn)入某個子應(yīng)用需要fetch,后面就不應(yīng)需要了。我的做法是通過函數(shù)緩存來實現(xiàn),但是整個生命周期的執(zhí)行沒有絲毫變化,這樣似乎不太好,不夠優(yōu)雅。
這里倒是有個要補充的點,我們希望用戶的點擊觸發(fā)window.history.pushState事件,以此來顯式的改變地址欄url,但是我們還需要對pushSate進(jìn)行監(jiān)聽來觸發(fā)函數(shù)切換應(yīng)用。pushState又是沒法被直接監(jiān)聽的,我們需要對window.history.pushState事件進(jìn)行包裝,通過監(jiān)聽自定義事件來監(jiān)聽history變化,下面是實現(xiàn)函數(shù)。
export function patchEventListener(event: any, ListerName: string) {
return function (this: any) {
const e = new Event(ListerName);
event.apply(this, arguments)
window.dispatchEvent(e);
};
}
window.history.pushState = patchEventListener(window.history.pushState, "cosmos_pushState");
window.addEventListener("cosmos_pushState", routerChange);
應(yīng)用通信
需求決定實現(xiàn)。
理想的微前端可能都不需要這個設(shè)計,因為我們說了,子應(yīng)用是不知道自己是作為子應(yīng)用在運行的,但是畢竟只是理想。我們還是會有一些父子通信的需求。一般情況下,在微前端中,父子應(yīng)用之間,子應(yīng)用之間的通信頻度較低,數(shù)據(jù)量較小。
所以在microcosmos 里我只是運用了簡單的發(fā)布訂閱來實現(xiàn)??浚氵@也太簡單了吧。(別罵了別罵了,能用就行
export function initCosmosStore(initData) {
return window.MICROCOSMOS_ROOT_STORE = (function () {
let store = initData;
let observers: Array<Function> = [];
function getStore() {
return store;
}
function changeStore(newValue) {
return new Promise((resolve, reject) => {
if (newValue !== store) {
let oldValue = store;
store = newValue;
resolve(store);
observers.forEach(fn => fn(newValue, oldValue));
}
})
}
function subscribeStore(fn) {
observers.push(fn);
}
return { getStore, changeStore, subscribeStore }
})()
}
預(yù)加載
預(yù)加載是為了降低白屏?xí)r間,獲取更流暢的應(yīng)用切換效果,對于一些通過微前端實現(xiàn)的工作臺,主應(yīng)用上可能注冊了十幾個甚至更多的子應(yīng)用,我們往往不會在短時間內(nèi)都執(zhí)行它們,那通過預(yù)加載,就能夠提前抓取子應(yīng)用的數(shù)據(jù)信息,讓微前端的優(yōu)勢發(fā)揮到極致。
要注意的是瀏覽器同域名下的并發(fā)請求數(shù)量是有限制的,不同瀏覽器可能都不太一樣,比如在chrome上可能是6,所以我們需要對子應(yīng)用列表進(jìn)行切片,再通過promise 鏈?zhǔn)秸{(diào)用。
至此,microcosmos的技術(shù)實現(xiàn)就講完啦,當(dāng)然還是有些小細(xì)節(jié),沒法全部來講。
總結(jié)
微前端的架構(gòu)雖然看起來簡單,但如果真的要做一個高可用的版本,還有很多的路要走,相信未來我們會有更完善的一整套微前端工程化方案,而不是局限于容器。

PS:即將發(fā)布的webpack5的特性之一module federation使得JavaScript 應(yīng)用得以從另一個 JavaScript 應(yīng)用中動態(tài)地加載代碼 —— 同時共享依賴。如果某應(yīng)用所消費的 federated module 沒有 federated code 中所需的依賴,Webpack 將會從 federated 構(gòu)建源中下載缺少的依賴項, webpack能夠更好更方便地支持不同工程之間構(gòu)建產(chǎn)物的互相加載,讓我們一起看看這最終會給微前端帶來什么。