single-spa的简单使用、原理、实现

在前端微服务化的领域内,有许多优秀的解决方案,比如qiankun、wujie、micro-app、single-spa、iframe等,这些方案都有各自的优缺点, 本文并不会对这些方案进行比较,而是单纯的介绍single-spa的原理以及实现。

之前在两家公司分别用过iframe和qiankun的处理方案,iframe原理还是比较简单的,在这里就不再赘述了,本来打算研究下qiankun的源码,但是发现qiankun其实依赖一个重要的第三方库, 就是我们刚刚提到的single-spa,所以就先研究下single-spa的源码吧,qiankun的源码后面有机会再更新。

本文分为三大块:

  1. single-spa的简单使用
  2. 剖析single-spa的源码
  3. 手动实现一个简单的single-spa

大家根据自己的兴趣,跳到指定章节,demo地址也放在github上了,传送门

一、single-spa的使用

要想了解一个库的原理,首先要会使用这个库,我们先看下single-spa的使用:

准备子应用

这个子应用是一个vue项目,我们也可以通过vue-cli来创建一个项目,然后安装single-spa-vue,然后我们就需要做些简单的改造了

main.js改造如下:做的事情比较简单,就是导出生命周期函数,添加el属性,这个属性是挂载到父应用中的id所指向的标签中

js 复制代码
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import singleSpa from 'single-spa-vue';

Vue.config.productionTip = false

const appOptions = {
	el: '#microApp', // 挂载到父应用中的id为microApp的标签中
	router,
	render: h => h(App)
}
const vueLifeCycle = singleSpa({
	Vue,
	appOptions
})

// 如果是父应用引用子应用,就会有这个属性
if(window.singleSpaNavigate){
	__webpack_public_path__ = 'http://localhost:4001/'
}
// 如果不是父应用引用
if(!window.singleSpaNavigate){
	delete appOptions.el;
	new Vue(appOptions).$mount('#app');
}

// 子应用的生命周期,我们可以在不同的阶段做一些事情

export function bootstrap (props) {
	console.log('app1 bootstrap')
	return vueLifeCycle.bootstrap(() => {})
}

export function mount (props) {
	console.log('app1 mount')
	return vueLifeCycle.mount(() => {})
}

export function unmount (props) {
	console.log('app1 unmount')
	return vueLifeCycle.unmount(() => {})
}

然后我们区分下项目独立启动,以及通过父应用引用的方式启动,这里我们创建了两个env文件,分别是.env和.env.micro, 这是我们子项目独立启动的时候,使用env 这里的环境变量,通过父应用启动时,使用env.micro 文件的环境变量,VUE_APP_BASE_URL表示子应用的路由前缀

dotenv 复制代码
//.env
NODE_ENV=development
VUE_APP_BASE_URL=/
VUE_APP_PORT=4001
dotenv 复制代码
//.env.micro
NODE_ENV=development
VUE_APP_BASE_URL=/child-vue1
VUE_APP_PORT=4001

同时我们需要修改package.json文件,来注入环境变量

json 复制代码
{
  "scripts": {
    "serve": "vue-cli-service serve",
    "serve:micro": "vue-cli-service serve --mode micro"
  }
}

这样在路由中,我们就可以获取到VUE_APP_BASE_URLrouter.js改造如下:

js 复制代码
const router = new VueRouter({
	mode: 'history',
	base: process.env.VUE_APP_BASE_URL,
	routes
})

最后就是vue.config.js的改动:

js 复制代码
module.exports = {
	lintOnSave: false,
	publicPath: '//localhost:4001',
    transpileDependencies: true,
	configureWebpack: {
		output: {
			library: 'singleChildVue1',// 导出的子应用名
			libraryTarget: 'umd' // 导出的子应用格式
		},
	},
	devServer: {
		port: 4001
	}
}

子应用的改造基本完成了,其实就是改造了下路由,然后导出了生命周期函数,最后是配置了下webpack的导出格式,这些动作都是为了让子应用能够被父应用引用,使用同样的 操作流程我们继续创建一个子应用。

准备基座

主应用是一个vue项目,通过vue-cli来创建一个项目,然后安装single-spa,具体的改造如下 main.js改造如下:

js 复制代码
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import { registerApplication, start } from 'single-spa';
// import { registerApplication, start } from "../../zzc-single-spa";

Vue.config.productionTip = false

async function loadScript(url){
	return new Promise((resolve, reject) => {
		let script = document.createElement('script');
		script.src = url;
		script.onload = resolve;
		script.onerror = reject;
		document.head.appendChild(script);
	})
}

