前言
看这篇文章之前先要了解微前端概念,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。