Single-spa 源碼淺析

来源:https://www.cnblogs.com/Grewer/archive/2023/03/26/17259732.html
-Advertisement-
Play Games

引言 前一段時間, 正好在做微前端的接入和微前端管理平臺的相關事項。 而我們當前使用的微前端框架則是 qiankun, 他是這樣介紹自己的: qiankun 是一個基於 single-spa 的微前端實現庫,旨在幫助大家能更簡單、無痛的構建一個生產可用微前端架構系統。 所以本文基於 single-s ...


引言

前一段時間, 正好在做微前端的接入和微前端管理平臺的相關事項。 而我們當前使用的微前端框架則是 qiankun, 他是這樣介紹自己的:

qiankun 是一個基於 single-spa 的微前端實現庫,旨在幫助大家能更簡單、無痛的構建一個生產可用微前端架構系統。

所以本文基於 single-spa 源碼, 來介紹 single-spa

當前使用版本 5.9.4

啟動

在官方 demo 中, 要運行此框架需要做的是有這四步:

  1. 準備好子應用的文件, 需要拋出一些生命周期函數
  2. 一個子應用 app1 的載入函數(可以是 import 非同步載入, 也可以是 ajax/fetch 載入)
  3. 註冊子應用
  4. 啟動程式

app1.js:

export function bootstrap(props) {
    //初始化時觸發
}

export function mount(props) {
    // 應用掛載完畢之後觸發
}

export function unmount(props) {
    // 應用卸載之後觸發
}

main.js:

import * as singleSpa from 'single-spa'

const name = 'app1';
const app = () => import('./app1/app1.js'); // 一個載入函數
const activeWhen = '/app1'; // 當路由為 app1 時, 會觸發微應用的載入

// 註冊應用
singleSpa.registerApplication({name, app, activeWhen});
// 啟動
singleSpa.start();

文件結構

single-spa 的文件結構為:

├── applications
│   ├── app-errors.js
│   ├── app.helpers.js
│   ├── apps.js
│   └── timeouts.js
├── devtools
│   └── devtools.js
├── jquery-support.js
├── lifecycles
│   ├── bootstrap.js
│   ├── lifecycle.helpers.js
│   ├── load.js
│   ├── mount.js
│   ├── prop.helpers.js
│   ├── unload.js
│   ├── unmount.js
│   └── update.js
├── navigation
│   ├── navigation-events.js
│   └── reroute.js
├── parcels
│   └── mount-parcel.js
├── single-spa.js
├── start.js
└── utils
    ├── assign.js
    ├── find.js
    └── runtime-environment.js

registerApplication

我們先從註冊應用開始看起

function registerApplication(
    appNameOrConfig,
    appOrLoadApp,
    activeWhen,
    customProps
) {
    // 數據整理, 驗證傳參的合理性, 最後整理得到數據源:
    // {
    //      name: xxx,
    //      loadApp: xxx,
    //      activeWhen: xxx,
    //      customProps: xxx,
    // }
    const registration = sanitizeArguments(
        appNameOrConfig,
        appOrLoadApp,
        activeWhen,
        customProps
    );
    
    // 如果有重名,則拋出錯誤, 所以 name 應該是要保持唯一值
    if (getAppNames().indexOf(registration.name) !== -1)
        throw Error('xxx'); // 這裡省略具體錯誤
    
    // 往 apps 中添加數據
    // apps 是 single-spa 的一個全局變數, 用來存儲當前的應用數據
    apps.push(
        assign(
            {
                // 預留值
                loadErrorTime: null,
                status: NOT_LOADED, // 預設是 NOT_LOADED , 也就是待載入的狀態
                parcels: {},
                devtools: {
                    overlays: {
                        options: {},
                        selectors: [],
                    },
                },
            },
            registration
        )
    );
    
    // 判斷 window 是否為空, 進入條件
    if (isInBrowser) {
        ensureJQuerySupport(); // 確保 jq 可用
        reroute();
    }
}

reroute

reroutesingle-spa 的核心函數, 在註冊應用時調用此函數的作用, 就是將應用的 promise 載入函數, 註入一個待載入的數組中 等後面正式啟動時再調用, 類似於 ()=>import('xxx')

主要流程: 判斷是否符合載入條件 -> 開始載入代碼

