萬字長文+圖文並茂+全面解析微前端框架 qiankun 原始碼 - qiankun 篇

曬兜斯發表於2020-04-06

本文將針對微前端框架 qiankun 的原始碼進行深入解析,在原始碼講解之前,我們先來了解一下什麼是 微前端

微前端 是一種類似於微服務的架構,它將微服務的理念應用於瀏覽器端,即將單頁面前端應用由單一的單體應用轉變為多個小型前端應用聚合為一的應用。各個前端應用還可以獨立開發、獨立部署。同時,它們也可以在共享元件的同時進行並行開發——這些元件可以通過 NPM 或者 Git Tag、Git Submodule 來管理。

qiankun(乾坤) 就是一款由螞蟻金服推出的比較成熟的微前端框架,基於 single-spa 進行二次開發,用於將 Web 應用由單一的單體應用轉變為多個小型前端應用聚合為一的應用。(見下圖)

qiankun

那麼,話不多說,我們的原始碼解析正式開始。

初始化全域性配置 - start(opts)

我們從兩個基礎 API - registerMicroApps(apps, lifeCycles?) - 註冊子應用start(opts?) - 啟動主應用 開始,由於 registerMicroApps 函式中設定的回撥函式較多,並且讀取了 start 函式中設定的初始配置項,所以我們從 start 函式開始解析。

我們從 start 函式開始解析(見下圖):

qiankun

我們對 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-spastartSingleSpa 方法啟動應用,這個在 single-spa 篇我們會單獨剖析,這裡可以簡單理解為啟動主應用。

從上面可以看出,start 函式負責初始化一些全域性設定,然後啟動應用。這些初始化的配置引數有一部分將在 registerMicroApps 註冊子應用的回撥函式中使用,我們繼續往下看。

註冊子應用 - registerMicroApps(apps, lifeCycles?)

registerMicroApps 函式的作用是註冊子應用,並且在子應用啟用時,建立執行沙箱,在不同階段呼叫不同的生命週期鉤子函式。(見下圖)

qiankun

從上面可以看出,在 第 70~71 行registerMicroApps 函式做了個處理,防止重複註冊相同的子應用。

第 74 行 呼叫了 single-sparegisterApplication 方法註冊了子應用。

我們直接來看 registerApplication 方法,registerApplication 方法是 single-spa 中註冊子應用的核心函式。該函式有四個引數,分別是

  • name(子應用的名稱)
  • 回撥函式(activeRule 啟用時呼叫)
  • activeRule(子應用的啟用規則)
  • props(主應用需要傳遞給子應用的資料)

這些引數都是由 single-spa 直接實現,這裡可以先簡單理解為註冊子應用(這個我們會在 single-spa 篇展開說)。在符合 activeRule 啟用規則時將會啟用子應用,執行回撥函式,返回一些生命週期鉤子函式(見下圖)。

注意,這些生命週期鉤子函式屬於 single-spa,由 single-spa 決定在何時呼叫,這裡我們從函式名來簡單理解。(bootstrap - 初始化子應用,mount - 掛載子應用,unmount - 解除安裝子應用)

qiankun

如果你還是覺得有點懵,沒關係,我們通過一張圖來幫助理解。(見下圖)

qiankun

獲取子應用資源 - import-html-entry

我們從上面分析可以看出,qiankunregisterMicroApps 方法中第一個入參 apps - Array<RegistrableApp<T>> 有三個引數 name、activeRule、props 都是交給 single-spa 使用,還有 entryrender 引數還沒有用到。

我們這裡需要關注 entry(子應用的 entry 地址)render(子應用被啟用時觸發的渲染規則) 這兩個還沒有用到的引數,這兩個引數延遲到 single-spa 子應用啟用後的回撥函式中執行。

那我們假設此時我們的子應用已啟用,我們來看看這裡做了什麼。(見下圖)

qiankun

