微前端single-spa: 從應用到原始碼解析,看這一篇就夠了!

語言: CN / TW / HK

導讀: 本文從single-spa的應用、原始碼入手,探究微前端single-spa實現原理,幫助讀者更好的瞭解single-spa的工作流程從而更好的應用到現實專案中去。在閱讀之前,建議先看一下single-spa原始碼,帶著問題再來看, 這樣才能更好的加深對微前端理解。

01

背景

基礎服務中臺的統一運維平臺是一個包含資源組織、監控配置與結果檢視、跳板機關聯等內容的一站式解決方案平臺。資源組織是用服務樹節點的方式來管理我們的資源與許可權操作,同時提供了容量管理等多樣的監控手段。從前端技術上來講,我們需要把服務樹節點的資料傳遞到監控、跳板機、容量管理等功能模組。同時隨著前端技術棧的演進,我們也在逐步從Vue2技術棧演進到Vue3。

根據以上背景,我們需要解決的問題:

1. 共享登入狀態,共享服務樹的節點資料;

2. 與技術棧無關;

3. 各模組獨立維護,獨立部署;

4. 從使用者體驗角度考慮,我們需要使用者切換各模組時與單應用切換路由感覺一樣。

基於以上要求,我們選擇了qiankun微前端解決方案。qiankun是基於single-spa開發的,要想吃透qiankun必須先了解single-spa的執行機制和原理實現,在這裡跟大家分享一下我對single-spa實現原理的一些探究跟見解。

通過這篇文章你能找到以下問題答案:

1. qiankun和single-spa是什麼關係;

2. single-spa如何獲取子應用;

3. single-spa是如何訪問子應用的生命週期函式,同時對於生命週期的排程時機是怎麼樣的;

4. single-spa是如何控制路由;

5. 子應用在主應用中如何進行掛載跟解除安裝。

02

什麼是single-spa

single-spa 是一個將多個單頁面應用聚合為一個整體應用的JavaScript微前端框架。從現代框架元件生命週期中獲得靈感,將生命週期應用於整個應用程式。

single-spa 進行前端架構設計可以帶來很多好處,例如:在同一頁面上使用多個前端框架而不用重新整理頁面 (React, AngularJS, Angular, Ember, 你正在使用的框架),獨立部署每一個單頁面應用,新功能使用新框架,舊的單頁應用不用重寫可以共存,改善初始載入時間,延遲載入程式碼。

可參考:

https://zh-hans.single-spa.js.org/docs/getting-started-overview

接下來我從single-spa的應用著手,逐步介紹single-spa的原理與原始碼。

03

single-spa在專案中的應用

single-spa微前端分主應用和子應用,主應用主要是負責整體佈局、註冊子應用以及路由管理,子應用就是各個模組的單頁應用。

主應用實現:

掛載節點配置,當我們載入子應用時,讓子應用掛載在id為capacity這裡。

主應用的主要工作就是攔截路由,載入子應用。我們呼叫single-spa提供給我們的registerApplication和start的方法,其中registerApplication引數有四個:appNameOrConfig、appOrLoadApp、activeWhen和customProps,分別對應的是註冊子專案名和一些配置,下面會詳細介紹。

子應用實現:

配置子專案的打包方式

子應用注意點:

暴露single-spa需要的bootstrap、mount、unmount方法,single-spa是協議接入,這幾個方法都是必須要提供的,而且必須是非同步的;

子應用的檔案打包為window或UMD格式,並將生命週期物件掛載到window上,以便父應用使用。

stats-webpack-plugin外掛能夠生成資源清單檔案,這個清單檔案讓主應用根據Webpack配置的入口名稱找到資原始檔路徑來載入子應用資源。

整體流程方案:

04

原始碼解析

上面的原理流程圖幫助我們對single-spa的流程有個大致的瞭解,所以在這裡先對流程圖做一個簡單的介紹。