export function reroute(pendingPromises = [], eventArguments) {
    if (appChangeUnderway) { //  一開始預設是 false
        // 如果是 true, 則返回一個 promise, 在隊列中添加 resolve 參數等等
        return new Promise((resolve, reject) => {
            peopleWaitingOnAppChange.push({
                resolve,
                reject,
                eventArguments,
            });
        });
    }
    
    const {
        appsToUnload,
        appsToUnmount,
        appsToLoad,
        appsToMount,
    } = getAppChanges();
    // 遍歷所有應用數組 apps , 根據 app 的狀態, 來分類到這四個數組中
    // 會根據 url 和 whenActive 判斷是否該 load
    // unload , unmount, to load, to mount
    
    let appsThatChanged,
        navigationIsCanceled = false,
        oldUrl = currentUrl,
        newUrl = (currentUrl = window.location.href);
    
    // 存儲著一個閉包變數, 是否已經啟動, 在註冊步驟中, 是未啟動的
    if (isStarted()) {
        // 省略, 當前是未開始的
    } else {
        // 未啟動, 直接返回 loadApps, 他的定義在下方
        appsThatChanged = appsToLoad;
        return loadApps();
    }
    
    function cancelNavigation() {
        navigationIsCanceled = true;
    }
    
    // 返回一個 resolve 的 promise
    // 將需要載入的應用,  map 成一個新的 promise 數組
    // 並且用 promise.all 來返回
    // 不管成功或者失敗, 都會調用 callAllEventListeners 函數, 進行路由通知
    function loadApps() {
        return Promise.resolve().then(() => {
            // toLoadPromise 主要作用在甲方有講述, 主要來定義資源的載入, 以及對應的回調
            const loadPromises = appsToLoad.map(toLoadPromise);
            
            // 通過 Promise.all 來執行, 返回的是 app.loadPromise
            // 這是資源載入
            return (
                Promise.all(loadPromises)
                .then(callAllEventListeners)
                // there are no mounted apps, before start() is called, so we always return []
                .then(() => [])
                .catch((err) => {
                    callAllEventListeners();
                    throw err;
                })
            );
        });
    }

}

toLoadPromise

註冊流程中 reroute 中的主要執行函數
主要功能是賦值 loadPromiseapp, 其中 loadPromise 函數中包括了: 執行函數、來載入應用的資源、定義載入完畢的回調函數、狀態的修改、還有載入錯誤的一些處理

export function toLoadPromise(app) {
    return Promise.resolve().then(() => {
        // 是否重覆註冊 promise 載入了
        if (app.loadPromise) {
            return app.loadPromise;
        }
        // 剛註冊的就是 NOT_LOADED 狀態
        if (app.status !== NOT_LOADED && app.status !== LOAD_ERROR) {
            return app;
        }
        
        // 修改狀態為, 載入源碼
        app.status = LOADING_SOURCE_CODE;
        
        let appOpts, isUserErr;
        
        // 返回的是 app.loadPromise
        return (app.loadPromise = Promise.resolve()
        .then(() => {
            // 這裡調用的了 app的 loadApp 函數(由外部傳入的), 開始載入資源
            // getProps 用來判斷 customProps 是否合法, 最後傳值給 loadApp 函數
            const loadPromise = app.loadApp(getProps(app));
            // 判斷 loadPromise 是否是一個 promise
            if (!smellsLikeAPromise(loadPromise)) {
                // 省略報錯
                isUserErr = true;
                throw Error("...");
            }
            return loadPromise.then((val) => {
                // 資源載入成功
                app.loadErrorTime = null;
                
                appOpts = val;
                
                let validationErrMessage, validationErrCode;
                
                // 省略對於資源返回結果的判斷
                // 比如appOpts是否是對象, appOpts.mount appOpts.bootstrap 是否是函數, 等等
                // ...
                
                // 修改狀態為, 未進入引導
                // 同時將資源結果的函數賦值, 以備後面執行
                app.status = NOT_BOOTSTRAPPED;
                app.bootstrap = flattenFnArray(appOpts, "bootstrap");
                app.mount = flattenFnArray(appOpts, "mount");
                app.unmount = flattenFnArray(appOpts, "unmount");
                app.unload = flattenFnArray(appOpts, "unload");
                app.timeouts = ensureValidAppTimeouts(appOpts.timeouts);
                
                // 執行完畢之後刪除 loadPromise
                delete app.loadPromise;
                
                return app;
            });
        })
        .catch((err) => {
            // 報錯也會刪除 loadPromise
            delete app.loadPromise;
            // 修改狀態為 用戶的傳參報錯, 或者是載入出錯
            let newStatus;
            if (isUserErr) {
                newStatus = SKIP_BECAUSE_BROKEN;
            } else {
                newStatus = LOAD_ERROR;
                app.loadErrorTime = new Date().getTime();
            }
            handleAppError(err, app, newStatus);
            
            return app;
        }));
    });
}