從上圖可以看出,在子應用啟用後,首先在 第 81~84 行 處使用了 import-html-entry 庫從 entry 進入載入子應用,載入完成後將返回一個物件(見下圖)

qiankun

我們來解釋一下這幾個欄位

欄位 解釋
template 將指令碼檔案內容註釋後的 html 模板檔案
assetPublicPath 資源地址根路徑,可用於載入子應用資源
getExternalScripts 方法:獲取外部引入的指令碼檔案
getExternalStyleSheets 方法:獲取外部引入的樣式表檔案
execScripts 方法:執行該模板檔案中所有的 JS 指令碼檔案,並且可以指定指令碼的作用域 - proxy 物件

我們先將 template 模板getExternalScriptsgetExternalStyleSheets 函式的執行結果列印出來,效果如下(見下圖):

qiankun

從上圖我們可以看到我們外部引入的三個 js 指令碼檔案,這個模板檔案沒有外部 css 樣式表,對應的樣式表陣列也為空。

然後我們再來分析 execScripts 方法,該方法的作用就是指定一個 proxy(預設是 window)物件,然後執行該模板檔案中所有的 JS,並返回 JS 執行後 proxy 物件的最後一個屬性(見下圖 1)。在微前端架構中,這個物件一般會包含一些子應用的生命週期鉤子函式(見下圖 2),主應用可以通過在特定階段呼叫這些生命週期鉤子函式,進行掛載和銷燬子應用的操作。

qiankun

qiankun

qiankunimportEntry 函式中還傳入了配置項 getTemplate,這個其實是對 html 目標檔案的二次處理,這裡就不作展開了,有興趣的可以自行去了解一下。

主應用掛載子應用 HTML 模板

我們回到 qiankun 原始碼部分繼續看(見下圖)

qiankun

從上圖看出,在 第 85~87 行 處,先對單例項進行檢測。在單例項模式下,新的子應用掛載行為會在舊的子應用解除安裝之後才開始。

第 88 行 中,執行註冊子應用時傳入的 render 函式,將 HTML Templateloading 作為入參,render 函式的內容一般是將 HTML 掛載在指定容器中(見下圖)。

qiankun

在這個階段,主應用已經將子應用基礎的 HTML 結構掛載在了主應用的某個容器內,接下來還需要執行子應用對應的 mount 方法(如 Vue.$mount)對子應用狀態進行掛載。

此時頁面還可以根據 loading 引數開啟一個類似載入的效果,直至子應用全部內容載入完成。

沙箱執行環境 - genSandbox

我們回到 qiankun 原始碼部分繼續看,此時還是子應用啟用時的回撥函式部分(見下圖)

qiankun

第 90~98 行qiankun 比較核心的部分,也是幾個子應用之間狀態獨立的關鍵,那就是 js 的沙箱執行環境。如果關閉了 useJsSandbox 選項,那麼所有子應用的沙箱環境都是 window,就很容易對全域性狀態產生汙染。

我們進入到 genSandbox 內部,看看 qiankun 是如何建立的 (JS)沙箱執行環境。(見下圖)

qiankun

從上圖可以看出 genSandbox 內部的沙箱主要是通過是否支援 window.Proxy 分為 ProxySandboxSnapshotSandbox 兩種(多例項還有一種 LegacySandbox 沙箱,這裡我們不作講解)。

ProxySandbox

我們先來看看 ProxySandbox 沙箱是怎麼進行狀態隔離的(見下圖)

qiankun

我們來分析一下 ProxySandbox 類的幾個屬性:

欄位 解釋
updateValueMap 記錄沙箱中更新的值,也就是每個子應用中獨立的狀態池
name 沙箱名稱
proxy 代理物件,可以理解為子應用的 global/window 物件
sandboxRunning 當前沙箱是否在執行中
active 啟用沙箱,在子應用掛載時啟動
inactive 關閉沙箱,在子應用解除安裝時啟動
constructor 建構函式,建立沙箱環境

