在前端微服务化的领域内,有许多优秀的解决方案,比如qiankun、wujie、micro-app、single-spa、iframe等,这些方案都有各自的优缺点, 本文并不会对这些方案进行比较,而是单纯的介绍single-spa的原理以及实现。
之前在两家公司分别用过iframe和qiankun的处理方案,iframe原理还是比较简单的,在这里就不再赘述了,本来打算研究下qiankun的源码,但是发现qiankun其实依赖一个重要的第三方库, 就是我们刚刚提到的single-spa,所以就先研究下single-spa的源码吧,qiankun的源码后面有机会再更新。
本文分为三大块:
single-spa
的简单使用- 剖析
single-spa
的源码 - 手动实现一个简单的
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_URL
,router.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
,就可以看到我们的主应用了,然后点击app1
和app2
就可以看到我们的子应用了,那么到目前为止,微前端的single-spa已经被我们拿捏了一点点,我们继续窥探下single-spa的源码,看看它是怎么做到的。
二、single-spa源码解读
源码的解读思路以single-spa
导出的两个函数registerApplication
和start
为入口逐层分析的,我们先看看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
做了三件重要的事情:
- 格式化用户传递的参数,最终都是转换成一个标准的config对象,类似的处理方式在很多库中都有,比如
axios
,vue-router
,vue
等等 - 给每个应用都添加一些默认的属性,比如
status
等等,并添加到apps数组中存储起来 - 判断浏览器环境,如果是浏览器环境,然后调用
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
都是处理一些参数的格式化,比如loadApp
,customProps
,activeWhen
等等,最终实现我们的子应用的配置对象的准入,这个对象的结构大致如下:
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函数
reroute
是 single-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
的职责很明确,五个字概括就是:先卸货后装货
那么我们先看下卸货的过程
卸货
卸货我们看两个函数就可以,toUnmountPromise
和toUnloadPromise
,源码如下:
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)
);
});
这里我们重点看下toLoadPromise
和tryToBootstrapAndMount
函数,源码如下:
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
状态,两次判断的作用是为了确保应用加载的准确性,上面的代码中给出了官方的解释,在这里我们也不用做多用的分析,我们只需要知道判断两次的作用就可以了。 重点还是放在toBootstrapPromise
和toMountPromise
函数上,源码如下:
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)
,并且我们的appOptions
的el
是#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
}
}
啰啰嗦嗦说了这么多,希望对大家有所帮助,如果有不对的地方,欢迎指正,谢谢!