function loadApp (url, globalVar) {
	return async function () {
		await loadScript(url + '/js/chunk-vendors.js'); // 子应用的公共依赖
		await loadScript(url + '/js/app.js'); // 子应用的入口文件
		await loadScript(url + '/js/about.js'); // 我们的子应用有两个页面,home和about,about会单独打包为一个js文件
		return window[globalVar];
	}
}


const apps = [
	{
		name: 'singleChildVue1',
		app: loadApp('http://localhost:4001', 'singleChildVue1'),
		activeWhen: location => location.pathname.startsWith('/child-vue1'),
		customProps: {
			data: {
				msg: 'hello single-spa1'
			}
		}
	},
	{
		name: 'singleChildVue2',
		app: loadApp('http://localhost:4002', 'singleChildVue2'),
		activeWhen: location => location.pathname.startsWith('/child-vue2'),
		customProps: {
			data: {
				msg: 'hello single-spa2'
			}
		}
	}
]
for (let i = 0; i < apps.length; i++) {
	registerApplication(apps[i])
}

new Vue({
	router,
	mounted() {
		start()
	},
	render: h => h(App)
}).$mount('#root')

app.vue改造如下:

vue 复制代码
<template>
  <div id="root">
    <div id="nav">
      <router-link to="/child-vue1">app1</router-link> |
      <router-link to="/child-vue2">app2</router-link>
    </div>
    <!-- 子应用容器 -->
    <div id = "microApp">
      <router-view/>
    </div>

  </div>
</template>

分别启动主应用和子应用后,我们打开localhost:8080,就可以看到我们的主应用了,然后点击app1app2就可以看到我们的子应用了,那么到目前为止,微前端的single-spa已经被我们拿捏了一点点,我们继续窥探下single-spa的源码,看看它是怎么做到的。

二、single-spa源码解读

源码的解读思路以single-spa导出的两个函数registerApplicationstart为入口逐层分析的,我们先看看registerApplication函数

registerApplication

registerApplication这个函数的作用是注册子应用,

js 复制代码
export function registerApplication(
	appNameOrConfig,
	appOrLoadApp,
	activeWhen,
	customProps
) {
	// 格式化用户传递的参数,最终都是转换成一个config对象
	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数组中
	apps.push(
		// 给每个应用添加一些默认的属性
		assign(
			{
				loadErrorTime: null,
				status: NOT_LOADED, // 应用的状态, 所有应用的状态都是从NOT_LOADED开始
				parcels: {},
				devtools: {
					overlays: {
						options: {},
						selectors: [],
					},
				},
			},
			registration
		)
	);

	// 判断浏览器环境
	if (isInBrowser) {
		// 判断是否需要加载jquery
		// ensureJQuerySupport(); 非重点,忽略分析
		
		reroute();
	}
}

registerApplication做了三件重要的事情:

  1. 格式化用户传递的参数,最终都是转换成一个标准的config对象,类似的处理方式在很多库中都有,比如axiosvue-router,vue等等
  2. 给每个应用都添加一些默认的属性,比如status等等,并添加到apps数组中存储起来
  3. 判断浏览器环境,如果是浏览器环境,然后调用reroute函数

sanitizeArguments准入函数

sanitizeArguments函数的作用是格式化用户传递的参数,最终都是转换成一个标准的config对象,这个函数的源码如下:

js 复制代码
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;
  }

	// 如果传入的loadApp不是一个函数,则返回一个函数,该函数返回一个Promise对象,该Promise对象的值为loadApp
  registration.loadApp = sanitizeLoadApp(registration.loadApp);
	// 如果没有传入customProps,则返回一个空对象
  registration.customProps = sanitizeCustomProps(registration.customProps);
	// 如果传入的activeWhen不是一个函数,则返回一个函数,该函数返回一个布尔值,该布尔值为activeWhen(location)的值
  registration.activeWhen = sanitizeActiveWhen(registration.activeWhen);

  return registration;
}

sanitizeArguments都是处理一些参数的格式化,比如loadAppcustomPropsactiveWhen等等,最终实现我们的子应用的配置对象的准入,这个对象的结构大致如下:

vue 复制代码
{
    name: 'singleChildVue1',
    loadApp: loadApp('http://localhost:4001', 'singleChildVue1'),
    activeWhen: location => location.pathname.startsWith('/child-vue1'),
    customProps: {
        data: {
            msg: 'hello single-spa1'
        }
    }
}

给每个应用都添加默认的属性

registerApplication中,我们给每个应用添加了一些默认属性,这里我们只关注status属性,这个属性是用来标识应用的状态的,默认值是NOT_LOADED,表示应用是未加载状态,这个常量的定义如下:

js 复制代码
export const NOT_LOADED = "NOT_LOADED"; //初始状态 微应用的资源未加载
export const LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE"; // 资源加载中
export const NOT_BOOTSTRAPPED = "NOT_BOOTSTRAPPED"; // 微应用未启动
export const BOOTSTRAPPING = "BOOTSTRAPPING"; // 微应用启动中
export const NOT_MOUNTED = "NOT_MOUNTED"; // 微应用未挂载
export const MOUNTING = "MOUNTING"; // 微应用挂载中
export const MOUNTED = "MOUNTED"; // 微应用已挂载
export const UPDATING = "UPDATING"; // 微应用更新中
export const UNMOUNTING = "UNMOUNTING"; // 微应用卸载中
export const UNLOADING = "UNLOADING"; // 微应用卸载资源中
export const LOAD_ERROR = "LOAD_ERROR"; // 微应用加载出错
export const SKIP_BECAUSE_BROKEN = "SKIP_BECAUSE_BROKEN"; // 微应用因为某些原因被跳过

抛开异常状态,我们的子应用状态有10个之多,可以分为四类:加载、启动、挂载、卸载,这四类状态的变化如下图所示:

reroute函数

reroutesingle-spa 中的核心方法。它的作用是根据当前的路由状态来确定哪些应用需要被加载、启动、挂载、卸载。reroute 函数的源码如下:

js 复制代码
export function reroute(pendingPromises = [], eventArguments) {
// 其他代码

	const {
		appsToUnload,
		appsToUnmount,
		appsToLoad,
		appsToMount,
	} = getAppChanges();
	let appsThatChanged,
		navigationIsCanceled = false,
		oldUrl = currentUrl,
		newUrl = (currentUrl = window.location.href);

	// 是否已经执行start方法
	if (isStarted()) {
      // appsThatChanged 是状态发生变化的 app
		appsThatChanged = appsToUnload.concat(
			appsToLoad,
			appsToUnmount,
			appsToMount
		);
		// 变动的app执行performAppChanges方法
		return performAppChanges();
	} else {
		appsThatChanged = appsToLoad;
		return loadApps();
	}
}
getAppChanges

getAppChanges函数的作用是根据当前的url找出状态需要改变的应用,这个函数的源码如下:

js 复制代码
export function getAppChanges() {
	// 需要被移除的应用
	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.forEach((app) => {
		// 判断当前应用是否处于被激活状态
		const appShouldBeActive =
			app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);

		switch (app.status) {
			case LOAD_ERROR:
				if (appShouldBeActive && currentTime - app.loadErrorTime >= 200) {
					appsToLoad.push(app);
				}
				break;
			case NOT_LOADED:
			case LOADING_SOURCE_CODE:
				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 };
}

应用分为四类:

  • appsToLoad:需要被加载的应用,NOT_LOADED和LOADING_SOURCE_CODE状态下的应用,且当前应用应当被激活,会被push到该数组中
  • appsToMount:需要被挂载的应用,NOT_MOUNTED和NOT_BOOTSTRAPPED状态下的应用,且当前应用应当被激活,会被push到该数组中
  • appsToUnmount:需要被卸载的应用,MOUNTED状态下的应用,且当前应用未被激活,会被push到该数组中
  • appsToUnload:需要被移除的应用,NOT_MOUNTED和NOT_BOOTSTRAPPED状态下的应用,如果应用未被激活,且应用应当被卸载,会被push到该数组中

应用是否被激活,是通过shouldBeActive函数来判断的,这个函数的源码如下:

js 复制代码
// 当前应用是否是被激活状态
export function shouldBeActive(app) {
	try {
		return app.activeWhen(window.location);
	} catch (err) {
		handleAppError(err, app, SKIP_BECAUSE_BROKEN);
		return false;
	}
}

源码比较简单,就不过多解析了

loadApps

loadApps函数的作用是加载应用,当我们注册函数的时候,isStart()false,当我们在main.js中调用start后,isStart()true,所以当我们打开localhost:8080时,调用 注册应用函数时,会进入到loadApps这个函数的逻辑,源码如下:

js 复制代码
  function loadApps() {
    return Promise.resolve().then(() => {
      const loadPromises = appsToLoad.map(toLoadPromise);

      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;
          })
      );
    });
  }

其实,当前场景下,应为我们访问的是根路径,所以appsToLoad未空数组,函数内部的逻辑,我们也不用关系,但是有个情况例外,当我们访问的是localhost:8080/child-vue1时,appsToLoad就不是空数组了,而是我们的子应用child-vue1,这个时候,我们就需要关心loadApps函数的逻辑了,以及toLoadPromise, 这里我们不过多赘述,稍后在讲performAppChanges函数的时候,会详细讲解toLoadPromise函数的逻辑

