Single-SPA简单使用案例
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<a href="#/a">a应用</a>
<a href="#/b">b应用</a>
<script src="https://cdn.bootcdn.net/ajax/libs/single-spa/5.9.3/umd/single-spa.min.js"></script>
<script>
let {registerApplication, start} = singleSpa;
//子应用中需要暴露三个钩子函数
let app1 = {
boostrap: [
async () => {
console.log('app1 启动-1')
},
async () => {
console.log('app1 启动-2')
}
],
mount: async () => {
console.log('app1 mount')
},
unmount: async () => {
console.log('app1 unmount')
},
};
let app2 = {
boostrap: [
async () => {
console.log('app2 启动-1')
},
async () => {
console.log('app2 启动-2')
}
],
mount: async () => {
console.log('app2 mount')
},
unmount: async () => {
console.log('app2 unmount')
},
}
const customProps = {name: 'wq'}
registerApplication(
'app1',
async () => app1,
location => location.hash == '#/a', //路径匹配后会加载应用
customProps //自定义属性
),
registerApplication(
'app2',
async () => app2,
location => location.hash == '#/b', //路径匹配后会加载应用
customProps //自定义属性
)
start();
</script>
</body>
</html>
由上可知,需要先在父应用中注册子应用,等路径匹配时,再加载子应用。子应用对外暴露三个钩子函数boostrap,mount, unmount.
Single-SPA源码分析
状态机
single-spa基于子应用的状态来进行加载,挂载,卸载等操作,在进行相应操作后,需要更改状态,不停的进行状态流转。
首先来看一张状态流转图
在子应用的不同阶段,有不同的状态。
registerApplication方法
调用此方法进行应用注册,也就是将应用保存起来
const apps = []; //这里用于存放所有的应用
function registerApplication(appName, loadApp, activeWhen, customProps) {
//注册子应用
const registeraction = {
name: appName,
loadApp,
activeWhen,
customProps,
status: NOT_LOADED
}
//保存到数组中,后续可以在数组里筛选需要的app是加载还是卸载还是挂载
apps.push(registeraction);
//需要加载应用 注册完毕后,需要进行应用的加载
//后续切换路由,要再次做这些事,single-spa的核心
reroute();
}
reroute方法是single-spa的核心,看一下reoute方法是怎么实现的:
function reroute() {
//首先,我们需要先知道需要加载,需要挂载,需要卸载的应用
const {appsToLoad, appsToMount, appsToUnmount} = getAppChanges();
//确认了需要加载的app之后,进行加载
return loadApps()
function loadApps() {
const loadPromises = appsToLoad.map(toLoadPromise);
return Promise.all(loadPromises);
}
}
function toLoadPromise() {
//需要返回一个Promise
return Promise.resolve().then(() => {
if(app.status !== NOT_LOADED) {
//只有当应用是NOT_LOADED的时候才需要加载
return app;
}
return app.loadApp().then((val) => {
//应用加载完成后
let { boostrap, mount, unmount } = val;
app.status = NOT_BOOTSTRAPPED; //改变应用状态
//获取应用的钩子方法,接入协议
//因为钩子函数可能为数组,因此需要打平
app.boostrap = flattenFnArray(boostrap);
app.mount = flattenFnArray(mount);
app.unmount = flattenFnArray(unmount);
return app;
})
})
}
function flattenFnArray() {
fns = Array.isArray(fns) ? fns : [fns]
//数组中的promise需要按序调用
return function(customProps) {
//异步串行
return fns.reduce((resultPromise, fn) => resultPromise.then(() => fn(customProps)), Promise.resolve())
}
}
function shouldBeActive(app) {
//判断此应用是否应该被激活
return app.activeWhen(window.location) //路由匹配上了,就需要激活
}
function getAppChanges() {
const appsToLoad = [];
const appsToMount = [];
const appsToUnmount = [];
//这里的apps就是registerApplication中存放所有子应用的地方
apps.forEach(app => {
const appShouldBeActive = shouldBeActive(app);
switch(app.status) {
case NOT_LOADED: //没有被加载,需要加载
case LOADING_SOURCE_CODE:
if(appShouldBeActive) {
appsToLoad.push(app);
}
break;
case NOT_BOOTSTRAPPED: //没有启动过,没有被挂载,需要挂载
case NOT_MOUNTED:
if(appShouldBeActive) {
appsToMount.push(app);
}
break;
case MOUNTED:
if(!appShouldBeActive) { //路径不匹配
appsToUnmount.push(app); //正在挂载中,但是路径不匹配,需要卸载
}
break;
default:
break;
}
});
return {appsToLoad, appsToMount, appsToUnmount}
}
总结:registerApplication方法就是保存应用,并预加载被激活的子应用
start方法
//开始启动主应用
let start = false;
function start() {
start = true;
reroute();
}
start方法获取加载的应用,执行对应的生命周期钩子
回到reroute方法
function reroute() {
//首先,我们需要先知道需要加载,需要挂载,需要卸载的应用
const {appsToLoad, appsToMount, appsToUnmount} = getAppChanges();
//在启动阶段,执行子应用的生命周期钩子
if(start) {
return perfromAppChanges()
}
//确认了需要加载的app之后,进行加载
return loadApps()
function loadApps() {
const loadPromises = appsToLoad.map(toLoadPromise);
return Promise.all(loadPromises);
}
function perfromAppChanges() {
//需要调用bootrap,调用mount和unmount
//加载应用,toLoadPromise中需要增加LOADING_SOURCE_CODE状态,避免重复加载
//tryBootstrapAndMount方法执行钩子函数
appsToLoad.map(app => toLoadPromise(app).then(app, tryBootstrapAndMount(app)))
}
}
function toLoadPromise(app) {
return Promise.resolve().then(() => {
//获取应用的钩子方法,接入协议
if(app.status !== NOT_LOADED) {
//只有当他是NOT_LOADED的时候才需要加载
return app;
}
app.status = LOADING_SOURCE_CODE;
return app.loadApp().then((val) => {
let { boostrap, mount, unmount } = val;
app.status = NOT_BOOTSTRAPPED;
app.boostrap = flattenFnArray(boostrap);
app.mount = flattenFnArray(mount);
app.unmount = flattenFnArray(unmount);
return app;
})
})
}
function tryBootstrapAndMount(app) {
return Promise.resolve().then(() => {
if(shouldBeActive(app)) {
//先执行bootrap,再执行mount
return toBoostrapPromise(app).then(toMountPromise)
}
})
}
function toBoostrapPromise() {
return Promise.resolve().then(() => {
if(app.status !== NOT_BOOTSTRAPPED) {
return app;
}
app.status = BOOSTRAPPING; //正在启动
//执行钩子函数
return app.boostrap(app.customProps).then(() => {
app.status = NOT_MOUNTED;
return app;
})
})
}
function toMountPromise(app) {
//挂载应用
return Promise.resolve().then(() => {
if(app.status !== NOT_MOUNTED) {
return app;
}
return app.mount(app.customProps).then(() => {
app.status = MOUNTED;
return app;
})
})
}
start()方法就完成了
在路由切换时,需要能够挂载和卸载子应用。在片段标识符变化时(片段标识符指 URL 中 # 号和它以后的部分),会触发hashchange事件,而在history.back(),history.go()执行时,会触发popstate事件(注意:history.pushState(), history.replaceState()不会触发popstate事件,需要手动触发)
此外,要注意子应用中也有可能有路由系统,需要保证先加载父应用,再加载子应用。因此需要劫持window.addEventListener,先保存popstate,hashchange的处理事件。
function urlRoute() {
reroute();
}
window.addEventListener('hashchange', urlRoute)
window.addEventListener('popstate', urlRoute)
const routerEventListeningTo = ['hashchange', 'popstate'];
const capturedEventListener = {
hashchange: [], //什么时候调用?父应用加载完子应用后调用
popstate: []
}
const originalAddEventListener = window.addEventListener;
const originalRemoveListener = window.removeEventListener;
window.addEventListener = function (eventName, fn) {
if(routerEventListeningTo.includes(eventName) && !capturedEventListener[eventName].some(l => l === fn)) {
//避免重复监听
return capturedEventListener[eventName].push(fn);
}
return originalAddEventListener.apply(this, arguments)
}
window.removeEventListener = function(eventName, fn) {
if(routerEventListeningTo.includes(eventName)) {
return capturedEventListener[eventName] = capturedEventListener[eventName].filter(l => l !== fn);
}
return originalRemoveListener.apply(this, arguments)
}
//如果使用的是history.pushState,可以实现页面跳转,但是不会触发popstate
//解决historyApi调用时可以触发popstate
history.pushState = function() {
//触发popstate事件
window.dispatchEvent(new PopStateEvent('popstate'))
}
再次回到reroute方法中,reroute中在路由切换的时候,还需要卸载失活的子应用
function reroute() {
//首先,我们需要先知道需要加载,需要挂载,需要卸载的应用
const {appsToLoad, appsToMount, appsToUnmount} = getAppChanges();
//在启动阶段,执行子应用的生命周期钩子
if(start) {
return perfromAppChanges()
}
//确认了需要加载的app之后,进行加载
return loadApps()
function loadApps() {
const loadPromises = appsToLoad.map(toLoadPromise);
return Promise.all(loadPromises);
}
function perfromAppChanges() {
//需要调用bootrap,调用mount和unmount
//应用启动了,需要卸载不需要的,挂载需要的
const unmountPromises = Promise.all(appsToUnmount.map(toUnmountPromise)); //先卸载应用
//tryBootstrapAndMount方法执行钩子函数
appsToLoad.map(app => toLoadPromise(app).then(app, tryBootstrapAndMount(app, unmountPromises)))
//有可能start()异步调用的,如果此时已经加载完成,处于要挂载的阶段,直接挂载就行了
appsToMount.map((app) => tryBootstrapAndMount(app, unmountPromises))
}
}
function toUnmountPromise() {
return Promise.resolve().then(() => {
//如果不是挂载状态,退出
if(app.status !== MOUNTED) {
return app;
}
app.status = UNMOUNTING; //正在卸载
return app.unmount(app.customProps).then(() => {
app.status = NOT_MOUNTED;
return app;
})
})
}
//tryBootstrapAndMount中也需要改造一下,需要等旧的子应用卸载之后,才能挂载新的应用
function tryBootstrapAndMount(app, unmountPromises) {
return Promise.resolve().then(() => {
if(shouldBeActive(app)) {
//先执行bootrap,再执行mount
return toBoostrapPromise(app).then((app) => {
return unmountPromises.then(() => {
//执行保存的事件处理函数
capturedEventListener.hashchange.forEach(fn => fn())
capturedEventListener.popstate.forEach(fn => fn())
return toMountPromise(app)
})
})
}
})
}
至此,整个single-spa的大致工作原理就完成了,在加载和卸载的过程中,大量运用到promise的知识