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

本文將針對微前端框架 qiankun 的源碼進行深刻解析,在源碼講解以前,咱們先來了解一下什麼是 微前端css

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

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

qiankun

那麼,話很少說,咱們的源碼解析正式開始。vue

初始化全局配置 - start(opts)

咱們從兩個基礎 API - registerMicroApps(apps, lifeCycles?) - 註冊子應用start(opts?) - 啓動主應用 開始,因爲 registerMicroApps 函數中設置的回調函數較多,而且讀取了 start 函數中設置的初始配置項,因此咱們從 start 函數開始解析。webpack

咱們從 start 函數開始解析(見下圖):git

qiankun

咱們對 start 函數進行逐行解析:github

  • 第 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 註冊子應用的回調函數中使用,咱們繼續往下看。web

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

registerMicroApps 函數的做用是註冊子應用,而且在子應用激活時,建立運行沙箱,在不一樣階段調用不一樣的生命週期鉤子函數。(見下圖)算法

qiankun

從上面能夠看出,在 第 70~71 行registerMicroApps 函數作了個處理,防止重複註冊相同的子應用。bootstrap

第 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 最佳實踐》 有興趣的話,還請在評論區留言告訴做者吧!

首發平臺

相關文章
相關標籤/搜索