我們現在從 window.Proxysetget 屬性來詳細講解 ProxySandbox 是如何實現沙箱執行環境的。(見下圖)

qiankun

注意:子應用沙箱中的 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 沙箱畫一張圖來加深理解(見下圖)

qiankun

SnapshotSandbox

在不支援 window.Proxy 屬性時,將會使用 SnapshotSandbox 沙箱,我們來看看其內部實現(見下圖)

qiankun

我們來分析一下 SnapshotSandbox 類的幾個屬性:

欄位 解釋
name 沙箱名稱
proxy 代理物件,此處為 window 物件
sandboxRunning 當前沙箱是否啟用
windowSnapshot window 狀態快照
modifyPropsMap 沙箱執行期間被修改過的 window 屬性
constructor 建構函式,啟用沙箱
active 啟用沙箱,在子應用掛載時啟動
inactive 關閉沙箱,在子應用解除安裝時啟動

SnapshotSandbox 的沙箱環境主要是通過啟用時記錄 window 狀態快照,在關閉時通過快照還原 window 物件來實現的。(見下圖)

qiankun

我們先看 active 函式,在沙箱啟用時,會先給當前 window 物件打一個快照,記錄沙箱啟用前的狀態(第 38~40 行)。打完快照後,函式內部將 window 狀態通過 modifyPropsMap 記錄還原到上次的沙箱執行環境,也就是還原沙箱啟用期間(歷史記錄)修改過的 window 屬性。

在沙箱關閉時,呼叫 inactive 函式,在沙箱關閉前通過遍歷比較每一個屬性,將被改變的 window 物件屬性值(第 54 行)記錄在 modifyPropsMap 集合中。在記錄了 modifyPropsMap 後,將 window 物件通過快照 windowSnapshot 還原到被沙箱啟用前的狀態(第 55 行),相當於是將子應用執行期間對 window 造成的汙染全部清除。

SnapshotSandbox 沙箱就是利用快照實現了對 window 物件狀態隔離的管理。相比較 ProxySandbox 而言,在子應用啟用期間,SnapshotSandbox 將會對 window 物件造成汙染,屬於一個對不支援 Proxy 屬性的瀏覽器的向下相容方案。

我們對 SnapshotSandbox 沙箱畫一張圖來加深理解(見下圖)

qiankun

掛載沙箱 - mountSandbox

qiankun

我們繼續回到這張圖,genSandbox 函式不僅返回了一個 sandbox 沙箱,還返回了一個 mountunmount 方法,分別在子應用掛載時和解除安裝時的時候呼叫。

我們先看看 mount 函式內部(見下圖)

qiankun

首先,在 mount 內部先啟用了子應用沙箱(第 26 行),在沙箱啟動後開始劫持各類全域性監聽(第 27 行),我們這裡重點看看 patchAtMounting 內部是怎麼實現的。(見下圖)

qiankun

patchAtMounting 內部呼叫了下面四個函式:

  • patchTimer(計時器劫持)
  • patchWindowListener(window 事件監聽劫持)
  • patchHistoryListener(window.history 事件監聽劫持)
  • patchDynamicAppend(動態新增 Head 元素事件劫持)

上面四個函式實現了對 window 指定物件的統一劫持,我們可以挑一些解析看看其內部實現。

計時器劫持 - patchTimer

我們先來看看 patchTimer 對計時器的劫持(見下圖)

qiankun

從上圖可以看出,patchTimer 內部將 setInterval 進行過載,將每個啟用的定時器的 intervalId 都收集起來(第 23~24 行),以便在子應用解除安裝時呼叫 free 函式將計時器全部清除(見下圖)。

qiankun

我們來看看在子應用載入時的 setInterval 函式驗證即可(見下圖)

qiankun

從上圖可以看出,在進入子應用時,setInterval 已經被替換成了劫持後的函式,防止全域性計時器洩露汙染。