當啟動主應用的時候會呼叫registerApplication方法和start方法,兩個方法內部都呼叫了reroute函式,其中registerApplication註冊子應用,並將包裝後的子應用存入app陣列;start方法執行時會監聽url路由變化來呼叫reroute方法,此時瀏覽器導航操作的hasChange或popState事件回撥函式將收集到captureEventLister物件中待reroute後遍歷執行。

reroute方法內部呼叫getAppChanges,該方法會遍歷app應用陣列判斷生命週期,根據shouldBeActive方法location匹配的app啟用規則判斷子應用是已啟用,返回不同狀態的應用。然後reroute方法根據started變數的狀態走了兩個分支,如果started是未開啟狀態會呼叫loadApp函式執行app.loadApp來實際載入子應用。再呼叫callAllEventListeners遍歷執行路由收集的函式,因為沒有mounted所以reroute方法返回[]。

如果started是開啟狀態則呼叫performAppChanges方法先解除安裝需要解除安裝的應用,再執行appsToLoad、appsToMount載入啟動掛載應用,期間子應用的生命週期函式會掛載到app配置物件的屬性上,在指定的情況下執行。因為start方法內部簡單呼叫了reroute方法,通過閱讀原始碼我們發現可以通過registerApplication和reroute作為切入點來探究原始碼。

registerApplication()註冊應用:

通過以上程式碼我們發現 registerApplication方法來註冊子應用,每個應用程式都註冊了四件東西:

1. appNameOrConfig // 表示子應用的名稱

2. appOrLoadApp // 為載入子應用的方法

3. activeWhen // 確定應用程式何時處於活動狀態/非活動狀態

4. customProps // 表示主應用傳給子應用的物件資料

接著繼續執行registerApplication函式。

這段比較好理解,就把這個registration和一個物件進行合併,推入一個apps的數組裡面,對子應用的資訊進行快取。

接下來進入最重要的環節,繼續看回registerApplication程式碼,最後執行了一個叫做reroute的方法。

single-spa的核心 reroute():

reroute返回一個promise子應用陣列,陣列中的元素都是非同步的,reroute在整個single-spa就是負責改變app.status和執行在子應用中註冊的生命週期函式。

上面程式碼是根據Started判斷是否執行start方法,就會走入兩個不同的分支。

1.start未開啟時呼叫loadApp

我們接著來看看getAppChanges方法。上面通過呼叫getAppChanges解構出了幾個變數,函式的原始碼如下:

getAppChanges在遍歷我們apps陣列的時候,留意這段程式碼

const appShouldBeActive = app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);

這句話的作用就是根據我們當前的url進行判斷需要啟用哪一個子應用,這裡涉及到了我們的activeWhen引數選項,我們先回顧下這個選項有什麼作用,這個引數作用就是用來啟用應用的。

shouldBeActive方法:

現在就可以理解為什麼registerApplication的引數activeWhen方法這麼寫:

activeWhen: () => location.pathname.startsWith('/vue')

接下來看看loadApps函式,它的原始碼如下:

這裡註冊了一個微任務,注意是微任務,說明並不會馬上執行then之後的邏輯。appsToLoad是通過activeWhen規則分析當前使用者所在url,得到需要載入的子應用的陣列。通過map對需要啟用的子應用進行遍歷。

toLoadPromise的作用比較重要,是我執行我們呼叫registerApplication方法引數中的載入函式選項執行的地方。

toLoadPromise()原始碼如下:

該方法也是註冊一個微任務,也不是同步執行的。app.loadApp(getProps(app)),這裡開始執行loadApp,可以回頭看看loadApp是什麼東西,loadApp是我們傳入registerApplication的載入函式,這裡就是真正執行我們的載入函式的地方。我們的載入函式可能是這麼寫的:

說明這裡就是把我們應用的script標籤注入到html上。

作用:

1. 需要對子應用的程式碼進行載入,載入的寫法不限。你可以通過插入<script>標籤引用你的子應用程式碼,或者通過window.fetch去請求子應用的檔案資源。

2. 需要在載入函式中返回子應用匯出的生命週期函式提供給主應用。