start

註冊完應用之後, 最後是 singleSpa.start(); 的執行

start 的代碼很簡單:

// 一般來說 opts 是不傳什麼東西的
function start(opts) {
    // 主要作用還是將標記符 started設置為 true 了
    started = true;
    if (opts && opts.urlRerouteOnly) {
        // 使用此參數可以人為地觸發事件 popstate
        setUrlRerouteOnly(opts.urlRerouteOnly);
    }
    if (isInBrowser) {
        reroute();
    }
}

reroute

上述已經講過註冊時 reroute 的一些代碼了, 這裡會忽略已講過的一些東西

function reroute(pendingPromises = [], eventArguments) {
    const {
        appsToUnload,
        appsToUnmount,
        appsToLoad,
        appsToMount,
    } = getAppChanges();
    let appsThatChanged,
        navigationIsCanceled = false,
        oldUrl = currentUrl,
        newUrl = (currentUrl = window.location.href);
    
    if (isStarted()) {
        // 這次開始執行此處
        appChangeUnderway = true;
        // 合併狀態需要變更的 app
        appsThatChanged = appsToUnload.concat(
            appsToLoad,
            appsToUnmount,
            appsToMount
        );
        // 返回 performAppChanges 函數
        return performAppChanges();
    }
}

performAppChanges

在啟動後,就會觸發此函數 performAppChanges, 並返回結果
本函數的作用主要是事件的觸發, 包括自定義事件和子應用中的一些事件

  function performAppChanges() {
    return Promise.resolve().then(() => {
        // 觸發自定義事件, 關於 CustomEvent 我們再下方詳述
        // 當前事件觸發 getCustomEventDetail
        // 主要是 app 的狀態, url 的變更, 參數等等
        window.dispatchEvent(
            new CustomEvent(
                appsThatChanged.length === 0
                    ? "single-spa:before-no-app-change"
                    : "single-spa:before-app-change",
                getCustomEventDetail(true)
            )
        );
        
        // 省略類似事件
        
        // 除非在上一個事件中調用了 cancelNavigation, 才會進入這一步
        if (navigationIsCanceled) {
            window.dispatchEvent(
                new CustomEvent(
                    "single-spa:before-mount-routing-event",
                    getCustomEventDetail(true)
                )
            );
            // 將 peopleWaitingOnAppChange 的數據重新執行 reroute 函數 reroute(peopleWaitingOnAppChange)  
            finishUpAndReturn();
            // 更新 url
            navigateToUrl(oldUrl);
            return;
        }
        
        // 準備卸載的 app
        const unloadPromises = appsToUnload.map(toUnloadPromise);
        
        // 執行子應用中的 unmount 函數, 如果超時也會有報警
        const unmountUnloadPromises = appsToUnmount
        .map(toUnmountPromise)
        .map((unmountPromise) => unmountPromise.then(toUnloadPromise));
        
        const allUnmountPromises = unmountUnloadPromises.concat(unloadPromises);
        
        const unmountAllPromise = Promise.all(allUnmountPromises);
        
        // 所有應用的卸載事件
        unmountAllPromise.then(() => {
            window.dispatchEvent(
                new CustomEvent(
                    "single-spa:before-mount-routing-event",
                    getCustomEventDetail(true)
                )
            );
        });
        
        // 執行 bootstrap 生命周期, tryToBootstrapAndMount 確保先執行 bootstrap
        const loadThenMountPromises = appsToLoad.map((app) => {
            return toLoadPromise(app).then((app) =>
                tryToBootstrapAndMount(app, unmountAllPromise)
            );
        });
        
        // 執行 mount 事件
        const mountPromises = appsToMount
        .filter((appToMount) => appsToLoad.indexOf(appToMount) < 0)
        .map((appToMount) => {
            return tryToBootstrapAndMount(appToMount, unmountAllPromise);
        });
        // 其他的部分不太重要, 可省略
    });
}