那么到目前为止,我们的注册函数registerApplication的分析就告一段落了

start

接下来我们继续分析start函数,start函数的源码如下:

js 复制代码
let started = false;

export function start(opts) {
	started = true;
	if (opts && opts.urlRerouteOnly) {
		setUrlRerouteOnly(opts.urlRerouteOnly);
	}
	if (isInBrowser) {
		reroute();
	}
}
export function isStarted() {
  return started;
}

start函数的作用是启动single-spa,当我们调用start函数时,会将started设置为true,并且会调用reroute函数,reroute函数的作用是根据当前的url, 来判断是否需要改变应用的状态,reroute的源码我们上面已经已经摘出来了,这里我们重点看下其中的以个判断逻辑如下:

js 复制代码
// 是否已经执行start方法
	if (isStarted()) {
      // appsThatChanged 是状态发生变化的 app
		appsThatChanged = appsToUnload.concat(
			appsToLoad,
			appsToUnmount,
			appsToMount
		);
		// 变动的app执行performAppChanges方法
		return performAppChanges();
	} else {
		appsThatChanged = appsToLoad;
		return loadApps();
	}

reroute函数中我们会判断isStarted()是否为true,如果为true,则会执行performAppChanges函数,如果为false,则会执行loadApps函数,因为started已经修改为true,所以这里会进入performAppChanges的逻辑,performAppChanges函数的源码:

js 复制代码
  function performAppChanges() {
	return Promise.resolve().then(() => {
	//...触发了一些事件

      // 将要被移除的应用
		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);

		// 触发了一些事件

		/* We load and bootstrap apps while other apps are unmounting, but we
         * wait to mount the app until all apps are finishing unmounting
         */
        // 在其他应用被卸载的时,加载和启动应用,但是会等到所有的应用被卸载后,再去挂载应用
		const loadThenMountPromises = appsToLoad.map((app) => {
			return toLoadPromise(app).then((app) =>
				tryToBootstrapAndMount(app, unmountAllPromise)
			);
		});

		/* These are the apps that are already bootstrapped and just need
         * to be mounted. They each wait for all unmounting apps to finish up
         * before they mount.
         */
      // 有些应用已经被启动,只需要被挂载,他们会等待所有的应用被移除后,再去挂载
		const mountPromises = appsToMount
			.filter((appToMount) => appsToLoad.indexOf(appToMount) < 0)
			.map((appToMount) => {
				return tryToBootstrapAndMount(appToMount, unmountAllPromise);
			});
		return unmountAllPromise
			.catch((err) => {
				callAllEventListeners();
				throw err;
			})
			.then(() => {
				// 触发了一些事件

				return Promise.all(loadThenMountPromises.concat(mountPromises))
					.catch((err) => {
						pendingPromises.forEach((promise) => promise.reject(err));
						throw err;
					})
					.then(finishUpAndReturn);
			});
	});
}

performAppChanges的职责很明确,五个字概括就是:先卸货后装货

那么我们先看下卸货的过程

卸货

卸货我们看两个函数就可以,toUnmountPromisetoUnloadPromise,源码如下:

js 复制代码
export function toUnmountPromise(appOrParcel, hardFail) {
	return Promise.resolve().then(() => {
		if (appOrParcel.status !== MOUNTED) {
			return appOrParcel;
		}
		// 修改状态,表示正在卸载
		appOrParcel.status = UNMOUNTING;

		// 不重要 忽略分析
		const unmountChildrenParcels = Object.keys(
			appOrParcel.parcels
		).map((parcelId) => appOrParcel.parcels[parcelId].unmountThisParcel());

		let parcelError;

		return Promise.all(unmountChildrenParcels)
			.then(unmountAppOrParcel, (parcelError) => {
				// There is a parcel unmount error 
              // 报错时的钩子函数
			})

		function unmountAppOrParcel() {
			// We always try to unmount the appOrParcel, even if the children parcels failed to unmount.
			return reasonableTime(appOrParcel, "unmount")
				.then(() => {
					if (!parcelError) {
						// 修改状态,表示已经卸载
						appOrParcel.status = NOT_MOUNTED;
					}
				})
		}
	});
}

toUnmountPromise函数的作用是将应用从MOUNTED状态变为UNMOUNTING状态,然后调用应用的unmount钩子函数,最后将应用的状态修改为NOT_MOUNTED

我们再看下toUnloadPromise的源码,如下:

js 复制代码
export function toUnloadPromise(app) {
	return Promise.resolve().then(() => {
		const unloadInfo = appsToUnload[toName(app)];

		// 一些判断,忽略分析

        // 不重要 忽略分析
		const unloadPromise =
			app.status === LOAD_ERROR
				? Promise.resolve()
				: reasonableTime(app, "unload");

		// 修改状态,表示正在移除
		app.status = UNLOADING;

		return unloadPromise
			.then(() => {
				// 完成移除函数
				finishUnloadingApp(app, unloadInfo);
				return app;
			})
			.catch((err) => {
				errorUnloadingApp(app, unloadInfo, err);
				return app;
			});
	});
}
function finishUnloadingApp(app, unloadInfo) {
	delete appsToUnload[toName(app)];

	// Unloaded apps don't have lifecycles
	delete app.bootstrap;
	delete app.mount;
	delete app.unmount;
	delete app.unload;

	app.status = NOT_LOADED;

	/* resolve the promise of whoever called unloadApplication.
     * This should be done after all other cleanup/bookkeeping
     */
	unloadInfo.resolve();
}

该函数的作用是将应用从NOT_MOUNTED状态变为NOT_LOADED状态,我们回过头在看下卸货的流程,如下:

MOUNTED -> UNMOUNTING -> NOT_MOUNTED -> UNLOADING -> NOT_LOADED

装货

我们再回顾下装货的核心代码:

js 复制代码
const loadThenMountPromises = appsToLoad.map((app) => {
			return toLoadPromise(app).then((app) =>
				tryToBootstrapAndMount(app, unmountAllPromise)
			);
		});

这里我们重点看下toLoadPromisetryToBootstrapAndMount函数,源码如下:

js 复制代码
export function toLoadPromise(app) {
	return Promise.resolve().then(() => {
	// 一些判断。。。

		app.status = LOADING_SOURCE_CODE;

		let appOpts, isUserErr;

		return (app.loadPromise = Promise.resolve()
			.then(() => {
				const loadPromise = app.loadApp(getProps(app));
              
				// 一些判断。。。
				
				return loadPromise.then((val) => {
					app.loadErrorTime = null;

					appOpts = val;

					let validationErrMessage, validationErrCode;

					// 很多判断。。。

					// 设置app状态为未初始化,表示加载完成
					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);

					delete app.loadPromise;

					return app;
				});
			})
			.catch((err) => {
				// 加载出错,修改状态
				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;
			}));
	});
}

toLoadPromise函数的作用是将应用从NOT_LOADED状态变为LOADING_SOURCE_CODE状态,然后加载应用的资源,最后将应用的状态修改为NOT_BOOTSTRAPPED,并且将应用的生命周期函数挂载到应用上。 总结下就是:

NOT_LOADED -> LOADING_SOURCE_CODE -> NOT_BOOTSTRAPPED

那么如何加载应用资源的呢,我们是通过app.loadApp函数加载应用资源的,这个函数我们不陌生,就是我们在注册应用时候配置的loadApp函数,代码如下:

js 复制代码
{
	//...
	app: loadApp('http://localhost:4002', 'singleChildVue2')
	//...
}

当我们加载应用资源后,子应用会暴露一个全局的变量,就是我们在vue.config.js中配置的library,这里我们配置的是singleChildVue2,所以子应用会暴露一个全局变量singleChildVue2, 这样的话我们可以在基座中拿到这个变量,那么拿到变量上的钩子函数也是自然而然了。

我们再看下tryToBootstrapAndMount函数的源码,如下:

js 复制代码
/**
 * Let's imagine that some kind of delay occurred during application loading.
 * The user without waiting for the application to load switched to another route,
 * this means that we shouldn't bootstrap and mount that application, thus we check
 * twice if that application should be active before bootstrapping and mounting.
 * https://github.com/single-spa/single-spa/issues/524
 */
// 翻译上面的注释:假设在加载应用程序期间发生了某种延迟。用户在等待应用程序加载的情况下切换到另一个路由, 这意味着我们不应该引导和挂载该应用程序, 因此我们在引导和挂载之前检查该应用程序是否应处于活动状态。
// 尝试启动和挂载应用
function tryToBootstrapAndMount(app, unmountAllPromise) {
	if (shouldBeActive(app)) {
		return toBootstrapPromise(app).then((app) =>
			unmountAllPromise.then(() =>
				shouldBeActive(app) ? toMountPromise(app) : app
			)
		);
	} else {
		return unmountAllPromise.then(() => app);
	}
}

tryToBootstrapAndMount函数的作用是将应用从NOT_BOOTSTRAPPED状态变为MOUNTED状态,两次判断的作用是为了确保应用加载的准确性,上面的代码中给出了官方的解释,在这里我们也不用做多用的分析,我们只需要知道判断两次的作用就可以了。 重点还是放在toBootstrapPromisetoMountPromise函数上,源码如下:

js 复制代码
export function toBootstrapPromise(appOrParcel, hardFail) {
	return Promise.resolve().then(() => {
	// 一些判断。。。

		appOrParcel.status = BOOTSTRAPPING;

		// 一些判断。。。

		return reasonableTime(appOrParcel, "bootstrap")
			.then(successfulBootstrap)
			.catch((err) => {
				// 错误处理
			});
	});

	function successfulBootstrap() {
		appOrParcel.status = NOT_MOUNTED;
		return appOrParcel;
	}
}

逻辑很清晰,就是将应用从NOT_BOOTSTRAPPED状态变为BOOTSTRAPPING状态,然后执行应用的bootstrap生命周期函数,最后将应用的状态修改为NOT_MOUNTED

继续看下toMountPromise函数的源码,如下:

js 复制代码
export function toMountPromise(appOrParcel, hardFail) {
	return Promise.resolve().then(() => {
		// 一些判断。。。
		return reasonableTime(appOrParcel, "mount")
			.then(() => {
				appOrParcel.status = MOUNTED;

				// 一些判断。。。
              
				return appOrParcel;
			})
			.catch((err) => {
              // 错误处理
			});
	});
}

toMountPromise函数的作用是将应用从NOT_MOUNTED状态变为MOUNTED状态,然后执行应用的mount生命周期函数,最后将应用的状态修改为MOUNTED。 这个mount的生命周期函数就是我们在子应用中定义的mount函数,它的作用就是将子应用挂载到基座中,我们放到最后再去看下源码,这里只要知道它的作用就可以了。

我们回过头来看下tryToBootstrapAndMount的应用状态流转:

NOT_BOOTSTRAPPED -> BOOTSTRAPPING -> NOT_MOUNTED -> MOUNTING -> MOUNTED

那么我们整个装货的过程就是:

NOT_LOADED -> LOADING_SOURCE_CODE -> NOT_BOOTSTRAPPED -> BOOTSTRAPPING -> NOT_MOUNTED -> MOUNTING -> MOUNTED

至此,装货卸货我们也分析完了,我们把时间线往回拨一拨,我们一开始访问的是http://localhost:4000,这个时候基座会加载子应用的资源,那么当我们路由发生变更的时候,single-spa是如何做到加载子应用的呢? 其实,在我们引入single-spa的时候,会自执行下面一段代码:

js 复制代码
function urlReroute() {
	reroute([], arguments);
}
if (isInBrowser) {
	// We will trigger an app change for any routing events.
	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) {
		// 一些判断。。。
		return originalAddEventListener.apply(this, arguments);
	};

	window.removeEventListener = function (eventName, listenerFn) {
		// 一些判断。。。
		return originalRemoveEventListener.apply(this, arguments);
	};

	window.history.pushState = patchedUpdateState(
		window.history.pushState,
		"pushState"
	);
	window.history.replaceState = patchedUpdateState(
		window.history.replaceState,
		"replaceState"
	);
	function patchedUpdateState(updateState, methodName) {
		return function () {
			const urlBefore = window.location.href;
			const result = updateState.apply(this, arguments);
			const urlAfter = window.location.href;

			if (!urlRerouteOnly || urlBefore !== urlAfter) {
				if (isStarted()) {
					
					window.dispatchEvent(
						createPopStateEvent(window.history.state, methodName)
					);
				} else {
					
					reroute([]);
				}
			}

			return result;
		};
	}
	
}

这段代码的作用就是监听路由的变化,当路由发生变化的时候,会执行reroute函数,那么执行reroute函数又回到了我们之前讲的逻辑,我们就不再赘述了。

最后我们在补充一下之前讲装货的时候,我们说到mount在应用的挂载过程中很重要,我们看下mount函数的源码,这个mount最终会调用single-spa-vue中的mount函数:

js 复制代码
function mount(opts, mountedInstances, props) {
  const instance = {};
  return Promise.resolve().then(() => {
    return resolveAppOptions(opts, props).then((appOptions) => {
      if (props.domElement && !appOptions.el) {
        appOptions.el = props.domElement;
      }

      let domEl;
      if (appOptions.el) {
        if (typeof appOptions.el === "string") {
          domEl = document.querySelector(appOptions.el);
        } else {
					// 暂时不走这里
        }
      } else {
				// 暂时不走这里
      }

      if (!opts.replaceMode) {
        appOptions.el = appOptions.el + " .single-spa-container";

        // single-spa-vue@>=2 always REPLACES the `el` instead of appending to it.
        // We want domEl to stick around and not be replaced. So we tell Vue to mount
        // into a container div inside of the main domEl
        if (!domEl.querySelector(".single-spa-container")) {
          const singleSpaContainer = document.createElement("div");
          singleSpaContainer.className = "single-spa-container";
          domEl.appendChild(singleSpaContainer);
        }
      }

      instance.domEl = domEl;

      if (!appOptions.render && !appOptions.template && opts.rootComponent) {
        appOptions.render = (h) => h(opts.rootComponent);
      }

      if (!appOptions.data) {
        appOptions.data = {};
      }

      const originData = appOptions.data;
      appOptions.data = function () {
        const data =
          typeof originData === "function"
            ? originData.call(this, this)
            : originData;
        return { ...data, ...props };
      };

      if (opts.createApp) {
        instance.vueInstance = opts.createApp(appOptions);
        if (opts.handleInstance) {
          return Promise.resolve(
            opts.handleInstance(instance.vueInstance, props)
          ).then(function () {
            instance.root = instance.vueInstance.mount(appOptions.el);
            mountedInstances[props.name] = instance;

            return instance.vueInstance;
          });
        } else {
          instance.root = instance.vueInstance.mount(appOptions.el);
        }
      } else {
				// 执行new Vue()挂载应用
        instance.vueInstance = new opts.Vue(appOptions);
        if (instance.vueInstance.bind) {
          instance.vueInstance = instance.vueInstance.bind(
            instance.vueInstance
          );
        }
        if (opts.handleInstance) {
          return Promise.resolve(
            opts.handleInstance(instance.vueInstance, props)
          ).then(function () {
            mountedInstances[props.name] = instance;
            return instance.vueInstance;
          });
        }
      }

      mountedInstances[props.name] = instance;

      return instance.vueInstance;
    });
  });
}

上面的代码是single-spa-vue中的mount函数,我们看到mount函数中会执行new Vue(appOptions),并且我们的appOptionsel#microApp .single-spa-container",看到这里大家明白了吧

总结

上面用了很大的篇幅一步步剖析single-spa的源码,把整个流程串了一遍,说的通俗点就是:监听路由+卸货+装货 ,但其实里面还有很多细节的处理值得我们深入学习。下面我把自己手写的一个简单的single-spa的demo放在这里,大家可以看看,里面有很多注释,希望对大家有所帮助。

三、简易版single-spa

眼过千遍不如手过一遍,下面我们手撸一个single-spa

js 复制代码
// stasus.js
export const NOT_LOADED = "NOT_LOADED";
export const LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE";
export const NOT_BOOTSTRAPPED = "NOT_BOOTSTRAPPED";
export const BOOTSTRAPPING = "BOOTSTRAPPING";
export const NOT_MOUNTED = "NOT_MOUNTED";
export const MOUNTING = "MOUNTING";
export const MOUNTED = "MOUNTED";
export const UPDATING = "UPDATING";
export const UNMOUNTING = "UNMOUNTING";
export const UNLOADING = "UNLOADING";
export const LOAD_ERROR = "LOAD_ERROR";
export const SKIP_BECAUSE_BROKEN = "SKIP_BECAUSE_BROKEN";
js 复制代码
// index.js
import {
	NOT_LOADED,
	NOT_BOOTSTRAPPED,
	NOT_MOUNTED,
	MOUNTED,
	LOADING_SOURCE_CODE,
	BOOTSTRAPPING,
	UNMOUNTING
} from './status.js'

const apps = []
export function registerApplication (appConfig) {
	apps.push(Object.assign({}, appConfig, {
		status: NOT_LOADED,
	}))
	reroute();
}

