前言
看這篇文章之前先要了解微前端概念,single-spa如何使用。
這篇文章主要分析single-spa原理。然后分析完之后,作者說說自己對於同時都是微前端框架qiankun和single-spa的關系的一些理解,因為在我學習剛開始微前端的時候,我其實不太明白都是微前端框架qiankun和single-spa有什么區別,它們是什么關系,可能一些讀者也會有這樣的疑問,同時這篇文章作為鋪墊,后面會發出qiankun的原理學習。
我們關注幾個問題:
1. 我們怎么通過single-spa去讀取子應用的js?
2. single-spa是怎么訪問子應用的生命周期函數,同時對於生命周期的調度時機是怎么樣的?
3. 主應用是怎么傳入props進入子應用的生命周期函數中?
4. 主應用是怎么控制路由?
5. 如果有了解過qiankun,那么qiankun和single-spa是什么關系?
本文主要從兩個函數為切入,就是registerApplication和start函數
registerApplication函數
我們一般會這樣去寫registerApplication函數
singleSpa.registerApplication({ //注冊微前端服務
name: 'singleDemo', app: async () => { ... return ...; }, activeWhen: () => location.pathname.startsWith('xxx') // 配置微前端模塊
});
它的源碼如下:
export function registerApplication( appNameOrConfig, appOrLoadApp, activeWhen, customProps ) { //sanitizeArguments作用就是規范化參數和參數格式校驗 const registration = sanitizeArguments( appNameOrConfig, appOrLoadApp, activeWhen, customProps ); //這里就是校驗你注冊的應用是不是有重復名字的,有的話就拋出異常 if (getAppNames().indexOf(registration.name) !== -1) throw Error( formatErrorMessage( 21, __DEV__ && `There is already an app registered with name ${registration.name}`, registration.name ) ); //將應用信息推入apps數組中,assign就是用於兩個對象的合並 apps.push( assign( { loadErrorTime: null, status: NOT_LOADED, parcels: {}, devtools: { overlays: { options: {}, selectors: [], }, }, }, registration ) ); if (isInBrowser) { ensureJQuerySupport(); //這個reroute做了很多的事情。執行了用戶自定義加載函數。存放了生命周期等等 reroute(); } }
第一個執行的函數是sanitizeArguments,它的作用就是把我們傳入registerApplication函數的參數就是規范化,有寫錯誤的就拋出異常,下面是它的源碼
function sanitizeArguments( appNameOrConfig, appOrLoadApp, activeWhen, customProps ) { const usingObjectAPI = typeof appNameOrConfig === "object"; const registration = { name: null, loadApp: null, activeWhen: null, customProps: null, }; if (usingObjectAPI) { validateRegisterWithConfig(appNameOrConfig); registration.name = appNameOrConfig.name; registration.loadApp = appNameOrConfig.app; registration.activeWhen = appNameOrConfig.activeWhen; registration.customProps = appNameOrConfig.customProps; } else { //這句話的作用就是檢查用戶所傳入的參數是不是符合規范的,不符合規范的話就拋出異常 validateRegisterWithArguments( appNameOrConfig, appOrLoadApp, activeWhen, customProps ); registration.name = appNameOrConfig; registration.loadApp = appOrLoadApp; registration.activeWhen = activeWhen; registration.customProps = customProps; } //如果第二個參數不是函數的話就轉入promise,是函數的話就原封不動返回 registration.loadApp = sanitizeLoadApp(registration.loadApp); //看看CustomProps有沒有寫內容,沒有的話就默認返回對象,有的話就原封返回 registration.customProps = sanitizeCustomProps(registration.customProps); //如果你的activeWhen寫成函數的就原封返回,如果是一個字符串就幫你處理為函數再返回 registration.activeWhen = sanitizeActiveWhen(registration.activeWhen); return registration; }
閱讀它的源碼我們發現,里面就是一些規范化操作的細節,同時規划化完成之后會在registration對應的屬性上進行賦值。最后把這個registration返回出去。
所以這段代碼執行的作用就是:
1. 規范化屬性,有錯誤的就拋出異常。
2. 最后把我們的傳入的參數整理完了之后的屬性值重新賦值給registration,並且返回出去。
規范化操作結束之后,接着繼續執行registerApplication函數
export function registerApplication( appNameOrConfig, appOrLoadApp, activeWhen, customProps ) { ... if (getAppNames().indexOf(registration.name) !== -1) throw Error( formatErrorMessage( 21, __DEV__ && `There is already an app registered with name ${registration.name}`, registration.name ) ); apps.push( assign( { loadErrorTime: null, status: NOT_LOADED, parcels: {}, devtools: { overlays: { options: {}, selectors: [], }, }, }, registration ) ); ... }
接下來的貼出的這段比較好好理解。從錯誤信息也能夠看出,第一個if語句大概就是檢查你注冊的子應用信息是否有重名的現象。有的話就拋出異常,沒有的話就把這個registration和一個對象進行合並,推入一個apps的數組里面,對子應用的信息進行緩存。
注意看到對象中有一個status的屬性。這個屬性后面會被多次提到,同時還有一個 registration.loadApp屬性,因為這里存放的是我們注冊選項的加載函數,決定了我們用什么樣的方式去加載子應用的代碼。
接下來進入最重要的環節 ,繼續看回registerApplication代碼,最后執行了一個叫做reroute的方法,
export function registerApplication( appNameOrConfig, appOrLoadApp, activeWhen, customProps ) { ...if (isInBrowser) { ensureJQuerySupport(); reroute(); } }
下面看看reroute做了什么事情,這個reroute方法同時也會在start中進行調用。
reroute
reroute在整個single-spa的職能是什么,就是負責改變app.status。和執行在子應用中注冊的生命周期函數。
export function reroute(pendingPromises = [], eventArguments) {
//appChangeUnderway定義在了本文件的開頭,默認是置為false。所以在registerApplication方法使用的時候,是不會出發的if的邏輯
//在start之后就會被置為true。意味着在start重新調用reroute的時候就會進入這段if邏輯
if (appChangeUnderway) { return new Promise((resolve, reject) => { peopleWaitingOnAppChange.push({ resolve, reject, eventArguments, }); }); } const { appsToUnload, appsToUnmount, appsToLoad, appsToMount, } = getAppChanges(); let appsThatChanged, navigationIsCanceled = false, //oldUrl在文件開頭獲取 oldUrl = currentUrl, //新的url在本文件中獲取 newUrl = (currentUrl = window.location.href); //isStarted判斷是否執行start方法,start方法開頭把started置為true,就會走入這個分支 if (isStarted()) { appChangeUnderway = true; appsThatChanged = appsToUnload.concat( appsToLoad, appsToUnmount, appsToMount ); return performAppChanges(); } else { appsThatChanged = appsToLoad; return loadApps(); } function cancelNavigation() { navigationIsCanceled = true; } function loadApps() { ... } function performAppChanges() { ... } function finishUpAndReturn() { ... } }
開頭的if語句我已經給出了注釋,我們接着來看看getAppChanges方法。上面通過調用getAppChanges解構出了幾個變量,函數的源碼如下:
export function getAppChanges() {
//將應用分為4類
//需要被移除的 const appsToUnload = [],
//需要被卸載的 appsToUnmount = [],
//需要被加載的 appsToLoad = [],
//需要被掛載的 appsToMount = []; // We re-attempt to download applications in LOAD_ERROR after a timeout of 200 milliseconds const currentTime = new Date().getTime();
//apps是我們在registerApplication方法中注冊的子應用的信息的json對象會被緩存在apps數組中,apps裝有我們子應用的配置信息 apps.forEach((app) => { //shouldBeActive這里就是真正執行activeWhen中定義的函數如果根據當前的location.href匹配路徑成功的話,就說明此時 //應該激活這個應用 const appShouldBeActive = app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);
//我們在執行registerApplication前面的時候把app.status設置為了NOT_LOADED,看看下面的swtich,如果在上面的匹配成功的話就把app推入appsLoad數組中,表明這個子應用即將被加載。 switch (app.status) { case LOAD_ERROR: if (appShouldBeActive && currentTime - app.loadErrorTime >= 200) { appsToLoad.push(app); } break; //最開始注冊完之后的app狀態就是NOT_LOADED case NOT_LOADED: case LOADING_SOURCE_CODE: //如果app需要激活的話就推入數組 if (appShouldBeActive) { appsToLoad.push(app); } break; case NOT_BOOTSTRAPPED: case NOT_MOUNTED: if (!appShouldBeActive && getAppUnloadInfo(toName(app))) { appsToUnload.push(app); } else if (appShouldBeActive) { appsToMount.push(app); } break; case MOUNTED: if (!appShouldBeActive) { appsToUnmount.push(app); } break; // all other statuses are ignored } }); return { appsToUnload, appsToUnmount, appsToLoad, appsToMount }; }
getAppChanges在遍歷我們apps數組的時候,留意到這句話
const appShouldBeActive = app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);
這句話的作用就是根據我們當前的url進行判斷需要激活哪一個子應用,這里涉及到了我們的activeWhen參數選項,我們先回顧下這個選項有什么作用,下面是從官方文檔的截圖,可以看到這個參數作用就是用來激活應用的
我們看看shouldBeActive的源碼
//函數返回true or false表明你當前的url是否匹配到了子應用activeWhen: () => location.pathname.startsWith('/vue')
export function shouldBeActive(app) { try {
//可以看到這里就調用了activeWhen選項。並且傳入window.location作為參數 return app.activeWhen(window.location); } catch (err) { handleAppError(err, app, SKIP_BECAUSE_BROKEN); return false; } }
//在網上的一些例子中可能會這么寫這個參數選項,那結合上面的意思就是說匹配路徑開頭為/vue的,現在你應該明白這個activeWhen到底有什么作用。
看完了onAppChange函數接下來回頭看reroute函數
export function reroute(pendingPromises = [], eventArguments) { ... let appsThatChanged, navigationIsCanceled = false, //oldUrl在文件開頭獲取 oldUrl = currentUrl, //新的url在本函數中獲取 newUrl = (currentUrl = window.location.href); //isStarted判斷是否執行start方法,start方法開頭把started置為true,就會走入這個分支 if (isStarted()) { appChangeUnderway = true; appsThatChanged = appsToUnload.concat( appsToLoad, appsToUnmount, appsToMount ); return performAppChanges(); } else {
//registerApplication會走入這個分支 appsThatChanged = appsToLoad; return loadApps(); } function cancelNavigation() { navigationIsCanceled = true; } function loadApps() { ... } function performAppChanges() { ... } function finishUpAndReturn() { ... } }
接下來看看loadApps函數,它的源碼如下:
function loadApps() {
//這里注冊了一個微任務,注意是微任務說明並不會馬上執行then之后的邏輯 return Promise.resolve().then(() => {
//appsToLoad是通過activeWhen規則分析當前用戶所在url,得到需要加載的子應用的數組。下面就開始通過map對需要激活的子應用進行遍歷
//toLoadPromise的作用比較重要,是我執行我們調用registerApplication方法參數中的加載函數選項執行的地方,toLoadPromise源代碼在下面
const loadPromises = appsToLoad.map(toLoadPromise); return ( Promise.all(loadPromises) .then(callAllEventListeners) // there are no mounted apps, before start() is called, so we always return [] //在start之前生命周期函數都已經准備就緒,但是不會被觸發。直到start才會開始觸發,從上面的注釋就可以知道 .then(() => []) .catch((err) => { callAllEventListeners(); throw err; }) ); }); }
export function toLoadPromise(app) {
//注意這里也是注冊一個微任務,也不是同步執行的
//app就是子應用對應的配置json對象 return Promise.resolve().then(() => {
//在registerApplication返回的對json對象是沒有loadPromise屬性的 if (app.loadPromise) { return app.loadPromise; }
//在registerApplication的時候app.status === NOT_LOADED狀態,不會進入if語句 if (app.status !== NOT_LOADED && app.status !== LOAD_ERROR) { return app; }
//這里改變了app的狀態 app.status = LOADING_SOURCE_CODE; let appOpts, isUserErr;
//這里注冊了一個微任務,並把返回結果賦值給了app.loadPromise return (app.loadPromise = Promise.resolve() .then(() => { //這里開始執行loadApp,可以回頭看看loadApp是什么東西,loadApp我們傳入registerApplication的加載函數!!
//這里就是真正執行我們的加載函數。我們的加載函數可能是這么寫的(如下),說明這里就是把我們為應用的script標簽注入到html上
// app: async () => {
// await runScript('http://127.0.0.1:8081/static/js/chunk-vendors.js');
// await runScript('http://127.0.0.1:8081/static/js/app.js');
// },
const loadPromise = app.loadApp(getProps(app)); //這個校驗傳入register的第二個參數返回的是不是promise if (!smellsLikeAPromise(loadPromise)) { // The name of the app will be prepended to this error message inside of the handleAppError function isUserErr = true; throw Error( formatErrorMessage( 33, __DEV__ && `single-spa loading function did not return a promise. Check the second argument to registerApplication('${toName( app )}', loadingFunction, activityFunction)`, toName(app) ) ); }
//這個return十分重要,首先我們要知道上面執行app.loadApp(getProps(app))會是什么?
//看下面的分析
return loadPromise.then((val) => { ...省略若干 }); }) .catch((err) => { ...省略若干
})); }); }
用戶自定義加載函數的執行
在上面代碼的toLoadPromise中,有這么一句話app.loadApp(getProps(app))。從分析中我們知道,app.loadApp執行的函數的就是用戶調用registerApplication傳入的加載函數。就是下面圖的東西
我們要明白在加載函數中需要我們寫上我們對於子應用代碼的加載過程,上面的例子是一種簡單的寫法,我們還可能會這么寫(如下),不管怎么寫最終目的都是一樣的。
const runScript = async (url) => { return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = url; script.onload = resolve; script.onerror = reject; const firstScript = document.getElementsByTagName('script')[0]; firstScript.parentNode.insertBefore(script, firstScript); }); }; singleSpa.registerApplication({ //注冊微前端服務 name: 'singleDemo', app: async () => { await runScript('http://127.0.0.1:8081/static/js/chunk-vendors.js'); await runScript('http://127.0.0.1:8081/static/js/app.js'); console.log(window) return window['singleDemo']; }, activeWhen: () => location.pathname.startsWith('/vue') // 配置微前端模塊前 });
最終目的是什么:
1.需要對於子應用的代碼進行加載,加載的寫法不限。你可以通過插入<script>標簽引用你的子應用代碼,或者像qiankun一樣通過window.fetch去請求子應用的文件資源。
從這里加載函數的自定義我們可以看出為什么single-spa這個可以支持不同的前端框架例如vue,react接入,原因在於我們的前端框架最終打包都會變成app.js, vendor-chunk.js等js文件,變回原生的操作。我們從微前端的主應用去引入這些
js文件去渲染出我們的子應用,本質上最終都是轉為原生dom操作,所以說無論你的子應用用框架東西寫的,其實都一樣。所以加載函數就是single-spa對應子應用資源引入的入口地方。
2. 第二個目的就是需要在加載函數中我們要返回出子應用中導出的生命周期函數提供給主應用,那么從哪里看出需要返回子應用的生命周期函數。我們回過頭來看LoadPromise的加載代碼(如下)。
看看appOpts下面的if函數,可以看到傳入的參數有bootstrap, mount, unmount, unload等等的生命周期關鍵詞。讀者可以仔細閱讀一下它的校驗函數你大概就能夠知道,他在校驗appOpts即val里是否有這些生命周期函數。說明single-spa要求我們
在加載函數中需要return出子應用的生命周期函數。
export function toLoadPromise(app) { return Promise.resolve().then(() => { ...省略 return (app.loadPromise = Promise.resolve() .then(() => { ...省略 return loadPromise.then((val) => { app.loadErrorTime = null; //val就是裝有子應用的生命周期函數,appOpts其中裝有的就是子應用獲取的到的生命周期函數 appOpts = val; let validationErrMessage, validationErrCode; if (typeof appOpts !== "object") { validationErrCode = 34; if (__DEV__) { validationErrMessage = `does not export anything`; } } if ( // ES Modules don't have the Object prototype //這個if語句就是開始檢驗你有沒有bootstrap這個生命周期函數 Object.prototype.hasOwnProperty.call(appOpts, "bootstrap") && //這個校驗看看你的初始化屬性是不是函數或者是一個函數數組,從這里可以看出生命周期函數可以寫成數組的形式 !validLifecycleFn(appOpts.bootstrap) ) { ... } if (!validLifecycleFn(appOpts.mount)) { ... } if (!validLifecycleFn(appOpts.unmount)) { ... } const type = objectType(appOpts); //這里查看錯誤信息,有的話就排除異常 if (validationErrCode) { ... } if (appOpts.devtools && appOpts.devtools.overlays) { app.devtools.overlays = assign( {}, app.devtools.overlays, appOpts.devtools.overlays ); } app.status = NOT_BOOTSTRAPPED; //這里把生命周期的執行函數掛載到了app子應用的屬性上,但是並沒有真正的執行 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); delete app.loadPromise; return app; }); }) .catch((err) => { ... })); }); }
子應用生命周期函數獲取原理
我們可以看下他是怎么去獲得子應用生命周期函數的
validLifecycleFn(appOpts.bootstrap)
直接通過appOts.bootstrap去獲取,說明一個問題。我們導出的子應用所有的生命周期需要用對象存放起來。
但是這里有一個問題,主應用和子應用環境是有區別的,那么我們怎么通過主應用去獲取到我們子應用的生命周期函數呢?
雖然說主應用和子應用環境是有區別的,但是他們也有一個共同的地方,就是window對象,在加載函數return之前我們已經通過手段在主應用中加載到了我們子應用的代碼,那么我們可以想,既然
window對象是共有的環境,那么我們完全毫不負責任地在子應用vue的入口文件main.js去這樣定義我們的生命周期函數。
//子應用vue main.js
export const bootstrap = async () => { console.log('bootstrap') }
export const mount = async () => { console.log('mount') };
export const unmount = async () => { console.log('unmount') }; window['singleDemo'] = { bootstrap, mount, unmount }
然后在主應用的加載函數中從window中去獲取子應用生命周期函數。
//主應用registerApplication方法中的加載參數
app: async () => {
..加載js代碼 return window['singleDemo']; },
為什么上面說毫不負責任,原因在於,這樣的寫法子應用和主應用的window環境混在了一起,造成了互相全局環境的污染,當然這個只是一個例子讓讀者明白,主應用是怎么獲取到子應用生命周期函數。
要想避免這個污染是有辦法的,在qiankun框架中,它通過了一個沙箱環境對主應用和子應用之間的環境進行了隔離,避免了這種全局污染,同時qiankun獲取的子應用生命周期的手段和我寫的是不一樣的,
這個部分是自定義部分,只要你有辦法能夠拿到都是可以的。
還有一個叫做single-spa-vue的工具也能夠幫住你解決這個問題,有興趣的讀者可以研究一下他。
無論你怎么寫,只需要注意一個條件,你拿到的生命周期的必須用對象存放起來。
到這里對於獲取子應用的生命周期函數的解析就這么多,我也只知道這么多了。
我們接下來解決子應用生命周期生命周期的調用問題。
生命周期函數的調用。
在上面registerApplication函數中,我們加載了子應用的js代碼,獲得了應用的生命周期函數。但是在這個時候並沒有真正的嗲調用它們,那么他們的調用時機在哪里?
可以先告訴讀者調用的時機在執行start函數。接下來看看start函數(如下),我們看到start函數的代碼十分短。其實本質上最核心的還是調用reroute函數。
export function start(opts) { started = true; if (opts && opts.urlRerouteOnly) { setUrlRerouteOnly(opts.urlRerouteOnly); } if (isInBrowser) { reroute(); } }
但是在start中調用reroute函數和在registerApplication中調用是有區別的。
export function reroute(pendingPromises = [], eventArguments) { if (appChangeUnderway) { ... } const { appsToUnload, appsToUnmount, appsToLoad, appsToMount, } = getAppChanges(); let appsThatChanged, navigationIsCanceled = false, //oldUrl在文件開頭獲取 oldUrl = currentUrl, //新的url在本文件中獲取 newUrl = (currentUrl = window.location.href); //isStarted判斷是否執行start方法,start方法開頭把started置為true,就會走入這個分支
//在registerApplication中執行reroute和在start中執行reroute區別就在這個if分支!! if (isStarted()) { appChangeUnderway = true; appsThatChanged = appsToUnload.concat( appsToLoad, appsToUnmount, appsToMount ); return performAppChanges(); } else { appsThatChanged = appsToLoad; return loadApps(); }
...省略
}
在start走入的分支會執行一個叫做performAppChanges。
function performAppChanges() {
//這里注冊了一個微任務。 return Promise.resolve().then(() => { // https://github.com/single-spa/single-spa/issues/545
//開頭通過window.dispatchEvent注冊了綁定了一些自定義事件。我們暫且不關心它們。我們只關心在那里執行了我們的bootstrap生命周期函數
window.dispatchEvent( new CustomEvent( ... ) ); window.dispatchEvent( new CustomEvent( ... ) ); if (navigationIsCanceled) { ... return; } const unloadPromises = appsToUnload.map(toUnloadPromise); const unmountUnloadPromises = appsToUnmount .map(toUnmountPromise) .map((unmountPromise) => unmountPromise.then(toUnloadPromise)); const allUnmountPromises = unmountUnloadPromises.concat(unloadPromises); const unmountAllPromise = Promise.all(allUnmountPromises); unmountAllPromise.then(() => { ... }); /* We load and bootstrap apps while other apps are unmounting, but we * wait to mount the app until all apps are finishing unmounting */
//bootstrap生命周期函數在這里執行
//appsToLoad緩存了在reroute開頭通過getAppChange,然后根據activeWhen的規則匹配到現在需要加載的子應用
//app就是我們需要加載子應用的配置信息的json數組,然后對應在map的開頭執行了toLoadPromise,toLoadPromise
//我們在上面進行過了分析,它會執行app.loadApp就是我們參數的加載函數。然后得到的生命周期函數會把掛載到app配置對象
//的屬性上。在執行完了toLoadPromise后然后就執行tryToBootstrapAndMount函數,它的源碼如下:
const loadThenMountPromises = appsToLoad.map((app) => { return toLoadPromise(app).then((app) => tryToBootstrapAndMount(app, unmountAllPromise) ); }); ... }); }
tryToBootstrapAndMount源碼:
function tryToBootstrapAndMount(app, unmountAllPromise) {
//這里繼續調用sholdBeActive根據匹配規則檢查url,判斷是否需要執行該子應用的生命周期函數。 if (shouldBeActive(app)) {
//如果確認了我們要渲染這個子應用那么調用toBootstrapPromise函數,它的源碼如下: return toBootstrapPromise(app).then((app) => unmountAllPromise.then(() => shouldBeActive(app) ? toMountPromise(app) : app ) ); } else { return unmountAllPromise.then(() => app); } }
toBootstrapPromise源碼:
export function toBootstrapPromise(appOrParcel, hardFail) {
//第一個參數就是我們傳入的app子應用的配置對象 return Promise.resolve().then(() => {
//如果判斷應用不是處於沒啟動過的狀態就直接返回, if (appOrParcel.status !== NOT_BOOTSTRAPPED) { return appOrParcel; } //如果檢查到了應用處於需要啟動狀態,那么就改變應用狀態變為BOOTSTRAPING appOrParcel.status = BOOTSTRAPPING;
//這里檢查下子應用配置中有沒有bootstrap生命周期函數,沒有的話就進入下面邏輯 if (!appOrParcel.bootstrap) { // Default implementation of bootstrap return Promise.resolve().then(successfulBootstrap); }
//然后這里執行了resonableTime,這個函數就是真正調用了生命周期函數,它的源碼在下面 return reasonableTime(appOrParcel, "bootstrap") .then(successfulBootstrap) .catch((err) => { ... }); }); function successfulBootstrap() { appOrParcel.status = NOT_MOUNTED; return appOrParcel; } }
resonableTime源碼
export function reasonableTime(appOrParcel, lifecycle) {
//傳入的第一個參數依然是子應用的配置對象
//第二個參數就是你需要調用的生命周期的鈎子函數的名稱 ...省略return new Promise((resolve, reject) => { let finished = false; let errored = false;
//這里就是調用了生命周期的函數,同時可以看到調用的時候傳入了一個參數,這個參數就是從主應用中傳入子應用生命周期函數的值。 appOrParcel[lifecycle](getProps(appOrParcel)) .then((val) => { finished = true; resolve(val); }) .catch((val) => { finished = true; reject(val); }); ... }); }
路由控制
single-spa對應的路由處理的js在src/navigation/navigation-events.js。在文件最底部有這么一段執行邏輯
function urlReroute() {
reroute([], arguments);
}
if (isInBrowser) {
// We will trigger an app change for any routing events. //增加了兩個監聽,監聽url的變化,如果你用的hash模式改變#后面的值或者在瀏覽器中后退,那么就重新執行reroute。 window.addEventListener("hashchange", urlReroute); window.addEventListener("popstate", urlReroute); // Monkeypatch addEventListener so that we can ensure correct timing const originalAddEventListener = window.addEventListener; const originalRemoveEventListener = window.removeEventListener; window.addEventListener = function (eventName, fn) { if (typeof fn === "function") { export const routingEventsListeningTo = ["hashchange", "popstate"];
if ( routingEventsListeningTo.indexOf(eventName) >= 0 && //這里檢查在capturedEventListeners數組中,有沒有存放和你注冊過的相同的方法,沒有的話就把這個方法加上去 //那這里主要針對的是路由的事件 !find(capturedEventListeners[eventName], (listener) => listener === fn) ) { capturedEventListeners[eventName].push(fn); return; } } return originalAddEventListener.apply(this, arguments); };
window.removeEventListener = function (eventName, listenerFn) { if (typeof listenerFn === "function") { //export const routingEventsListeningTo = ["hashchange", "popstate"]; if (routingEventsListeningTo.indexOf(eventName) >= 0) { capturedEventListeners[eventName] = capturedEventListeners[ eventName ].filter((fn) => fn !== listenerFn); return; } } return originalRemoveEventListener.apply(this, arguments); };
//利用裝飾器模型,在原有window.history.pushState功能上進行增加。但是為什么要增加?原因在於我們看到這個代碼開頭,只是監聽了hashchange和popstate的變化,但是這兩個api是無法監聽用戶直接調用pushState方法進行url調轉
//下面的功能增加就是當用戶在執行這個pushState方法的時候也能夠重新reroute,下面我們來看看patchUpdateState源碼 window.history.pushState = patchedUpdateState( window.history.pushState, "pushState" );
//replaceState方法和pushState方法同理 window.history.replaceState = patchedUpdateState( window.history.replaceState, "replaceState" ); if (window.singleSpaNavigate) { ... } else { /* For convenience in `onclick` attributes, we expose a global function for navigating to * whatever an <a> tag's href is. */ window.singleSpaNavigate = navigateToUrl; } }
patchUpdateState源碼如下:
function patchedUpdateState(updateState, methodName) {
//下面是在調用的時候傳入的兩個參數 // window.history.pushState, // "pushState" return function () { //獲得跳轉之前的頁面url const urlBefore = window.location.href; //劫持使用原生pushState方法,這里就保證了原來的pushState功能不會丟失 const result = updateState.apply(this, arguments); //獲得跳轉之后的頁面url const urlAfter = window.location.href; //假如原來的url和新的url是不同的或者urlRerouteOnly為false的話那么都會執行if里面的邏輯。
//這個urlRerouteOnly是官方文檔上可以傳入start( urlRerouteOnly: true)函數的參數
//如果說你在start時候傳入了這個參數,那么如果當你調用pushState的時候,但是你調用pushState
//並不想改變url,只是想達到你某個目的,那么此時,前后url沒發生改變那么就不會重新reroute,從一定
//程度上提升了性能。
//假如你不傳入這個參數,那么只要你調用pushState就會重新reroute。
//下面看看createPopStateEvent看看它是通過什么方式執行reroute
if (!urlRerouteOnly || urlBefore !== urlAfter) { //這里本質上執行了自定義個popstate事件,回調之后就執行了reroute window.dispatchEvent( createPopStateEvent(window.history.state, methodName) ); } return result; }; } function createPopStateEvent(state, originalMethodName) { let evt; try {
//這里自定義了一個popstate事件,並且返回,那么在上層的window.dispatch去觸發·這個事件的時候本質上就是
//觸發popstate事件,那么我們在上面源碼的開頭就已經監聽了popstate事件,監聽到了的回調函數就是執行reroute
//所以說pushState執行reroute的手段本質上就是通過觸發popstate事件,從而觸發reroute。
evt = new PopStateEvent("popstate", { state }); } catch (err) { // IE 11 compatibility https://github.com/single-spa/single-spa/issues/299 // https://docs.microsoft.com/en-us/openspecs/ie_standards/ms-html5e/bd560f47-b349-4d2c-baa8-f1560fb489dd evt = document.createEvent("PopStateEvent"); evt.initPopStateEvent("popstate", false, false, state); } evt.singleSpa = true; evt.singleSpaTrigger = originalMethodName; return evt; }
以上就是對single-spa的路由解析。
single-spa和qiankun的關系
看了上面的分析,不知道讀者有沒有一種感覺,就是在single-spa中。通過reroute和路由控制不斷地在調度子應用,加載子應用的代碼,切話子應用,改變子應用的app.status。所以single-spa解決了一個子應用之間的調度問題。
但是single-spa有一個開放的地方是需要用戶自己去實現的,這個開放的地方就是registerApplication函數的加載函數,就是下面截圖這個地方。
這個地方的作用經過上面的分析,就是告訴single-spa,你需要如何去加載子應用的代碼,同時讓主應用獲取到子應用的生命周期函數。
這個地方是開放的,但是這個地方的書寫難度是比較高的,你需要找到一個合適的方案去解決上面這個問題。
那么qiankun的出現就是提供了一種解決方案幫住我們完成上述部分的代碼,下面是qiankun源碼的調用方式:
registerApplication({ name, app: async () => { loader(true); await frameworkStartedDefer.promise; const { mount, ...otherMicroAppConfigs } = ( await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles) )(); return { mount: [async () => loader(true), ...toArray(mount), async () => loader(false)], ...otherMicroAppConfigs, }; }, activeWhen: activeRule, customProps: props, });
如果有了解qiankun源碼的讀者可能知道,qiankun的編寫是基於single-spa和import-html-entry兩個庫。
single-spa幫住qiankun如何調度子應用,import-html-entry提供了一種window.fetch方案去加載子應用的代碼。
所以說qiankun這個微前端框架,就是幫助我們更加高效地使用single-spa。直接幫住用戶解決了一個子應用代碼加載的問題。
不需要用戶過多的思考,用就完事了。
就像vue.js和vue-cli一樣。vue-cli幫你把vue.js包裝好了,直接讓你舒服地用。
以上就是作者對於single-spa的簡單理解,如果還是不明白的話,只能怪我閱讀代碼水平和文字表達水平有限,sorry。