2016年由ThoughtWorks提出了一種類似微服務(wù)的概念“微前端”(Micro Frontend),其后該概念在web領(lǐng)域逐漸落地,在前端技術(shù)領(lǐng)域出現(xiàn)了繁多的微前端框架。本文將向你介紹有關(guān)微前端的概念、意義,帶你走近微前端框架,揭秘那些“不為人知”的巧妙技術(shù)實(shí)現(xiàn)。
概念
什么是微前端呢?雖然它在2016年就被提出,但是直至今天,我們?nèi)匀恢荒苊枋鏊妮喞?,無法給它清晰下定義。以下是筆者閱讀到的一些有關(guān)對微前端概念的闡述:
微前端是一種架構(gòu),而非一個(gè)獨(dú)立的技術(shù)點(diǎn)。我個(gè)人從兩個(gè)角度去看微前端,一個(gè)是應(yīng)用結(jié)構(gòu)上,微前端是多個(gè)小應(yīng)用聚合為一的應(yīng)用形式;一個(gè)是團(tuán)隊(duì)意識上,微前端架構(gòu)下,每個(gè)團(tuán)隊(duì)只負(fù)責(zé)獨(dú)立(封閉)的功能,而且需要包含從服務(wù)端到客戶端,團(tuán)隊(duì)協(xié)作意識與以往有較大不同。
微前端方案
如何在技術(shù)上落地實(shí)現(xiàn)微前端的概念呢?在前端技術(shù)領(lǐng)域出現(xiàn)了如下三種技術(shù)方案:
三種方案各有優(yōu)劣,我們不能立即下結(jié)論哪一種更好。
方案類型 | 典型技術(shù) | 優(yōu)點(diǎn) | 缺點(diǎn) | 共同點(diǎn) |
接口協(xié)議 | single-spa | 比較自由,可自主封裝 | 無法滿足很多場景 |
|
沙箱隔離 | qiankun | 開發(fā)思維簡單直接 | 沙箱帶來的性能等問題 | |
模塊協(xié)議 | webpack module federation | 用模塊思維理解引用 | 脫離構(gòu)建工具無法使用 |
就目前市面上的情況而言,基于沙箱隔離的微前端方案占據(jù)了主導(dǎo),也就是本文將要深入闡述的微前端框架們,也都是這類方案。其中原因,筆者認(rèn)為最主要的一點(diǎn),是基于沙箱隔離的方案可以讓應(yīng)用以最小的成本,從原本的單體大應(yīng)用遷移到微前端架構(gòu)上來。
微前端框架對比評測
微前端框架是用于快速讓web站點(diǎn)或其他技術(shù)棧切換到微前端架構(gòu)的底層引擎,市面上有非常多的微前端框架,筆者在2021年做過一次收集,比較有典型意義。(雖然在那之后還出現(xiàn)了新的微前端框架,但其大部分原理一致,因此,以下這些框架足以說明情況。)
除了webpack的聯(lián)邦模塊方案需要結(jié)合構(gòu)建來做,比較特殊外,其他方案都是在運(yùn)行時(shí)完成應(yīng)用聚合。 “子應(yīng)用獨(dú)立運(yùn)行”指子應(yīng)用不需要放到基座應(yīng)用這個(gè)大環(huán)境下就能自己跑,便于調(diào)試和被不同基座引入。 “子應(yīng)用嵌套子應(yīng)用”是一個(gè)比較特殊的點(diǎn),目前市面上能做到的框架不多。
微前端框架核心技術(shù)
在微前端架構(gòu)中,存在“主應(yīng)用”和“子應(yīng)用”兩個(gè)層級,而微前端框架的主要任務(wù)就是讓子應(yīng)用能夠在主應(yīng)用中有效運(yùn)行。如上文所述,目前較多的微前端框架是基于(或支持)沙箱隔離實(shí)現(xiàn)的主子應(yīng)用運(yùn)行機(jī)制,筆者自己實(shí)現(xiàn)的小型微前端框架“麥飯”也屬于此類,因此,本文只深入闡述這類微前端框架的技術(shù)原理及實(shí)現(xiàn)。微前端框架要解決的核心問題是資源加載和環(huán)境隔離兩大問題,此外,還有路由、通信等問題。
資源加載
微前端框架需要從服務(wù)端拉取子應(yīng)用的代碼文件,并完成解析和子應(yīng)用的掛載運(yùn)行。拋開webpack的模塊聯(lián)邦方案,現(xiàn)在常見的有兩種方案,分別是:以JS文件作為入口;以HTML文件作為入口。以JS文件作為入口可以直接運(yùn)行JS腳本,獲得JS導(dǎo)出的內(nèi)容,但是這樣,僅能加載腳本資源,無法加載CSS等樣式資源。而以HTML文件為入口,則可以通過HTML文件內(nèi)的文件引用,把對應(yīng)的所有JS、CSS文件都一起加載,而且,web站點(diǎn)都是以HTML文件作為入口,這也正好可以讓子應(yīng)用的開發(fā)者按照web開發(fā)的思路來寫子應(yīng)用。 筆者在寫麥飯這個(gè)框架的時(shí)候,希望直接引入子應(yīng)用就能跑,所以以HTML作為入口文件。開發(fā)者使用一個(gè)特殊的importSource函數(shù)來引入入口文件,這個(gè)函數(shù)可以根據(jù)入口文件,解析子應(yīng)用的全部資源,并做緩存。
解析資源
框架在獲得HTML入口文件地址后,通過HTTP請求獲得該文件的內(nèi)容,對內(nèi)容進(jìn)行解析,解析時(shí)需要做資源樹分析,也就是通過HTML讀取所有資源文件,比如link, script[src]。在讀取資源時(shí),可能還需要讀取資源本身又引入的資源。大致邏輯如下圖:在解析過程中,還需要根據(jù)registerMicroApp(麥飯?zhí)峁┑淖越涌冢┑呐渲茫瑳Q定CSS rules怎么處理。解析獲得CSS的技巧,是通過
預(yù)加載/懶加載
在設(shè)計(jì)上,一個(gè)子應(yīng)用的資源有兩種可選加載形式。在麥飯中,假如你希望提前預(yù)加載子應(yīng)用資源,可以在registerMicroApp時(shí)直接傳入 importSource(...),這個(gè)函數(shù)一執(zhí)行,就會去請求資源回來并做緩存。但是,假如你不需要預(yù)加載,你想在子應(yīng)用需要進(jìn)入界面時(shí)(或打算讓子應(yīng)用進(jìn)入界面時(shí))才加載資源,則配置為 () => importSource(...) ,這種配置會在子應(yīng)用執(zhí)行 bootstrap 的時(shí)候才去請求資源。
環(huán)境隔離
環(huán)境隔離是微前端框架實(shí)現(xiàn)時(shí)最核心的技術(shù)難點(diǎn)。由于子應(yīng)用的開發(fā)團(tuán)隊(duì)是分開的,兩個(gè)子應(yīng)用之間,可能存在相互污染的問題,這就要求微前端框架實(shí)現(xiàn)一種能力,讓子應(yīng)用運(yùn)行在自己的一個(gè)隔離環(huán)境中,從而不對其他子應(yīng)用造成污染。目前可以用來解決環(huán)境隔離的方案有:
也有框架把這些方案結(jié)合起來,在不同的場景下,主動或被動的使用其中的一種方案。其中,快照沙箱和代理沙箱是兩種比較獨(dú)特的技術(shù)方案:
快照沙箱
多個(gè)子應(yīng)用在頁面上相互切換,而子應(yīng)用腳本運(yùn)行會給當(dāng)前全局環(huán)境帶來污染??煺丈诚溆糜诮鉀Q這種污染。這種方案只適合同一時(shí)間只運(yùn)行一個(gè)子應(yīng)用的場景,例如騰訊云控制臺。當(dāng)子應(yīng)用進(jìn)入界面的時(shí)候,給window上的所有屬性打一個(gè)快照。子應(yīng)用運(yùn)行過程中window可能被修改。子應(yīng)用離開界面時(shí),把window清理干凈,再把快照上的屬性重新添加到window上,復(fù)原了子應(yīng)用掛載前的window。
代理沙箱
代理沙箱解決一個(gè)頁面內(nèi)同時(shí)運(yùn)行多個(gè)子應(yīng)用的場景。分兩個(gè)步驟實(shí)現(xiàn):
1. 創(chuàng)建代理對象
比如上面提到window可能被污染。那就創(chuàng)建一個(gè)window的代理對象,例如fakeWin,實(shí)現(xiàn)如下:這樣處理之后,我們在讀取時(shí)可能讀取到原始window上的值,但是一旦我們寫入新屬性之后,再讀就讀到剛才寫入的值,但對于原始的window來說,沒有被污染。
2. 創(chuàng)建運(yùn)行沙箱
要使代理對象作為全局對象給子應(yīng)用的腳本使用,必須把子應(yīng)用放在一個(gè)沙箱里面跑,這個(gè)沙箱使用我們制作的代理對象作為全局變量,這樣子應(yīng)用的腳本就會操作代理對象,從而與其他子應(yīng)用起到代理隔離的效果。具體實(shí)現(xiàn)如下:上面代碼里面的window, document, location等,都是前面創(chuàng)建好的代理對象。 當(dāng)然,這里只給出了一些最核心思路的代碼,實(shí)際上在真正實(shí)現(xiàn)時(shí),還要考慮各種特殊情況,需要進(jìn)行多方面的處理。 通過代理沙箱,子應(yīng)用就可以在主應(yīng)用中獨(dú)立運(yùn)行,而不會對主應(yīng)用上的其他子應(yīng)用產(chǎn)生負(fù)面影響。不過,值得一提的是,由于代理沙箱實(shí)際上虛擬了一個(gè)給子應(yīng)用的環(huán)境來運(yùn)行,也就意味著需要消耗更多的計(jì)算資源,會給子應(yīng)用的性能帶來一定影響。同時(shí),由于這種虛擬環(huán)境在某些情況下必須連接到真實(shí)環(huán)境進(jìn)行操作,或者從另外一面反過來說,虛擬環(huán)境中不一定能提供子應(yīng)用所需要的全部依賴,這就會導(dǎo)致子應(yīng)用中某些功能失效,甚至影響整個(gè)子應(yīng)用的表現(xiàn)效果。
路由映射
如果子應(yīng)用有自己的路由系統(tǒng),處理不好,子應(yīng)用在切換路由時(shí)會污染父應(yīng)用,導(dǎo)致瀏覽器url發(fā)生變化,結(jié)果把當(dāng)前頁面切到另外一個(gè)地方去了。為了解決這種問題,麥飯實(shí)現(xiàn)了一個(gè)路由映射功能。因?yàn)樽討?yīng)用是運(yùn)行在沙箱中的,所以,不同層的應(yīng)用得到的location是不同的,基座應(yīng)用使用瀏覽器的location,但是它的子應(yīng)用則不是,修改瀏覽器的url之后,可以通過路由映射機(jī)制,偽造子應(yīng)用得到的url。具體實(shí)現(xiàn)是通過創(chuàng)建一個(gè)臨時(shí)的iframe,利用代理沙箱的能力,將子應(yīng)用的location代理到iframe里面的location上去。得益于代理沙箱,子應(yīng)用的url變化不會導(dǎo)致瀏覽器的url變化。 映射邏輯需要寫一個(gè)map和reactive配置項(xiàng),當(dāng)瀏覽器的url發(fā)生變化時(shí),通過map映射到子應(yīng)用內(nèi)部。子應(yīng)用內(nèi)部url發(fā)生變化時(shí),通過reactive映射到瀏覽器,這樣即使用戶在某一時(shí)刻刷新瀏覽器,也可以通過url映射關(guān)系,準(zhǔn)確還原子應(yīng)用當(dāng)前的界面。
掛載
在麥飯中,子應(yīng)用需要通過一個(gè)
在實(shí)現(xiàn)
通信/應(yīng)用樹
這部分是麥飯?jiān)O(shè)計(jì)中最復(fù)雜的部分,也是最終與其他微前端框架區(qū)別的地方。我構(gòu)建了一個(gè)這樣的樹狀數(shù)據(jù)結(jié)構(gòu),稱之為“應(yīng)用樹”。它表達(dá)了基于 MFY 開發(fā)的微前端應(yīng)用中,應(yīng)用于子應(yīng)用的引用關(guān)系。
scope
scope概念是指一個(gè)應(yīng)用起來之后,會創(chuàng)建一個(gè)scope(作用域),這個(gè) scope 保存了該應(yīng)用的一些運(yùn)行時(shí)信息,同時(shí)通過了通信的接口方法。一個(gè)應(yīng)用可能會有多個(gè)子應(yīng)用,這些子應(yīng)用都有自己的 scope,上下級應(yīng)用之間可以通過scope完成通信,比如 parent_app 可以給 child_app_1 和 child_app_2 下發(fā)一個(gè)指令,接到這個(gè)指令后,兩個(gè)子應(yīng)用執(zhí)行自己的邏輯。child_app_2 可以向 parent_app 發(fā)送一個(gè)指令,而 parent_app 再把這個(gè)指令轉(zhuǎn)發(fā)給了 child_app_1,這樣就完成了兩個(gè)子應(yīng)用之間的通信。這像極了 react 組件通過 props 傳遞數(shù)據(jù)的模式。 rootScope 是一個(gè)特殊的scope,它對應(yīng)的是基座應(yīng)用,是應(yīng)用樹的頂點(diǎn)。由于我把 scope 設(shè)計(jì)為可以廣播消息的訂閱/發(fā)布對象,所以,利用 rootScope 可以完成跨層應(yīng)用間的直接通信(雖然不推薦)。
connectScope
每個(gè)應(yīng)用通過connectScope連接到自己所在的scope。這里需要一些技巧才能實(shí)現(xiàn),在同一層,實(shí)現(xiàn)邏輯有點(diǎn)像react hooks,你不需要關(guān)心你處于應(yīng)用樹的哪個(gè)位置,對于子應(yīng)用開發(fā)團(tuán)隊(duì)而言,只需要在代碼中使用connectScope()函數(shù),就可以直接連接到自己所在的作用域。如果你實(shí)現(xiàn)過react hooks的話,應(yīng)該能理解它的一個(gè)實(shí)現(xiàn)原理。但是由于一些實(shí)現(xiàn)上的限制,你不能異步執(zhí)行connectScope,必須在代碼第一次執(zhí)行時(shí),同步調(diào)用connectScope獲取當(dāng)前子應(yīng)用的scope。
狀態(tài)共享
“如果子應(yīng)用1修改了用戶的某個(gè)狀態(tài),子應(yīng)用2怎么對這個(gè)修改做出響應(yīng)?” 這個(gè)問題涉及到一個(gè)狀態(tài)共享問題。由于我在設(shè)計(jì)時(shí),堅(jiān)持每個(gè)子應(yīng)用團(tuán)隊(duì)?wèi)?yīng)該封閉開發(fā)的理念,開發(fā)團(tuán)隊(duì)不應(yīng)該考慮自己開發(fā)的應(yīng)用還會和其他應(yīng)用放在一起使用,或者還需要依賴其他應(yīng)用的狀態(tài)變化,這會讓我在開發(fā)的時(shí)候一直處于對當(dāng)前應(yīng)用狀態(tài)的未知狀態(tài),那這樣就沒法調(diào)試和測試了。因此,設(shè)計(jì)中我直接拒絕實(shí)現(xiàn)子應(yīng)用間的狀態(tài)共享。 但是在實(shí)際使用過程中,這種需求是存在的。因此,我建議使用通信的方式解決,子應(yīng)用1發(fā)出一個(gè)消息,通過 rootScope,通知網(wǎng)絡(luò)我改變了用戶狀態(tài),那么其他子應(yīng)用在接受到這個(gè)消息之后,自己決定是否要重新渲染界面。
思考
本文雖然已經(jīng)通過筆者實(shí)現(xiàn)麥飯這個(gè)小型微前端框架,詳細(xì)的闡述了一個(gè)微前端框架的核心技術(shù)實(shí)現(xiàn),但是,也同時(shí)遺留了很多問題:
微前端不是萬能的,坑也很多,所以應(yīng)了那句話“沒有銀彈”。
結(jié)語
微前端是一種架構(gòu)形式,一旦采用這種架構(gòu),就會影響到你的應(yīng)用的運(yùn)行方式、團(tuán)隊(duì)的管理方式、構(gòu)建部署的方式,因此,開發(fā)團(tuán)隊(duì)最好經(jīng)過比較長一段時(shí)間的調(diào)研之后,才決定啟用這種架構(gòu)。從本文中,你也會發(fā)現(xiàn),要實(shí)現(xiàn)微前端框架的核心能力,需要使用一些看上去不那么優(yōu)雅的hack方法,既然是hack方法,就存在一定的弊端,比較容易給將來的開發(fā)埋下坑。本文只介紹了實(shí)現(xiàn)微前端框架的核心技術(shù)點(diǎn),在實(shí)際項(xiàng)目中,還需要面臨更多問題,但這并不是說我在勸退大家,而是希望大家在選擇時(shí),根據(jù)實(shí)際的需求決定,不要由于這個(gè)很火就立馬使用。如果你對微前端相關(guān)的話題感興趣,可以在文章下面留言,我們一起探討有關(guān)微前端框架的實(shí)現(xiàn)技術(shù)。
轉(zhuǎn)自:Tencent CDC(https://cdc.tencent.com/2022/02/22/micro-frontend-framework/)