動態新增樣式表和指令碼檔案劫持 - patchDynamicAppend

patchWindowListenerpatchHistoryListener 的實現都與 patchTimer 實現類似,這裡就不作複述了。

我們需要重點對 patchDynamicAppend 函式進行解析,這個函式的作用是劫持對 head 元素的操作(見下圖)

qiankun

從上圖可以看出,patchDynamicAppend 主要是對動態新增的 style 樣式表和 script 標籤做了處理。

我們先看看對 style 樣式表的處理(見下圖)

qiankun

從上圖可以看出,主要的處理邏輯在 第 68~74 行,如果當前子應用處於啟用狀態(判斷子應用的啟用狀態主要是因為:當主應用切換路由時可能會自動新增動態樣式表,此時需要避免主應用的樣式表被新增到子應用head節點中導致出錯),那麼動態 style 樣式表就會被新增到子應用容器內(見下圖),在子應用解除安裝時樣式表也可以和子應用一起被解除安裝,從而避免樣式汙染。同時,動態樣式表也會儲存在 dynamicStyleSheetElements 陣列中,在後面還會提到其用處。

qiankun

我們再來看看對 script 指令碼檔案的處理(見下圖)

qiankun

對動態 script 指令碼檔案的處理較為複雜一些,我們也來解析一波:

第 83~101 行 處對外部引入的 script 指令碼檔案使用 fetch 獲取,然後使用 execScripts 指定 proxy 物件(作為 window 物件)後執行指令碼檔案內容,同時也觸發了 loaderror 兩個事件。

第 103~106 行 處將註釋後的指令碼檔案內容以註釋的形式新增到子應用容器內。

第 109~113 行 是對內嵌指令碼檔案的執行過程,就不作複述了。

我們可以看出,對動態新增的指令碼進行劫持的主要目的就是為了將動態指令碼執行時的 window 物件替換成 proxy 代理物件,使子應用動態新增的指令碼檔案的執行上下文也替換成子應用自身。

HTMLHeadElement.prototype.removeChild 的邏輯就是多加了個子應用容器判斷,其他無異,就不展開說了。

最後我們來看看 free 函式(見下圖)

qiankun

這個 free 函式與其他的 patches(劫持函式) 實現不太一樣,這裡快取了一份 cssRules,在重新掛載的時候會執行 rebuild 函式將其還原。這是因為樣式元素 DOM 從文件中刪除後,瀏覽器會自動清除樣式元素表。如果不這麼做的話,在重新掛載時會出現存在 style 標籤,但是沒有渲染樣式的問題。

解除安裝沙箱 - unmountSandbox

我們再回到 mount 函式本身(見下圖)

qiankun

從上圖可以看出,在 patchAtMounting 函式中劫持了各類全域性監聽,並返回瞭解除劫持的 free 函式。在解除安裝應用時呼叫 free 函式解除這些全域性監聽的劫持行為(見下圖)

qiankun

從上圖可以看到 sideEffectsRebuildersfree 後被返回,在 mount 的時候又將被呼叫 rebuild 重建動態樣式表。這塊環環相扣,是稍微有點繞,沒太看明白的同學可以翻上去再看一遍。

到這裡,qiankun 的最核心部分-沙箱機制,我們就已經解析完畢了,接下來我們繼續剖析別的部分。

在這裡我們畫一張圖,對沙箱的建立過程進行一個總梳理(見下圖)

qiankun

註冊內部生命週期函式

在建立好了沙箱環境後,在 第 100~106 行 註冊了一些內部生命週期函式(見下圖)

qiankun

在上圖中,第 106 行mergeWith 方法的作用是將內建的生命週期函式與傳入的 lifeCycles 生命週期函式。

這裡的 lifeCycles 生命週期函式指的是全子應用共享的生命週期函式,可用於執行多個子應用間相同的邏輯操作,例如 載入效果 之類的。(見下圖)

qiankun