function reroute () {
	// 获取需要被加载的app
	const {
		appsToLoad,
		appsToMount,
		appsToUnmount,
		appsToUnload
	} = getAppChanges()
	if (isStarted()) {
		return performAppChanges()
	} else {
		return loadApps()
	}

	function loadApps () {
		appsToLoad.map(toLoad)
	}
	function performAppChanges () {
		console.log(appsToLoad, appsToMount, appsToUnmount, appsToUnload, '这是app的变化')
		const unMountPromsies = appsToUnmount.map(toUnmount)
		const unloadMountPromsies = Promise.all(unMountPromsies).then(apps => {
			return apps.map(toUnload)
		})
		const unloadPromises = appsToUnload.map(toUnload)
		const allUnloadPromises = unloadPromises.concat(unloadMountPromsies)
		Promise.all(allUnloadPromises).then(() => {
			appsToLoad.map(app => {
				return toLoad(app).then((app) => {
					tryToBoostrapAndMount(app)
				})
			})
			appsToMount.map(tryToBoostrapAndMount)
		})
	}
}
// 初始化 + 挂载app
async function tryToBoostrapAndMount (app) {
	if (app.status !== NOT_BOOTSTRAPPED) return app
	if (shouldBeActive(app)) {
		app.status = BOOTSTRAPPING
		await app.bootstrap(app.customProps)
		apps.status = NOT_MOUNTED
		if (shouldBeActive(app)) {
			await app.mount(app.customProps)
			app.status = MOUNTED
		}
	}
}
// 卸载app
async function toUnmount (app) {
	if (app.status !== MOUNTED) return app
	// 正在卸载
	app.status = UNMOUNTING
	await app.unmount(app.customProps)
	// 卸载完成
	app.status = NOT_MOUNTED
	return app
}
async function toUnload (app) {
	if (app.status !== NOT_MOUNTED) return app
	app.status = NOT_LOADED
	if (app.unload) {
		await app.unload(app.customProps)
	}
	return app
}
// 加载app
async function toLoad (app) {
	if (app.status !== NOT_LOADED) return
	app.status = LOADING_SOURCE_CODE
	const {bootstrap, mount, unmount, unload} = await app.app(app.customProps)
	console.log(childVue1, '这是load之后')
	app.status = NOT_BOOTSTRAPPED
	app.bootstrap = bootstrap
	app.mount = mount
	app.unmount = unmount
	app.unload = unload
	reroute()
	return app
}
function getAppChanges () {
	const appsToLoad = []
	const appsToMount = []
	const appsToUnmount = []
	const appsToUnload = []
	apps.forEach(app => {
		const appShouldBeActive = shouldBeActive(app)
		switch (app.status) {
			case NOT_LOADED:
				if (appShouldBeActive) {
					appsToLoad.push(app)
				}
				break
			case NOT_BOOTSTRAPPED:
			case NOT_MOUNTED:
					if (!appShouldBeActive) {
						appsToUnload.push(app)
					} else {
						appsToMount.push(app)
					}
				break
			case MOUNTED:
				if (!appShouldBeActive) {
					appsToUnmount.push(app)
				}
				break
		}
	})

	return {
		appsToLoad,
		appsToMount,
		appsToUnmount,
		appsToUnload
	}
}
let started = false
export function start () {
	started = true
	reroute()
}
function isStarted () {
	return started
}
// 应用是否需要激活
function shouldBeActive (app) {
	return app.activeWhen(window.location)
}

window.addEventListener('hashchange', reroute)
window.history.pushState = patchedUpdateState(window.history.pushState)
window.history.replaceState = patchedUpdateState(window.history.replaceState)
function patchedUpdateState (updateState) {
	return function (...args) {
		console.log('statechange')
		const urlBefore = window.location.href
		const result = Reflect.apply(updateState, this, args)
		const urlAfter = window.location.href
		if (urlBefore !== urlAfter) {
			// 重新加载应用
			reroute()
		}
		return result
	}
}

啰啰嗦嗦说了这么多,希望对大家有所帮助,如果有不对的地方,欢迎指正,谢谢!

相关推荐
水w2 小时前
VuePress v2 快速搭建属于自己的个人博客网站
开发语言·前端·vue·vuepress
不爱说话郭德纲2 小时前
理解 Object.create 并正确使用 Object.create
前端·javascript·vue.js·es6·html5
羊小猪~~3 小时前
前端入门一之ES6--递归、浅拷贝与深拷贝、正则表达式、es6、解构赋值、箭头函数、剩余参数、String、Set
开发语言·前端·javascript·css·正则表达式·html·es6
花弄影15213 小时前
vue之axios根据某个接口创建实例,并设置headers和超时时间,捕捉异常
前端·javascript·vue.js
cooldream20094 小时前
使用 Vue 和 Create-Vue 构建工程化前端项目
前端·javascript·vue.js
明里灰4 小时前
从浏览器地址栏输入url到显示页面的步骤
前端·浏览器
软件小伟5 小时前
Vite是什么?Vite如何使用?相比于Vue CLI的区别是什么?(一篇文章帮你搞定!)
前端·vue.js·ecmascript·vite·vue vli
雪碧聊技术5 小时前
03-axios常用的请求方法、axios错误处理
前端·javascript·ajax·axios请求方法·restful风格·axios错误处理·axios请求配置
雾恋6 小时前
不要焦虑,在低迷的环境充实自己;在复苏的环境才能把握住机遇
前端·javascript
花花鱼7 小时前
vscode vite+vue3项目启动调试
前端·javascript·vue.js