引言 前一段時間, 正好在做微前端的接入和微前端管理平臺的相關事項。 而我們當前使用的微前端框架則是 qiankun, 他是這樣介紹自己的: qiankun 是一個基於 single-spa 的微前端實現庫,旨在幫助大家能更簡單、無痛的構建一個生產可用微前端架構系統。 所以本文基於 single-s ...
引言
前一段時間, 正好在做微前端的接入和微前端管理平臺的相關事項。 而我們當前使用的微前端框架則是 qiankun
, 他是這樣介紹自己的:
qiankun 是一個基於 single-spa 的微前端實現庫,旨在幫助大家能更簡單、無痛的構建一個生產可用微前端架構系統。
所以本文基於 single-spa
源碼, 來介紹 single-spa
當前使用版本 5.9.4
啟動
在官方 demo 中, 要運行此框架需要做的是有這四步:
- 準備好子應用的文件, 需要拋出一些生命周期函數
- 一個子應用 app1 的載入函數(可以是 import 非同步載入, 也可以是 ajax/fetch 載入)
- 註冊子應用
- 啟動程式
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
reroute
是 single-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
中的主要執行函數
主要功能是賦值 loadPromise
給 app
, 其中 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
}
}))
整體流程
- 在正式環境使用
registerApplication
來註冊應用 - 這時候在
single-spa
內部會將註冊的信息, 初始化載入函數 - 使用 url 進行匹配, 是否要載入, 如果需要載入, 則歸類
- 如果匹配上, 開始載入應用的文件 (即使還沒使用
start
) - 最後使用
start
, 開始發送各類事件, 調用應用的各類生命周期方法
這裡用一個簡單的圖來說明下:
總結
single-spa 無疑是微前端的一個重要里程碑,在大型應用場景下, 可支持多類框架, 抹平了框架間的巨大交互成本
他的核心是對子應用進行管理,但還有很多工程化問題沒做。比如JavaScript全局對象覆蓋、css載入卸載、公共模塊管理要求只下載一次等等性能問題
這又促成了其他的框架的誕生, 比較出名的就是 qiankun
、Isomorphic Layout Composer
。
而這些就是另一個話題了。
引用
- https://zh-hans.single-spa.js.org/docs/getting-started-overview
- https://zhuanlan.zhihu.com/p/344145423
- https://www.zhangxinxu.com/wordpress/2020/08/js-customevent-pass-param/
- https://juejin.cn/post/7054454791803502628