CustomEvent

CustomEvent 是一個原生 API, 這裡稍微介紹下

在某些場景中, 我們會經常做出一些模擬點擊的行為, 比如這樣:

<button id="submit" onclick="alert('Click!');">btn</button>

<script>
    const btn = document.getElementById('submit');
    btn.click()
</script>

通過 CustomEvent 也能實現這種事件:

<button id="submit" onclick="alert('Click!');">btn</button>

<script>
    const btn = document.getElementById('submit');
    btn.dispatchEvent(new CustomEvent('click'))
    // 使用 btn.dispatchEvent(new Event('click')) 也是一樣的
    // 區別在於 CustomEvent 可以傳遞自定義參數
</script>

不僅是瀏覽器原生的事件,如'click','mousedown','change','mouseover','mouseenter'等可以觸發,任意的自定義名稱的事件也是可以觸發的

document.body.addEventListener('測試自定義事件', (ev) => {
    console.log(ev.detail)
})

document.body.dispatchEvent(new CustomEvent('測試自定義事件', {
    detail: {
        foo: 1
    }
}))

整體流程

  1. 在正式環境使用 registerApplication 來註冊應用
  2. 這時候在 single-spa 內部會將註冊的信息, 初始化載入函數
  3. 使用 url 進行匹配, 是否要載入, 如果需要載入, 則歸類
  4. 如果匹配上, 開始載入應用的文件 (即使還沒使用 start)
  5. 最後使用 start, 開始發送各類事件, 調用應用的各類生命周期方法

這裡用一個簡單的圖來說明下:

image

總結

single-spa 無疑是微前端的一個重要里程碑,在大型應用場景下, 可支持多類框架, 抹平了框架間的巨大交互成本

他的核心是對子應用進行管理,但還有很多工程化問題沒做。比如JavaScript全局對象覆蓋、css載入卸載、公共模塊管理要求只下載一次等等性能問題

這又促成了其他的框架的誕生, 比較出名的就是 qiankunIsomorphic Layout Composer

而這些就是另一個話題了。

引用


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 基本操作 pwd命令 作用:顯示當前工作目錄 用法:pwd cd命令 作用:改變目錄位置 用法:cd [option] [dir] cd 目錄路徑 -進入指定目錄 cd .. -返回父目錄 cd / -進入根目錄 cd或cd ~ -進入用戶主目錄 ls命令 用法:ls [option] [file] ...
  • signal源碼位置:、 信號集合../sched/signal.h 信號結構體:../signal_types.h signal函數:..\kernel\signal.c sigio的概述流程 對於網路IO來說,一旦收到數據,信號機制會發送sigio這個信號 簡單使用sigio,udp可以使用,t ...
  • 問題 搭建Typecho的時候使用的是Mariadb資料庫,建立在Debian伺服器上,正常aptitude install mariadb-server,安裝好之後顯示success沒有任何報錯,出於習慣第一次用資料庫之前我都會mysql_secure_installation命令將其初始化避免一 ...
  • Mysql資料庫 一、資料庫 mysql服務啟動,在cmd輸入net start mysql #創建資料庫 CREATE DATABASE hsp_db01; #創建一個使用 utf8 字元集的 hsp_db02 資料庫 CREATE DATABASE hsp_db02 CHARACTER SET ...
  • P3 創建資料庫 CHARACTER SET:指定資料庫採用的字元集,如果不指定字元集,預設utf8 COLLATE:指定資料庫字元集的校對規則(常用的 utf8_bin[區分大小寫]、utf8_general_ci[不區分大小寫],註意預設是utf8_general_ci) 創建指令:CREATE ...
  • 原文鏈接:https://www.zhoubotong.site/post/92.html 通常我們直接通過遞歸查詢來達到實現子節點數據獲取的需求,這裡不談存儲過程的實現,存儲過程普通賬號有許可權限制,通常也不易於開發者維護,這裡介紹下純mysql遞歸實現的方式:測試數據可以通過之前的一篇文章來模擬。 ...
  • 一、字元串數據類型: MySQL類型名 大小 用途 對應Java類名 char 0-255 bytes 定長字元串 (姓名、性別、學號) String varchar 0-65535 bytes 變長字元串(比上面更長一點的那種) String tinytext 0-255 bytes 比較短的那種 ...
  • 隨著機票訂單業務的不斷增長,當前訂單處理系統的架構已經不能滿足日益增長的業務需求,系統性能捉襟見肘,主要體現在以下方面: 資料庫CPU資源在業務高峰期經常達到50%以上,運行狀況亮起了黃燈; 磁碟存儲空間嚴重不足,需要經常清理磁碟數據騰挪可用空間; 系統擴容能力不足,如果需要提升處理能力只能更換配置... ...