除了外部傳入的生命週期函式外,我們還需要關注 qiankun 內建的生命週期函式做了些什麼(見下圖)

qiankun

我們對上圖的程式碼進行逐一解析:

  • 第 13~15 行:在載入子應用前 beforeLoad(只會執行一次)時注入一個環境變數,指示了子應用的 public 路徑。
  • 第 17~19 行:在掛載子應用前 beforeMount(可能會多次執行)時可能也會注入該環境變數。
  • 第 23~30 行:在解除安裝子應用前 beforeUnmount 時將環境變數還原到原始狀態。

通過上面的分析我們可以得出一個結論,我們可以在子應用中獲取該環境變數,將其設定為 __webpack_public_path__ 的值,從而使子應用在主應用中執行時,可以匹配正確的資源路徑。(見下圖)

qiankun

觸發 beforeLoad 生命週期鉤子函式

在註冊完了生命週期函式後,立即觸發了 beforeLoad 生命週期鉤子函式(見下圖)

qiankun

從上圖可以看出,在 第 108 行 中,觸發了 beforeLoad 生命週期鉤子函式。

隨後,在 第 110 行 執行了 import-html-entryexecScripts 方法。指定了指令碼檔案的執行沙箱(jsSandbox),執行完子應用的指令碼檔案後,返回了一個物件,物件包含了子應用的生命週期鉤子函式(見下圖)。

qiankun

第 112~121 行 對子應用的生命週期鉤子函式做了個檢測,如果在子應用的匯出物件中沒有發現生命週期鉤子函式,會在沙箱物件中繼續查詢生命週期鉤子函式。如果最後沒有找到生命週期鉤子函式則會丟擲一個錯誤,所以我們的子應用一定要有 bootstrap, mount, unmount 這三個生命週期鉤子函式才能被 qiankun 正確嵌入到主應用中。

這裡我們畫一張圖,對子應用掛載前的初始化過程做一個總梳理(見下圖)

qiankun

進入到 mount 掛載流程

在一些初始化配置(如 子應用資源、執行沙箱環境、生命週期鉤子函式等等)準備就緒後,qiankun 內部將其組裝在一起,返回了三個函式作為 single-spa 內部的生命週期函式(見下圖)

qiankun

single-spa 內部的邏輯我們後面再展開說,這裡我們可以簡單理解為 single-spa 內部的三個生命週期鉤子函式:

  • bootstrap:子應用初始化時呼叫,只會呼叫一次;
  • mount:子應用掛載時呼叫,可能會呼叫多次;
  • unmount:子應用解除安裝時呼叫,可能會呼叫多次;

我們可以看出,在 bootstrap 階段呼叫了子應用暴露的 bootstrap 生命週期函式。

我們這裡對 mount 階段進行展開,看看在子應用 mount 階段執行了哪些函式(見下圖)

qiankun

