本文將針對微前端框架 qiankun
的原始碼進行深入解析,在原始碼講解之前,我們先來了解一下什麼是 微前端
。
微前端
是一種類似於微服務的架構,它將微服務的理念應用於瀏覽器端,即將單頁面前端應用由單一的單體應用轉變為多個小型前端應用聚合為一的應用。各個前端應用還可以獨立開發、獨立部署。同時,它們也可以在共享元件的同時進行並行開發——這些元件可以通過 NPM
或者 Git Tag、Git Submodule
來管理。
qiankun(乾坤)
就是一款由螞蟻金服推出的比較成熟的微前端框架,基於 single-spa
進行二次開發,用於將 Web 應用由單一的單體應用轉變為多個小型前端應用聚合為一的應用。(見下圖)
那麼,話不多說,我們的原始碼解析正式開始。
初始化全域性配置 - start(opts)
我們從兩個基礎 API - registerMicroApps(apps, lifeCycles?) - 註冊子應用
和 start(opts?) - 啟動主應用
開始,由於 registerMicroApps
函式中設定的回撥函式較多,並且讀取了 start
函式中設定的初始配置項,所以我們從 start
函式開始解析。
我們從 start
函式開始解析(見下圖):
我們對 start
函式進行逐行解析:
第 196 行
:設定window
的__POWERED_BY_QIANKUN__
屬性為true
,在子應用中使用window.__POWERED_BY_QIANKUN__
值判斷是否執行在主應用容器中。第 198~199 行
:設定配置引數(有預設值),將配置引數儲存在importLoaderConfiguration
物件中;第 201~203 行
:檢查prefetch
屬性,如果需要預載入,則新增全域性事件single-spa:first-mount
監聽,在第一個子應用掛載後預載入其他子應用資源,優化後續其他子應用的載入速度。第 205 行
:根據singularMode
引數設定是否為單例項模式。第 209~217 行
:根據jsSandbox
引數設定是否啟用沙箱執行環境,舊版本需要關閉該選項以相容 IE。(新版本在單例項模式下預設支援 IE,多例項模式依然不支援 IE)。第 222 行
:呼叫了single-spa
的startSingleSpa
方法啟動應用,這個在single-spa
篇我們會單獨剖析,這裡可以簡單理解為啟動主應用。
從上面可以看出,start
函式負責初始化一些全域性設定,然後啟動應用。這些初始化的配置引數有一部分將在 registerMicroApps
註冊子應用的回撥函式中使用,我們繼續往下看。
註冊子應用 - registerMicroApps(apps, lifeCycles?)
registerMicroApps
函式的作用是註冊子應用,並且在子應用啟用時,建立執行沙箱,在不同階段呼叫不同的生命週期鉤子函式。(見下圖)
從上面可以看出,在 第 70~71 行
處 registerMicroApps
函式做了個處理,防止重複註冊相同的子應用。
在 第 74 行
呼叫了 single-spa
的 registerApplication
方法註冊了子應用。
我們直接來看 registerApplication
方法,registerApplication
方法是 single-spa
中註冊子應用的核心函式。該函式有四個引數,分別是
name(子應用的名稱)
回撥函式(activeRule 啟用時呼叫)
activeRule(子應用的啟用規則)
props(主應用需要傳遞給子應用的資料)
這些引數都是由 single-spa
直接實現,這裡可以先簡單理解為註冊子應用(這個我們會在 single-spa
篇展開說)。在符合 activeRule
啟用規則時將會啟用子應用,執行回撥函式,返回一些生命週期鉤子函式(見下圖)。
注意,這些生命週期鉤子函式屬於
single-spa
,由single-spa
決定在何時呼叫,這裡我們從函式名來簡單理解。(bootstrap
- 初始化子應用,mount
- 掛載子應用,unmount
- 解除安裝子應用)
如果你還是覺得有點懵,沒關係,我們通過一張圖來幫助理解。(見下圖)
獲取子應用資源 - import-html-entry
我們從上面分析可以看出,qiankun
的 registerMicroApps
方法中第一個入參 apps - Array<RegistrableApp<T>>
有三個引數 name、activeRule、props
都是交給 single-spa
使用,還有 entry
和 render
引數還沒有用到。
我們這裡需要關注 entry(子應用的 entry 地址)
和 render(子應用被啟用時觸發的渲染規則)
這兩個還沒有用到的引數,這兩個引數延遲到 single-spa
子應用啟用後的回撥函式中執行。
那我們假設此時我們的子應用已啟用,我們來看看這裡做了什麼。(見下圖)
從上圖可以看出,在子應用啟用後,首先在 第 81~84 行
處使用了 import-html-entry
庫從 entry
進入載入子應用,載入完成後將返回一個物件(見下圖)
我們來解釋一下這幾個欄位
欄位 | 解釋 |
---|---|
template |
將指令碼檔案內容註釋後的 html 模板檔案 |
assetPublicPath |
資源地址根路徑,可用於載入子應用資源 |
getExternalScripts |
方法:獲取外部引入的指令碼檔案 |
getExternalStyleSheets |
方法:獲取外部引入的樣式表檔案 |
execScripts |
方法:執行該模板檔案中所有的 JS 指令碼檔案,並且可以指定指令碼的作用域 - proxy 物件 |
我們先將 template 模板
、getExternalScripts
和 getExternalStyleSheets
函式的執行結果列印出來,效果如下(見下圖):
從上圖我們可以看到我們外部引入的三個 js
指令碼檔案,這個模板檔案沒有外部 css
樣式表,對應的樣式表陣列也為空。
然後我們再來分析 execScripts
方法,該方法的作用就是指定一個 proxy
(預設是 window
)物件,然後執行該模板檔案中所有的 JS
,並返回 JS
執行後 proxy
物件的最後一個屬性(見下圖 1)。在微前端架構中,這個物件一般會包含一些子應用的生命週期鉤子函式(見下圖 2),主應用可以通過在特定階段呼叫這些生命週期鉤子函式,進行掛載和銷燬子應用的操作。
在 qiankun
的 importEntry
函式中還傳入了配置項 getTemplate
,這個其實是對 html
目標檔案的二次處理,這裡就不作展開了,有興趣的可以自行去了解一下。
主應用掛載子應用 HTML 模板
我們回到 qiankun
原始碼部分繼續看(見下圖)
從上圖看出,在 第 85~87 行
處,先對單例項進行檢測。在單例項模式下,新的子應用掛載行為會在舊的子應用解除安裝之後才開始。
在 第 88 行
中,執行註冊子應用時傳入的 render
函式,將 HTML Template
和 loading
作為入參,render
函式的內容一般是將 HTML
掛載在指定容器中(見下圖)。
在這個階段,主應用已經將子應用基礎的 HTML
結構掛載在了主應用的某個容器內,接下來還需要執行子應用對應的 mount
方法(如 Vue.$mount
)對子應用狀態進行掛載。
此時頁面還可以根據 loading
引數開啟一個類似載入的效果,直至子應用全部內容載入完成。
沙箱執行環境 - genSandbox
我們回到 qiankun
原始碼部分繼續看,此時還是子應用啟用時的回撥函式部分(見下圖)
在 第 90~98 行
是 qiankun
比較核心的部分,也是幾個子應用之間狀態獨立的關鍵,那就是 js
的沙箱執行環境。如果關閉了 useJsSandbox
選項,那麼所有子應用的沙箱環境都是 window
,就很容易對全域性狀態產生汙染。
我們進入到 genSandbox
內部,看看 qiankun
是如何建立的 (JS)沙箱執行環境
。(見下圖)
從上圖可以看出 genSandbox
內部的沙箱主要是通過是否支援 window.Proxy
分為 ProxySandbox
和 SnapshotSandbox
兩種(多例項還有一種 LegacySandbox
沙箱,這裡我們不作講解)。
ProxySandbox
我們先來看看 ProxySandbox
沙箱是怎麼進行狀態隔離的(見下圖)
我們來分析一下 ProxySandbox
類的幾個屬性:
欄位 | 解釋 |
---|---|
updateValueMap |
記錄沙箱中更新的值,也就是每個子應用中獨立的狀態池 |
name |
沙箱名稱 |
proxy |
代理物件,可以理解為子應用的 global/window 物件 |
sandboxRunning |
當前沙箱是否在執行中 |
active |
啟用沙箱,在子應用掛載時啟動 |
inactive |
關閉沙箱,在子應用解除安裝時啟動 |
constructor |
建構函式,建立沙箱環境 |
我們現在從 window.Proxy
的 set
和 get
屬性來詳細講解 ProxySandbox
是如何實現沙箱執行環境的。(見下圖)
注意:子應用沙箱中的
proxy
物件可以簡單理解為子應用的window
全域性物件(程式碼如下),子應用對全域性屬性的操作就是對該proxy
物件屬性的操作,帶著這份理解繼續往下看吧。
// 子應用指令碼檔案的執行過程:
eval(
// 這裡將 proxy 作為 window 引數傳入
// 子應用的全域性物件就是該子應用沙箱的 proxy 物件
(function(window) {
/* 子應用指令碼檔案內容 */
})(proxy)
);
複製程式碼
當呼叫 set
向子應用 proxy/window
物件設定屬性時,所有的屬性設定和更新都會命中 updateValueMap
,儲存在 updateValueMap
集合中(第 38 行
),從而避免對 window
物件產生影響(舊版本則是通過 diff
演算法還原 window
物件狀態快照,子應用之間的狀態是隔離的,而父子應用之間 window
物件會有汙染)。
當呼叫 get
從子應用 proxy/window
物件取值時,會優先從子應用的沙箱狀態池 updateValueMap
中取值,如果沒有命中才從主應用的 window
物件中取值(第 49 行
)。對於非建構函式的取值將會對 this
指標繫結到 window
物件後,再返回函式。
如此一來,ProxySandbox
沙箱應用之間的隔離就完成了,所有子應用對 proxy/window
物件值的存取都受到了控制。設定值只會作用在沙箱內部的 updateValueMap
集合上,取值也是優先取子應用獨立狀態池(updateValueMap
)中的值,沒有找到的話,再從 proxy/window
物件中取值。
我們對 ProxySandbox
沙箱畫一張圖來加深理解(見下圖)
SnapshotSandbox
在不支援 window.Proxy
屬性時,將會使用 SnapshotSandbox
沙箱,我們來看看其內部實現(見下圖)
我們來分析一下 SnapshotSandbox
類的幾個屬性:
欄位 | 解釋 |
---|---|
name |
沙箱名稱 |
proxy |
代理物件,此處為 window 物件 |
sandboxRunning |
當前沙箱是否啟用 |
windowSnapshot |
window 狀態快照 |
modifyPropsMap |
沙箱執行期間被修改過的 window 屬性 |
constructor |
建構函式,啟用沙箱 |
active |
啟用沙箱,在子應用掛載時啟動 |
inactive |
關閉沙箱,在子應用解除安裝時啟動 |
SnapshotSandbox
的沙箱環境主要是通過啟用時記錄 window
狀態快照,在關閉時通過快照還原 window
物件來實現的。(見下圖)
我們先看 active
函式,在沙箱啟用時,會先給當前 window
物件打一個快照,記錄沙箱啟用前的狀態(第 38~40 行
)。打完快照後,函式內部將 window
狀態通過 modifyPropsMap
記錄還原到上次的沙箱執行環境,也就是還原沙箱啟用期間(歷史記錄)修改過的 window
屬性。
在沙箱關閉時,呼叫 inactive
函式,在沙箱關閉前通過遍歷比較每一個屬性,將被改變的 window
物件屬性值(第 54 行
)記錄在 modifyPropsMap
集合中。在記錄了 modifyPropsMap
後,將 window
物件通過快照 windowSnapshot
還原到被沙箱啟用前的狀態(第 55 行
),相當於是將子應用執行期間對 window
造成的汙染全部清除。
SnapshotSandbox
沙箱就是利用快照實現了對 window
物件狀態隔離的管理。相比較 ProxySandbox
而言,在子應用啟用期間,SnapshotSandbox
將會對 window
物件造成汙染,屬於一個對不支援 Proxy
屬性的瀏覽器的向下相容方案。
我們對 SnapshotSandbox
沙箱畫一張圖來加深理解(見下圖)
掛載沙箱 - mountSandbox
我們繼續回到這張圖,genSandbox
函式不僅返回了一個 sandbox
沙箱,還返回了一個 mount
和 unmount
方法,分別在子應用掛載時和解除安裝時的時候呼叫。
我們先看看 mount
函式內部(見下圖)
首先,在 mount
內部先啟用了子應用沙箱(第 26 行
),在沙箱啟動後開始劫持各類全域性監聽(第 27 行
),我們這裡重點看看 patchAtMounting
內部是怎麼實現的。(見下圖)
patchAtMounting
內部呼叫了下面四個函式:
patchTimer(計時器劫持)
patchWindowListener(window 事件監聽劫持)
patchHistoryListener(window.history 事件監聽劫持)
patchDynamicAppend(動態新增 Head 元素事件劫持)
上面四個函式實現了對 window
指定物件的統一劫持,我們可以挑一些解析看看其內部實現。
計時器劫持 - patchTimer
我們先來看看 patchTimer
對計時器的劫持(見下圖)
從上圖可以看出,patchTimer
內部將 setInterval
進行過載,將每個啟用的定時器的 intervalId
都收集起來(第 23~24 行
),以便在子應用解除安裝時呼叫 free
函式將計時器全部清除(見下圖)。
我們來看看在子應用載入時的 setInterval
函式驗證即可(見下圖)
從上圖可以看出,在進入子應用時,setInterval
已經被替換成了劫持後的函式,防止全域性計時器洩露汙染。
動態新增樣式表和指令碼檔案劫持 - patchDynamicAppend
patchWindowListener
和 patchHistoryListener
的實現都與 patchTimer
實現類似,這裡就不作複述了。
我們需要重點對 patchDynamicAppend
函式進行解析,這個函式的作用是劫持對 head
元素的操作(見下圖)
從上圖可以看出,patchDynamicAppend
主要是對動態新增的 style
樣式表和 script
標籤做了處理。
我們先看看對 style
樣式表的處理(見下圖)
從上圖可以看出,主要的處理邏輯在 第 68~74 行
,如果當前子應用處於啟用狀態(判斷子應用的啟用狀態主要是因為:當主應用切換路由時可能會自動新增動態樣式表,此時需要避免主應用的樣式表被新增到子應用
head節點中導致出錯
),那麼動態 style
樣式表就會被新增到子應用容器內(見下圖),在子應用解除安裝時樣式表也可以和子應用一起被解除安裝,從而避免樣式汙染。同時,動態樣式表也會儲存在 dynamicStyleSheetElements
陣列中,在後面還會提到其用處。
我們再來看看對 script
指令碼檔案的處理(見下圖)
對動態 script
指令碼檔案的處理較為複雜一些,我們也來解析一波:
在 第 83~101 行
處對外部引入的 script
指令碼檔案使用 fetch
獲取,然後使用 execScripts
指定 proxy
物件(作為 window
物件)後執行指令碼檔案內容,同時也觸發了 load
和 error
兩個事件。
在 第 103~106 行
處將註釋後的指令碼檔案內容以註釋的形式新增到子應用容器內。
在 第 109~113 行
是對內嵌指令碼檔案的執行過程,就不作複述了。
我們可以看出,對動態新增的指令碼進行劫持的主要目的就是為了將動態指令碼執行時的 window
物件替換成 proxy
代理物件,使子應用動態新增的指令碼檔案的執行上下文也替換成子應用自身。
HTMLHeadElement.prototype.removeChild
的邏輯就是多加了個子應用容器判斷,其他無異,就不展開說了。
最後我們來看看 free
函式(見下圖)
這個 free
函式與其他的 patches(劫持函式)
實現不太一樣,這裡快取了一份 cssRules
,在重新掛載的時候會執行 rebuild
函式將其還原。這是因為樣式元素 DOM
從文件中刪除後,瀏覽器會自動清除樣式元素表。如果不這麼做的話,在重新掛載時會出現存在 style
標籤,但是沒有渲染樣式的問題。
解除安裝沙箱 - unmountSandbox
我們再回到 mount
函式本身(見下圖)
從上圖可以看出,在 patchAtMounting
函式中劫持了各類全域性監聽,並返回瞭解除劫持的 free
函式。在解除安裝應用時呼叫 free
函式解除這些全域性監聽的劫持行為(見下圖)
從上圖可以看到 sideEffectsRebuilders
在 free
後被返回,在 mount
的時候又將被呼叫 rebuild
重建動態樣式表。這塊環環相扣,是稍微有點繞,沒太看明白的同學可以翻上去再看一遍。
到這裡,qiankun
的最核心部分-沙箱機制,我們就已經解析完畢了,接下來我們繼續剖析別的部分。
在這裡我們畫一張圖,對沙箱的建立過程進行一個總梳理(見下圖)
註冊內部生命週期函式
在建立好了沙箱環境後,在 第 100~106 行
註冊了一些內部生命週期函式(見下圖)
在上圖中,第 106 行
的 mergeWith
方法的作用是將內建的生命週期函式與傳入的 lifeCycles
生命週期函式。
這裡的
lifeCycles
生命週期函式指的是全子應用共享的生命週期函式,可用於執行多個子應用間相同的邏輯操作,例如載入效果
之類的。(見下圖)
除了外部傳入的生命週期函式外,我們還需要關注 qiankun
內建的生命週期函式做了些什麼(見下圖)
我們對上圖的程式碼進行逐一解析:
第 13~15 行
:在載入子應用前beforeLoad
(只會執行一次)時注入一個環境變數,指示了子應用的public
路徑。第 17~19 行
:在掛載子應用前beforeMount
(可能會多次執行)時可能也會注入該環境變數。第 23~30 行
:在解除安裝子應用前beforeUnmount
時將環境變數還原到原始狀態。
通過上面的分析我們可以得出一個結論,我們可以在子應用中獲取該環境變數,將其設定為 __webpack_public_path__
的值,從而使子應用在主應用中執行時,可以匹配正確的資源路徑。(見下圖)
觸發 beforeLoad
生命週期鉤子函式
在註冊完了生命週期函式後,立即觸發了 beforeLoad
生命週期鉤子函式(見下圖)
從上圖可以看出,在 第 108 行
中,觸發了 beforeLoad
生命週期鉤子函式。
隨後,在 第 110 行
執行了 import-html-entry
的 execScripts
方法。指定了指令碼檔案的執行沙箱(jsSandbox
),執行完子應用的指令碼檔案後,返回了一個物件,物件包含了子應用的生命週期鉤子函式(見下圖)。
在 第 112~121 行
對子應用的生命週期鉤子函式做了個檢測,如果在子應用的匯出物件中沒有發現生命週期鉤子函式,會在沙箱物件中繼續查詢生命週期鉤子函式。如果最後沒有找到生命週期鉤子函式則會丟擲一個錯誤,所以我們的子應用一定要有 bootstrap, mount, unmount
這三個生命週期鉤子函式才能被 qiankun
正確嵌入到主應用中。
這裡我們畫一張圖,對子應用掛載前的初始化過程做一個總梳理(見下圖)
進入到 mount
掛載流程
在一些初始化配置(如 子應用資源、執行沙箱環境、生命週期鉤子函式等等
)準備就緒後,qiankun
內部將其組裝在一起,返回了三個函式作為 single-spa
內部的生命週期函式(見下圖)
single-spa
內部的邏輯我們後面再展開說,這裡我們可以簡單理解為 single-spa
內部的三個生命週期鉤子函式:
bootstrap
:子應用初始化時呼叫,只會呼叫一次;mount
:子應用掛載時呼叫,可能會呼叫多次;unmount
:子應用解除安裝時呼叫,可能會呼叫多次;
我們可以看出,在 bootstrap
階段呼叫了子應用暴露的 bootstrap
生命週期函式。
我們這裡對 mount
階段進行展開,看看在子應用 mount
階段執行了哪些函式(見下圖)
我們進行逐行解析:
第 127~133 行
:對單例項模式進行檢測。在單例項模式下,新的子應用掛載行為會在舊的子應用解除安裝之後才開始。(由於這裡是序列順序執行,所以如果某一處發生阻塞的話,會阻塞所有後續的函式執行)第 134 行
:執行註冊子應用時傳入的render
函式,將HTML Template
和loading
作為入參。這裡一般是在發生了一次unmount
後,再次進行mount
掛載行為時將HTML
掛載在指定容器中(見下圖)由於初始化的時候已經呼叫過一次
render
,所以在首次呼叫mount
時可能已經執行過一次render
方法。在下面的程式碼中也有對重複掛載的情況進行判斷的語句 -
if (frame.querySelector("div") === null
,防止重複掛載子應用。
第 135 行
:觸發了beforeMount
全域性生命週期鉤子函式;第 136 行
:掛載沙箱,這一步中啟用了對應的子應用沙箱,劫持了部分全域性監聽(如setInterval
)。此時開始子應用的程式碼將在沙箱中執行。(反推可知,在beforeMount
前的部分全域性操作將會對主應用造成汙染,如setInterval
)第 137 行
:觸發子應用的mount
生命週期鉤子函式,在這一步通常是執行對應的子應用的掛載操作(如ReactDOM.render、Vue.$mount
。(見下圖)
第 138 行
:再次呼叫render
函式,此時loading
引數為false
,代表子應用已經載入完成。第 139 行
:觸發了afterMount
全域性生命週期鉤子函式;第 140~144 行
:在單例項模式下設定prevAppUnmountedDeferred
的值,這個值是一個promise
,在當前子應用解除安裝時才會被resolve
,在該子應用執行期間會阻塞其他子應用的掛載動作(第 134 行
);
我們在上面很詳細的剖析了整個子應用的 mount
掛載流程,如果你還沒有搞懂的話,沒關係,我們再畫一個流程圖來幫助理解。(見下圖)
進入到 unmount
解除安裝流程
我們剛才梳理了子應用的 mount
掛載流程,我們現在就進入到子應用的 unmount
解除安裝流程。在子應用啟用階段, activeRule
未命中時將會觸發 unmount
解除安裝行為,具體的行為如下(見下圖)
從上圖我們可以看出,unmount
解除安裝流程要比 mount
簡單很多,我們直接來梳理一下:
第 148 行
:觸發了beforeUnmount
全域性生命週期鉤子函式;第 149 行
:這裡與mount
流程的順序稍微有點不同,這裡先執行了子應用的unmount
生命週期鉤子函式,保證子應用仍然是執行在沙箱內,避免造成狀態汙染。在這裡一般是對子應用的一些狀態進行清理和解除安裝操作。(如下圖,銷燬了剛才建立的vue
例項)
第 150 行
:解除安裝沙箱,關閉了沙箱的啟用狀態。第 151 行
:觸發了afterUnmount
全域性生命週期鉤子函式;第 152 行
:觸發render
方法,並且傳入的appContent
為空字串,此處可以清空主應用容器內的內容。第 153~156 行
:當前子應用解除安裝完成後,在單例項模式下觸發prevAppUnmountedDeferred.resolve()
,使其他子應用的掛載行為得以繼續進行,不再阻塞。
我們對 unmount
解除安裝流程也畫一張圖,幫助大家理解(見下圖)。
總結
到這裡,我們對 qiankun
框架的總流程梳理就差不多了。這裡應該做個總結,大家看了這麼多文字,估計大家也看累了,最後用一張圖對 qiankun
的總流程進行總結吧。
展望
傳統的雲控制檯應用,幾乎都會面臨業務快速發展之後,單體應用進化成巨石應用的問題。我們要如何維護一個巨無霸中臺應用?
上面這個問題引出了微前端架構理念,所以微前端的概念也越來越火,我們團隊最近也在嘗試轉型微前端架構。
工欲善其事必先利其器,所以本文針對 qiankun
的原始碼進行解讀,在分享知識的同時也是幫助自己理解。
這是我們團隊對微前端架構的最佳實踐(見下圖),如果有需求的話,可以在評論區留言,我們會考慮出一篇《微前端框架 qiankun
最佳實踐》來幫助大家搭建一套微前端架構。
最後一件事
這篇文章我花了大約半個月的時間來進行排版、梳理、畫圖,堅持下來一路寫完確實很不容易。
如果您已經看到這裡了,希望您還是點個贊再走吧~
如果本文對您有幫助的話,請點個贊和收藏吧!
您的點贊是對作者的最大鼓勵,也可以讓更多人看到本篇文章!
如果對 《微前端框架
qiankun最佳實踐》
有興趣的話,還請在評論區留言告訴作者吧!