single-spa
single-spa
qiankun 是一个基于 single-spa 的微前端实现库,在 qiankun 还未诞生前,用户通常使用 single-spa 来解决微前端的问题,所以我们先来了解 single-spa。我们先来上一个例子,并逐步分析每一步发生了什么。
-
亮点
- 全异步编程,对于用户需要提供的 load,bootstrap,mount,unmount 均使用 promise 异步的形式处理,不管同步、异步都能 hold 住
- 通过劫持路由,可以在每次路由变更时先判断是否需要切换应用,再交给子应用去响应路由
- 标准化每个应用的挂载和卸载函数,不耦合任何框架,只要子应用实现了对应接口即可接入系统中
-
不足
load
方法需要知道子项目的入口文件- 把多个应用的运行时集成起来需要项目间自行处理内存泄漏,样式污染问题
- 没有提供父子数据通信的方式
应用注册
import { registerApplication, start } from "single-spa";
registerApplication(
"foo",
() => System.import("foo"),
(location) => location.pathname.startsWith("foo")
);
registerApplication({
name: "bar",
loadingFn: () => import("bar.js"),
activityFn: (location) => location.pathname.startsWith("bar"),
});
start();
- appName: string 应用的名字将会在 single-spa 中注册和引用, 并在开发工具中标记
- loadingFn: () => 必须是一个加载函数,返回一个应用或者一个 Promise
- activityFn: (location) => boolean 判断当前应用是否活跃的方法
- customProps?: Object 可选的传递自定义参数
元数据处理
首先,single-spa 会对上述数据进行标准化处理,并添加上状态,最终转化为一个元数据数组,例如上述数据会被转为:
[{
name: 'foo',
loadApp: () => System.import('foo'),
activeWhen: location => location.pathname.startsWith('foo'),
customProps: {},
status: 'NOT_LOADED'
},{
name: 'bar',
loadApp: () => import('bar.js'),
activeWhen: location => location.pathname.startsWith('bar')
customProps: {},
status: 'NOT_LOADED'
}]
路由劫持
single-spa 内部会对浏览器的路由进行劫持,所有的路由方法和路由事件都确保先进入 single-spa 进行统一调度。
// 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;
window.addEventListener = function (eventName, fn) {
if (typeof fn === "function") {
if (
["hashchange", "popstate"].indexOf(eventName) >= 0 &&
!find(capturedEventListeners[eventName], (listener) => listener === fn)
) {
capturedEventListeners[eventName].push(fn);
return;
}
}
return originalAddEventListener.apply(this, arguments);
};
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) {
urlReroute(createPopStateEvent(window.history.state, methodName));
}
};
}
window.history.pushState = patchedUpdateState(
window.history.pushState,
"pushState"
);
window.history.replaceState = patchedUpdateState(
window.history.replaceState,
"replaceState"
);
可以看到,所有的劫持都指向了一个出口函数 urlReroute。
urlReroute 统一处理函数
每次路由变化,都进入一个相同的函数进行处理:
let appChangeUnderway = false,
peopleWaitingOnAppChange = [];
export async function reroute(pendingPromises = [], eventArguments) {
// 根据不同的条件把应用分到不同的待处理数组里
const {
appsToUnload,
appsToUnmount,
appsToLoad,
appsToMount,
} = getAppChanges();
// 如果在变更进行中还进行了新的路由跳转,则进入一个队列中排队,
if (appChangeUnderway) {
return new Promise((resolve, reject) => {
peopleWaitingOnAppChange.push({ resolve, reject, eventArguments });
});
}
// 标记此次变更正在执行中,
appChangeUnderway = true;
await Promise.all(appsToUnmount.map(toUnmountPromise)); // 待卸载的应用先执行unmount
await Promise.all(appsToUnload.map(toUnloadPromise)); // 待销毁的应用先销毁
await Promise.all(appsToLoad.map(toLoadPromise)); // 待加载的应用先执行load
await Promise.all(appsToBootstrap.map(toBootstrapPromise)); // 待bootstrap的应用执行bootstrap
await Promise.all(appsMount.map(toMountPromise)); // 待挂载的应用执行mount
appChangeUnderway = false;
// 如果排队的队列中还有路由变更,则进行新的一轮reroute循环
reroute(peopleWaitingOnAppChange);
}
接下来看看分组函数在做什么。
getAppChanges 应用分组
每次路由变更都先根据应用的 activeRule 规则把应用分组。
export function getAppChanges() {
const appsToUnload = [],
appsToUnmount = [],
appsToLoad = [],
appsToMount = [];
apps.forEach((app) => {
const appShouldBeActive =
app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);
switch (app.status) {
case LOAD_ERROR:
case NOT_LOADED:
if (appShouldBeActive) appsToLoad.push(app);
case NOT_BOOTSTRAPPED:
case NOT_MOUNTED:
if (!appShouldBeActive) {
appsToUnload.push(app);
} else if (appShouldBeActive) {
appsToMount.push(app);
}
case MOUNTED:
if (!appShouldBeActive) appsToUnmount.push(app);
}
});
return { appsToUnload, appsToUnmount, appsToLoad, appsToMount };
}
关于状态字段的枚举
single-spa 对应用划分了一下的状态:
export const NOT_LOADED = "NOT_LOADED"; // 还未加载
export const LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE"; // 加载源码中
export const NOT_BOOTSTRAPPED = "NOT_BOOTSTRAPPED"; // 已加载源码,还未bootstrap
export const BOOTSTRAPPING = "BOOTSTRAPPING"; // bootstrap中
export const NOT_MOUNTED = "NOT_MOUNTED"; // bootstrap完毕,还未mount
export const MOUNTING = "MOUNTING"; // mount中
export const MOUNTED = "MOUNTED"; // mount结束
export const UPDATING = "UPDATING"; // updata中
export const UNMOUNTING = "UNMOUNTING"; // unmount中
export const UNLOADING = "UNLOADING"; // unload中
export const LOAD_ERROR = "LOAD_ERROR"; // 加载源码时加载失败
export const SKIP_BECAUSE_BROKEN = "SKIP_BECAUSE_BROKEN"; // 在load,bootstrap,mount,unmount阶段发生脚本错误
single-spa 使用了有限状态机的设计思想:
- 事物拥有多种状态,任一时间只会处于一种状态不会处于多种状态;
- 动作可以改变事物状态,一个动作可以通过条件判断,改变事物到不同的状态,但是不能同时指向多个状态,一个时间,就一个状态;
- 状态总数是有限的。
single-spa 的事件系统
基于浏览器原生的事件系统,无框架耦合,全局开箱可用。
// 接收方式
window.addEventListener("single-spa:before-routing-event", (evt) => {
const {
originalEvent,
newAppStatuses,
appsByNewStatus,
totalAppChanges,
} = evt.detail;
console.log(
"original event that triggered this single-spa event",
originalEvent
); // PopStateEvent | HashChangeEvent | undefined
console.log(
"the new status for all applications after the reroute finishes",
newAppStatuses
); // { app1: MOUNTED, app2: NOT_MOUNTED }
console.log(
"the applications that changed, grouped by their status",
appsByNewStatus
); // { MOUNTED: ['app1'], NOT_MOUNTED: ['app2'] }
console.log(
"number of applications that changed status so far during this reroute",
totalAppChanges
); // 2
});