我們進行逐行解析:

  • 第 127~133 行:對單例項模式進行檢測。在單例項模式下,新的子應用掛載行為會在舊的子應用解除安裝之後才開始。(由於這裡是序列順序執行,所以如果某一處發生阻塞的話,會阻塞所有後續的函式執行)
  • 第 134 行:執行註冊子應用時傳入的 render 函式,將 HTML Templateloading 作為入參。這裡一般是在發生了一次 unmount 後,再次進行 mount 掛載行為時將 HTML 掛載在指定容器中(見下圖)

    由於初始化的時候已經呼叫過一次 render,所以在首次呼叫 mount 時可能已經執行過一次 render 方法。

    在下面的程式碼中也有對重複掛載的情況進行判斷的語句 - if (frame.querySelector("div") === null,防止重複掛載子應用。

qiankun

  • 第 135 行:觸發了 beforeMount 全域性生命週期鉤子函式;
  • 第 136 行:掛載沙箱,這一步中啟用了對應的子應用沙箱,劫持了部分全域性監聽(如 setInterval)。此時開始子應用的程式碼將在沙箱中執行。(反推可知,在 beforeMount 前的部分全域性操作將會對主應用造成汙染,如 setInterval
  • 第 137 行:觸發子應用的 mount 生命週期鉤子函式,在這一步通常是執行對應的子應用的掛載操作(如 ReactDOM.render、Vue.$mount。(見下圖)

qiankun

  • 第 138 行:再次呼叫 render 函式,此時 loading 引數為 false,代表子應用已經載入完成。
  • 第 139 行:觸發了 afterMount 全域性生命週期鉤子函式;
  • 第 140~144 行:在單例項模式下設定 prevAppUnmountedDeferred 的值,這個值是一個 promise,在當前子應用解除安裝時才會被 resolve,在該子應用執行期間會阻塞其他子應用的掛載動作(第 134 行);

我們在上面很詳細的剖析了整個子應用的 mount 掛載流程,如果你還沒有搞懂的話,沒關係,我們再畫一個流程圖來幫助理解。(見下圖)

qiankun

進入到 unmount 解除安裝流程

我們剛才梳理了子應用的 mount 掛載流程,我們現在就進入到子應用的 unmount 解除安裝流程。在子應用啟用階段, activeRule 未命中時將會觸發 unmount 解除安裝行為,具體的行為如下(見下圖)

qiankun

從上圖我們可以看出,unmount 解除安裝流程要比 mount 簡單很多,我們直接來梳理一下:

  • 第 148 行:觸發了 beforeUnmount 全域性生命週期鉤子函式;
  • 第 149 行:這裡與 mount 流程的順序稍微有點不同,這裡先執行了子應用的 unmount 生命週期鉤子函式,保證子應用仍然是執行在沙箱內,避免造成狀態汙染。在這裡一般是對子應用的一些狀態進行清理和解除安裝操作。(如下圖,銷燬了剛才建立的 vue 例項)

qiankun

  • 第 150 行:解除安裝沙箱,關閉了沙箱的啟用狀態。
  • 第 151 行:觸發了 afterUnmount 全域性生命週期鉤子函式;
  • 第 152 行:觸發 render 方法,並且傳入的 appContent 為空字串,此處可以清空主應用容器內的內容。
  • 第 153~156 行:當前子應用解除安裝完成後,在單例項模式下觸發 prevAppUnmountedDeferred.resolve(),使其他子應用的掛載行為得以繼續進行,不再阻塞。

我們對 unmount 解除安裝流程也畫一張圖,幫助大家理解(見下圖)。

qiankun

總結

到這裡,我們對 qiankun 框架的總流程梳理就差不多了。這裡應該做個總結,大家看了這麼多文字,估計大家也看累了,最後用一張圖對 qiankun 的總流程進行總結吧。

qiankun

展望

傳統的雲控制檯應用,幾乎都會面臨業務快速發展之後,單體應用進化成巨石應用的問題。我們要如何維護一個巨無霸中臺應用?

上面這個問題引出了微前端架構理念,所以微前端的概念也越來越火,我們團隊最近也在嘗試轉型微前端架構。

工欲善其事必先利其器,所以本文針對 qiankun 的原始碼進行解讀,在分享知識的同時也是幫助自己理解。

這是我們團隊對微前端架構的最佳實踐(見下圖),如果有需求的話,可以在評論區留言,我們會考慮出一篇《微前端框架 qiankun 最佳實踐》來幫助大家搭建一套微前端架構。

架構圖

最後一件事

這篇文章我花了大約半個月的時間來進行排版、梳理、畫圖,堅持下來一路寫完確實很不容易。

如果您已經看到這裡了,希望您還是點個贊再走吧~

如果本文對您有幫助的話,請點個贊和收藏吧!

您的點贊是對作者的最大鼓勵,也可以讓更多人看到本篇文章!

如果對 《微前端框架qiankun最佳實踐》 有興趣的話,還請在評論區留言告訴作者吧!

首發平臺

相關文章