從自定義的載入函式可以看出為什麼single-spa支援不同的前端框架。例如vue,react接入,原因在於我們的前端框架最終打包都會變成app.js、vendor-chunk.js等js檔案,變回原生的操作。我們從微前端的主應用去引入這些js檔案去渲染出我們的子應用,本質上最終都是轉為原生dom操作,所以說無論你的子應用用框架東西寫的,其實都一樣。所以載入函式就是single-spa對應子應用資源引入的入口地方。

2.子應用生命週期函式獲取

那麼從哪裡看出需要返回子應用的生命週期函式?我們回過頭來看LoadPromise的載入程式碼。

看看appOpts下面函式,可以看到傳入的引數有bootstrap、mount、unmount、unload等等的生命週期關鍵詞。

大概就能夠知道,他在校驗appOpts即val裡是否有這些生命週期函式。說明single-spa要求我們在載入函式中需要return出子應用的生命週期函式。validLifecycleFn(appOpts.bootstrap)直接通過appOts.bootstrap去獲取,說明我們子應用的所有的生命週期需要用物件存放起來。但是這裡有一個問題,主應用和子應用環境是有區別的,那麼我們怎麼通過主應用去獲取到我們子應用的生命週期函式呢?

那就是他們有一個共同的地方,就是window物件,在載入函式return之前我們已經通過手段在主應用中載入到了我們子應用的程式碼,window物件是共有的環境,在子應用vue的入口檔案main.js去這樣定義我們的生命週期函式。

然後在主應用的載入函式中,從window物件中去獲取子應用生命週期函式。

//主應用registerApplication方法中的載入引數
app: async () => {
  ..載入js程式碼   return window['singleCapacity'];
},

這樣的寫法使子應用和主應用的window環境混在了一起,造成了互相全域性環境的汙染。這個部分是自定義部分,只要你有辦法能夠拿到都是可以的。無論你怎麼寫,只需要注意一個條件,你拿到的生命週期必須用物件存放起來。

我們接下來解決子應用生命週期的呼叫問題。

3.start開啟時執行performAppChanges呼叫生命週期函式。

在上面registerApplication函式中,我們載入了子應用的js程式碼,獲得了應用的生命週期函式。但是在這個時候並沒有真正地呼叫它們,那麼他們的呼叫時機在哪裡?

呼叫的時機在執行start函式的時候。接下來看看start函式(如下),其本質上最核心的還是呼叫reroute函式。

但是在start中呼叫reroute函式和在registerApplication中呼叫是有區別的。

在start走入的分支會執行一個叫做performAppChanges的方法

disptach自定義事件用於在mounted之前,可以做自己想做的事。appsToUnmount.map(toUnloadPromise)先解除安裝需要解除安裝的應用(根據狀態是否銷燬子應用,更新各種狀態),appsToLoad.map(toLoadPromise)、appsToMount.map(toMountpromise)兩個函式用來載入和再掛載新的應用。appsToLoad快取了在reroute開頭通過getAppChange方法中根據activeWhen的規則匹配到需要載入的子應用(app就是我們需要載入子應用的配置資訊的json陣列),然後遍歷執行了toLoadPromise,我們在上面分析過,它會執行app.loadApp,就是registerApplication引數的載入函式。然後得到的生命週期函式會掛載到app配置物件的屬性上。在執行完了toLoadPromise後執行tryToBootstrapAndMount函式,它的原始碼如下:

這裡繼續呼叫shouldBeActive根據匹配規則檢查url,判斷是否需要執行該子應用的生命週期函式。如果確認了我們要渲染這個子應用那麼就呼叫toBootstrapPromise函式,它的原始碼如下:

resonableTime原始碼

resonableTime() 是真正呼叫子應用生命週期鉤子函式。 第一個引數是子應用的配置物件,第二個引數就是你需要呼叫的生命週期的鉤子函式的名稱。

應用的生命週期如圖:

05

路由控制

single-spa對應的路由處理的程式碼在src/navigation/navigation-events.js。

在檔案最底部有這麼一段執行邏輯:

增加了hashchange、popstate兩個監聽,監聽url的變化。如果你用的hash模式改變#後面的值或者在瀏覽器中後退,那麼就重新執行reroute。監聽了hashchange和popstate的變化,但是這兩個api無法監聽使用者直接呼叫pushState方法進行url調轉。

patchedUpdateState呼叫的時候傳入的兩個引數window.history.pushState, pushState。

假如原來的url和新的url是不同的,或者urlRerouteOnly為false的話那麼都會執行if裡面的createPopStateEvent方法。

createPopStateEvent自定義了一個popstate事件,並且把它返回,那麼在上層的window.dispatch去觸發這個事件的時候,本質上就是觸發popstate事件,那麼我們在上面原始碼的開頭就已經監聽了popstate事件,監聽到了的回撥函式就是執行reroute,所以說pushState執行reroute的手段本質上就是通過觸發popstate事件,從而觸發reroute。

以上程式碼實際做的事情很簡單,總體分為以下幾步:

1. 重寫pushState以及replaceState方法,在方法中呼叫原有方法後執行如何處理子應用的邏輯監聽hashchange及popstate事件,事件觸發後執行如何處理子應用的邏輯。

2. 重寫監聽或移除事件函式,如果應用監聽了hashchange及popstate事件就將回調函式儲存起來以備後用。

06

子應用包裝

子應用接入single-spa時,我們需在在mount方法中添加掛載邏輯,在 unmount方法中新增解除安裝邏輯,在update方法中新增更新邏輯。single-spa為不同的技術棧提供了一些邏輯抽象封裝來對子應用進行包裝。例如:

  • single-spa-react

  • single-spa-vue

  • single-spa-angular

  • single-spa-angularjs

我們就以single-spa-vue為例,single-spa-vue是一個針對vue專案的初始化、掛載、解除安裝的庫函式,可以實現single-spa註冊的應用、生命週期函式等功能。

首先看一下在子應用中如何使用:

呼叫singleSpaVue方法對子應用進行包裝,然後在對應的生命週期鉤子函式執行子應用掛載解除安裝更新等操作。

以mount方法為例,子應用在匯出的mouned 方法裡呼叫了vueLifecycles.mount() 如下:

export function mount (props) {
  return vueLifecycles.mount(props)
}

single-spa-vue原始碼如下:

通過閱讀原始碼得知vueLifecycles.mount()方法主要是做了兩件事:

1. 是否指定了掛載節點,如果沒有就建立預設掛載節點。

2. 將子應用的vue物件初始化並掛載到指定的節點上。

同理vueLifecycles.unmount方法主要用於當前子應用呼叫$destroy方法來解除安裝子應用並清空dom節點。由此得知single-spa子專案的的掛載、更新、解除安裝等操作,並不是single-spa原生提供的,使用者可以根據自己的需要來自行實現子應用的掛載、解除安裝及更新等邏輯。通過single-spa包裝的主應用是一個基座,它提供相應的協議,子應用按照協議進行包裝就可以接入主應用。主應用就像插座,而子應用就像不同的電器,只要遵循某種協議就可以輕鬆實現可插拔操作。

07

結語

single-spa通過reroute和路由控制來呼叫子應用。但是在single-spa的開發過程中,我們需要自己手動去寫呼叫子應用的方法,而qiankun不需要,qiankun只需要你傳入響應的apps的配置即可,它會幫助我們去載入。還有JS隔離問題,CSS樣式隔離問題,為了解決single-spa這些問題,阿里基於single-spa研發了qiankun微前端框架,真正實現了微前端的所有特性。下篇文章我們會介紹qiankun在未來雲平臺的應用,敬請期待。

掃描下方二維碼新增 「好未來技術」 微信官方賬號

進入好未來技術官方交流群與作者實時互動~

(若掃碼無效,可通過微訊號 TAL-111111 直接新增)

- 也許你還想看 -

Web 基礎系列之 ES6

從輸入網址到內容返回解析|前端工程師需要掌握這些知識

Web前端工程師實現Native APP需求,用flutter做可攻可守的混合開發

我知道你“在看”喲~