前言
承接上文,我们通过接入react子应用以及简化到仅几行js代码的子应用的例子让大家对single-spa有了一定程度的理解,那么接下来本文就以这点理解来实现一个简单的single-spa,让我们对微前端有更加深入的理解。
效果
我们在实现single-spa之前, 先来看下我们要达到的效果。
- 先看代码:
html
<body>
<div id="root">主应用展示内容的盒子</div>
<a href="#/a">a应用</a>
<a href="#/b">b应用</a>
<script type="module">
import { registerApplication, start } from './single-spa/single-spa.js';
const app1 = {
bootstrap: [
async () => console.log('app1 bootstrap'),
],
mount: async () => {
console.log('app1 mount');
const div = document.createElement('div');
div.id = 'app1Root';
div.innerHTML = 'app1';
root.append(div);
},
unmount: async () => {
console.log('app1 unmount');
root.removeChild(app1Root);
},
};
const app2 = {
bootstrap: async () => console.log('app2 bootstrap'),
mount: async () => {
const div = document.createElement('div');
div.id = 'app2Root';
div.innerHTML = 'app2';
root.append(div);
console.log('app2 mount');
},
unmount: async () => {
console.log('app2 unmount');
root.removeChild(app2Root);
}
};
registerApplication('a', async () => app1, location => location.hash.startsWith('#/a'), { a: 1 });
registerApplication('b', async () => app2, location => location.hash.startsWith('#/b'), { b: 1 });
start();
</script>
</body>
注意引入registerApplication和start是自己实现的single-spa。
这里我们注册了两个子应用,他们很简单,就是分别实现了bootstrap、mount、unmount三个接入协议,这三个协议主要做的事情后就是加载的时候打印自己,挂载的时候自己的内容挂载到主应用去,卸载的时候把自己的内容卸载掉,效果如下。
实现
看完效果后,我们该开始实现它了。
在实现之前,这里先贴一下文件结构,方便后面引入导出的代码的理解。
注册
要想实现一个东西,我们得先才从入口出发,一点一点往里深入。
这里的入口自然是我们注册子应用了,我们看看registerApplication的传参,分别有四个:
- appName:子应用的名称,也可以称之为子应用的一个身份标识
- loadApp:加载子应用的方法
- activeWhen:展示子应用的条件判断方法
- customProps:给子应用传的自定义参数(不太重要,可以不关注)
根据这些信息我们可以写下这些代码:
js
// 文件名:single-spa/application/app.js
// 所有已经注册的子应用
export const apps = [];
export function registerApplication(appName, loadApp, activeWhen, customProps) {
const registration = {
name: appName,
loadApp,
activeWhen,
customProps,
};
apps.push(registration);
}
我们需要一个数组记录已经注册的子应用,然后这些所谓的注册的子应用就是记录下他们传的参数。
但我们仅记录这些够吗?
仔细看看上面的效果图,我们切换子应用的时候是要卸载(走他们的unmount)之前的应用的,那么我们怎么知道要卸载哪些应用呢?
或许有的掘友会说,我们定义一个变量,记录当前正在展示的子应用,当切换时就把这个正在展示的子应用给卸载了。
这样的方法好像可行,但如果我一个页面有左右两个区域,同时加载两个不同的子应用a和b,但此时需要将a切换成c,但b不变呢?上面的这种方法是不是就哑火了。
实际上解决方法很简单,就是给每个子应用都提供一个状态属性,也就是记录子应用当前是处于什么状态,是在加载中、还是在挂载中还是已经被卸载掉了。我们定义以下这些状态,也可以称他们为子应用的生命周期。
js
// 文件名:single-spa/application/app.helper.js
// 没有被加载
export const NOT_LOADED = "NOT_LOADED";
// activeWhen匹配了,要去加载这个资源了,加载中
export const LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE";
// 加载失败
export const LOAD_ERROR = "LOAD_ERROR";
// 启动
// 资源加载完成了,还没启动
export const NOT_BOOTSTRAPED = "NOT_BOOTSTRAPED";
// 正在启动
export const BOOTSTRAPING = "BOOTSTRAPING";
// 挂载
// 没有被挂载
export const NOT_MOUNTED = "NOT_MOUNTED";
// 正在挂载
export const MOUNTING = "MOUNTING";
// 挂载完成
export const MOUNTED = "MOUNTED";
// 卸载
export const UNMOUNTING = "UNMOUNTING";
然后每个子应用增加多一个status记录项。
js
// 文件名:single-spa/application/app.js
import { NOT_LOADED } from "./app.helper.js";
export const apps = [];
export function registerApplication(appName, loadApp, activeWhen, customProps) {
const registration = {
name: appName,
loadApp,
activeWhen,
customProps,
status: NOT_LOADED, // 这里增加了status,注册的时候自然是未加载的状态
};
apps.push(registration);
}
这样我们只要对他们的状态进行维护,切换子应用的时候遍历一下他们的状态,给这些子应用进行分类,然后在一个合适的时机分别执行他们对应的方法就行了。
js
// 文件名:single-spa/application/app.helper.js
import { apps } from "./app.js";
// 应用是否处于激活中
export function isActive(app) {
return app.status === MOUNTED;
}
// 应用是否应该被激活
export function shouldBeActive(app) {
return app.activeWhen(window.location);
}
export function getAppChanges() {
const appsToLoad = [];
const appsToMount = [];
const appsToUnmount = [];
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_BOOTSTRAPED:
case BOOTSTRAPING:
case NOT_MOUNTED:
// 当前路由下要被挂载的应用
if (appShouldBeActive) {
appsToMount.push(app);
}
break;
case MOUNTED:
// 当前路由下要被卸载的应用
if (!appShouldBeActive) {
appsToUnmount.push(app);
}
break;
default:
break;
}
});
return { appsToLoad, appsToMount, appsToUnmount };
}
到此,我们初步的注册应用就可以了。
路由劫持
上面注册完子应用后,我们就该考虑将子应用应用起来了。
我们继续从入口出发,看看activeWhen这个入参,可以看出切换子应用都是与路由相关的,那么我们方向就很明确了,那就是在路由变化的时候去执行这个activeWhen,结果返回true的子应用就是要挂载的。
那么如何监听路由变化并执行这些方法呢?很简单---路由劫持。
路由劫持并不难,主要是改写hashchange和popstate,这里就主要是贴代码了。
代码中的reroute就是我们执行activeWhen以及针对activeWhen返回的结果进行挂载卸载子应用了。
js
// 文件名:single-spa/navigation/navigation-event.js
import { reroute } from "./reroute.js";
// 对页面路由切换进行劫持,劫持后重新调用reroute方法,进行计算应用的加载
function urlRoute() {
reroute(arguments);
}
window.addEventListener("hashchange", urlRoute);
window.addEventListener("popstate", urlRoute);
// 需要劫持原生的路由系统,保证应用加载完后再切换路由
const capturedEventListeners = {
hashchange: [],
popstate: [],
};
const listeningTo = ["hashchange", "popstate"];
const originAddEventListener = window.addEventListener;
const originRemoveEventListener = window.removeEventListener;
// 改写addEventListener,收集所有hashchange和popstate绑定的事件
window.addEventListener = function (eventName, callback) {
if (
listeningTo.includes(eventName) &&
!capturedEventListeners[eventName].some((listener) => listener === callback)
) {
capturedEventListeners[eventName].push(callback);
return;
}
return originAddEventListener.apply(this, arguments);
};
// 改写removeEventListener,移除对应的hashchange和popstate绑定的事件
window.removeEventListener = function (eventName, callback) {
if (listeningTo.includes(eventName)) {
capturedEventListeners[eventName] = capturedEventListeners[
eventName
].filter((listener) => listener !== callback);
return;
}
return originRemoveEventListener.apply(this, arguments);
};
// 在触发了hashchange或popstate后,执行之前收集到的对应的回调
export function callCaptureEventListeners(e) {
if (e) {
const eventType = e[0].type;
if (listeningTo.includes(eventType)) {
capturedEventListeners[eventType].forEach((listener) => {
listener.apply(this, e);
});
}
}
}
// 改写pushState和replaceState
function patchFn(updateState) {
return function () {
const urlBefore = window.location.href;
const r = updateState.apply(this, arguments);
const urlAfter = window.location.href;
// 只有路径发生变化了才走逻辑
if (urlBefore !== urlAfter) {
// 手动派发popstate事件
window.dispatchEvent(new PopStateEvent("popstate"));
}
return r;
};
}
window.history.pushState = patchFn(window.history.pushState);
window.history.replaceState = patchFn(window.history.replaceState);
代码比较简单,代码中也有不少注释,所以这部分不做过多解释,看代码就能懂。
切换应用
上面我们实现了路由劫持,这里就该实现路由劫持要做的事了,也就是reroute方法。这个方法是核心,一切切换应用的逻辑都在这里。
在注册部分,我们聊过子应用的生命周期,我们在给子应用定义四个生命周期,分别是load -> bootstrap -> mount -> unmount。
那么我们先来完善这个生命周期,先实现他们在各个生命周期要做的事。
为了保证子应用自身生命周期的按顺序执行,所以每一个生命周期函数都应该是promise。
- load
所谓load,其实就是子应用的初始化,加载对应的子应用的代码,然后进行一些初始化操作,为后续的执行做铺垫,我们直接看代码。
js
// single-spa/lifecycles/load.js
import {
LOADING_SOURCE_CODE,
NOT_BOOTSTRAPED,
NOT_LOADED,
} from "../application/app.helper.js";
// 把函数进行串联
function flattenArrayToPromise(fns) {
fns = Array.isArray(fns) ? fns : [fns];
return function (props) {
return fns.reduce(
(rPromise, fn) => rPromise.then(() => fn(props)),
Promise.resolve()
);
};
}
export function toLoadPromise(app) {
return Promise.resolve().then(() => {
if (app.status !== NOT_LOADED) {
// 此应用加载完毕,就不需要加载了,直接返回
return app;
}
// 需要加载,把状态改为加载中
app.status = LOADING_SOURCE_CODE;
// 执行注册时提供的加载应用的方法
return app.loadApp(app.customProps).then((value) => {
const { bootstrap, mount, unmount } = value;
app.status = NOT_BOOTSTRAPED;
app.bootstrap = flattenArrayToPromise(bootstrap);
app.mount = flattenArrayToPromise(mount);
app.unmount = flattenArrayToPromise(unmount);
return app;
});
});
}
此处有一个难点,那就是flattenArrayToPromise这个方法,这个方法的作用是将一堆函数进行串联,让他们按照固定的顺序执行。
这个方法的存在主要是为了兼容那三个接入协议是数组的情况,我们返回到最开始的例子中,可以看到app1的bootstrap是一个数组,这是因为single-spa就是支持子应用的接入协议是数组的格式的。
- bootstrap
js
// single-spa/lifecycles/bootstrap.js
import {
BOOTSTRAPING,
NOT_BOOTSTRAPED,
NOT_MOUNTED,
} from "../application/app.helper.js";
export function toBootstrapPromise(app) {
return Promise.resolve().then(() => {
if (app.status !== NOT_BOOTSTRAPED) {
return app;
}
app.status = BOOTSTRAPING;
return app.bootstrap(app.customProps).then(() => {
app.status = NOT_MOUNTED;
return app;
});
});
}
- mount
js
// single-spa/lifecycles/mount.js
import { MOUNTED, NOT_MOUNTED } from "../application/app.helper.js";
export function toMountPromise(app) {
return Promise.resolve().then(() => {
// 结合bootstrap看,凡是加载完或者曾经被卸载的应用都会是NOT_MOUNTED状态
// 所以非这个状态就不可能需要去mount
if (app.status !== NOT_MOUNTED) {
return app;
}
return app.mount(app.customProps).then(() => {
app.status = MOUNTED;
return app;
});
});
}
- unmount
js
// single-spa/lifecycles/unmount.js
import { MOUNTED, NOT_MOUNTED, UNMOUNTING } from "../application/app.helper.js";
export function toUnmountPromise(app) {
return Promise.resolve().then(() => {
// 不是挂载状态就不用卸载了
if (app.status !== MOUNTED) {
return app;
}
app.status = UNMOUNTING;
return app.unmount(app.customProps).then(() => {
app.status = NOT_MOUNTED;
});
});
}
以上就是这四个生命周期函数了,除了load要进行一些初始化操作外,其他生命周期就是切换自身的状态而已。
ok,到这,准备工作都完成了,我们来分析下切换一个应用的时候都需要做什么事:
- 卸载不需要的应用(执行不需要的应用的unmount)
- 加载需要的应用(执行需要的应用的load)
- 启动需要的应用(执行需要的应用的bootstrap)
- 挂载需要的应用(执行需要的应用的mount)
- 执行切换路由后对应的绑定事件
把上面这些描述改为代码:
js
// 文件名:single-spa/navigation/reroute.js
import { getAppChanges, shouldBeActive } from "../application/app.helper.js";
import { toBootstrapPromise } from "../lifecycles/bootstrap.js";
import { toLoadPromise } from "../lifecycles/load.js";
import { toMountPromise } from "../lifecycles/mount.js";
import { toUnmountPromise } from "../lifecycles/unmount.js";
import { callCaptureEventListeners } from "./navigation-event.js";
export function reroute() {
const { appsToLoad, appsToMount, appsToUnmount } = getAppChanges();
function performAppChange() {
// 先卸载不需要的应用,返回一个卸载的promise
const unmountAllPromises = Promise.all(appsToUnmount.map(toUnmountPromise));
// 加载需要的应用 => 启动对应的应用 => 挂载对应的应用
const loadMountPromises = Promise.all(
appsToLoad.map((app) =>
toLoadPromise(app).then((app) => {
// 当应用加载完,需要启动和挂载时,需要保证卸载掉老的应用
if (shouldBeActive(app)) {
// 保证卸载完毕后再挂载
return toBootstrapPromise(app).then((app) =>
unmountAllPromises.then(() => toMountPromise(app))
);
}
})
)
);
const mountPromises = Promise.all(
appsToMount.map((app) => tryBootStrapAndMount(app, unmountAllPromises))
);
// 执行切换路由后对应的绑定事件
return Promise.all([loadMountPromises, mountPromises]).then(() => {
callEventListener();
});
}
performAppChange();
}
start
上面已经把核心逻辑完成,我们现在只要完成start方法,那么基本就ok了。
start方法并没有太多的逻辑,主要是记录一下当前是否start已经调用一下上面这个reroute就ok了。
js
// 文件名:single-spa/start.js
import { reroute } from "./navigation/reroute.js";
// 默认没有调用start方法
export let started = false;
export function start() {
// 启动了
started = true;
reroute();
}
结尾
ok,到此整个简单的single-spa基本就完成了,不过这里需要注意一下,上面贴的代码仅是核心部分,其实很多地方少了一些处理细节的代码,所以需要看完整代码的可以点击文末的链接。
最后,希望这篇文章能对大家有所帮助,另外,觉得本文不错的话,请不要吝啬手中的赞哦🌹🌹🌹