一周排行
    -Advertisement-
    Play Games
  • C#TMS系統代碼-基礎頁面BaseCity學習 本人純新手,剛進公司跟領導報道,我說我是java全棧,他問我會不會C#,我說大學學過,他說這個TMS系統就給你來管了。外包已經把代碼給我了,這幾天先把增刪改查的代碼背一下,說不定後面就要趕鴨子上架了 Service頁面 //using => impo ...
  • 委托與事件 委托 委托的定義 委托是C#中的一種類型,用於存儲對方法的引用。它允許將方法作為參數傳遞給其他方法,實現回調、事件處理和動態調用等功能。通俗來講,就是委托包含方法的記憶體地址,方法匹配與委托相同的簽名,因此通過使用正確的參數類型來調用方法。 委托的特性 引用方法:委托允許存儲對方法的引用, ...
  • 前言 這幾天閑來沒事看看ABP vNext的文檔和源碼,關於關於依賴註入(屬性註入)這塊兒產生了興趣。 我們都知道。Volo.ABP 依賴註入容器使用了第三方組件Autofac實現的。有三種註入方式,構造函數註入和方法註入和屬性註入。 ABP的屬性註入原則參考如下: 這時候我就開始疑惑了,因為我知道 ...
  • C#TMS系統代碼-業務頁面ShippingNotice學習 學一個業務頁面,ok,領導開完會就被裁掉了,很突然啊,他收拾東西的時候我還以為他要旅游提前請假了,還在尋思為什麼回家連自己買的幾箱飲料都要叫跑腿帶走,怕被偷嗎?還好我在他開會之前拿了兩瓶芬達 感覺感覺前面的BaseCity差不太多,這邊的 ...
  • 概述:在C#中,通過`Expression`類、`AndAlso`和`OrElse`方法可組合兩個`Expression<Func<T, bool>>`,實現多條件動態查詢。通過創建表達式樹,可輕鬆構建複雜的查詢條件。 在C#中,可以使用AndAlso和OrElse方法組合兩個Expression< ...
  • 閑來無聊在我的Biwen.QuickApi中實現一下極簡的事件匯流排,其實代碼還是蠻簡單的,對於初學者可能有些幫助 就貼出來,有什麼不足的地方也歡迎板磚交流~ 首先定義一個事件約定的空介面 public interface IEvent{} 然後定義事件訂閱者介面 public interface I ...
  • 1. 案例 成某三甲醫預約系統, 該項目在2024年初進行上線測試,在正常運行了兩天後,業務系統報錯:The connection pool has been exhausted, either raise MaxPoolSize (currently 800) or Timeout (curren ...
  • 背景 我們有些工具在 Web 版中已經有了很好的實踐,而在 WPF 中重新開發也是一種費時費力的操作,那麼直接集成則是最省事省力的方法了。 思路解釋 為什麼要使用 WPF?莫問為什麼,老 C# 開發的堅持,另外因為 Windows 上已經裝了 Webview2/edge 整體打包比 electron ...
  • EDP是一套集組織架構,許可權框架【功能許可權,操作許可權,數據訪問許可權,WebApi許可權】,自動化日誌,動態Interface,WebApi管理等基礎功能於一體的,基於.net的企業應用開發框架。通過友好的編碼方式實現數據行、列許可權的管控。 ...
  • .Net8.0 Blazor Hybird 桌面端 (WPF/Winform) 實測可以完整運行在 win7sp1/win10/win11. 如果用其他工具打包,還可以運行在mac/linux下, 傳送門BlazorHybrid 發佈為無依賴包方式 安裝 WebView2Runtime 1.57 M ...