single-spa解读
single-spa是什么,我的理解是一个应用调度框架主要的职责就是2件事,维护子应用状态和路由调度,梳理思路也会按照2个大方向来做功能的分类
- 维护子应用的状态,每个子应用都有自己的状态,子应用暴露生命周期函数来让框架统一调度
- 监听url的变化,由框架来调度子应用要做什么,主要就是(load、mount、unmount)三个生命周期
子应用调度
registerApplication注册子应用
js
export function registerApplication(
appNameOrConfig,
appOrLoadApp,
activeWhen,
customProps
) {
// 格式化入参由于支持多种传入格式,并对不合规的参数做校验
// 这里不需要深入去看,逻辑比较简单更多是为了格式校验和格式兼容
const registration = sanitizeArguments(
appNameOrConfig,
appOrLoadApp,
activeWhen,
customProps
);
// 重复的应用不能重复注册
if (getAppNames().indexOf(registration.name) !== -1)
throw Error(`There is already an app registered with name ${registration.name}`);
// 将注册的应用推入apps数组方便查找
apps.push(
assign( // 支持ie11,babel体积太大
{
loadErrorTime: null,
status: NOT_LOADED,
parcels: {},
devtools: {
overlays: {
options: {},
selectors: [],
},
},
},
registration
)
);
if (isInBrowser) {
ensureJQuerySupport(); // 对jquery打补丁,个人没看出有什么用
reroute(); // 根据当前路由
}
}
reroute根据当前url处理对应子应用并执行相应的生命周期处理
- appChangeUnderway在执行一次reroute的change时置为true,当在一次处理时又有其他的reroute函数调用进来,将这次调用存起来,等上一次执行完再重新执行一次,相当于加入了一个排队机制
- 根据当前url和子应用的激活条件计算出需要处理的子应用,并按照生命周期分类(appsToUnload、appsToUnmount、appsToLoad、appsToMount)
- 根据start是否调用,决定是执行全生命周期的执行还是只加载需要加载的应用
js
export function reroute(pendingPromises = [], eventArguments) {
// 已经启动了一次变更将这次的参数加入peopleWaitingOnAppChange等待上一次执行完再继续执行
if (appChangeUnderway) {
return new Promise((resolve, reject) => {
peopleWaitingOnAppChange.push({
resolve,
reject,
eventArguments,
});
});
}
// 计算当前每个app要做什么
const {
appsToUnload,
appsToUnmount,
appsToLoad,
appsToMount,
} = getAppChanges();
let appsThatChanged,
navigationIsCanceled = false,
oldUrl = currentUrl,
newUrl = (currentUrl = window.location.href);
if (isStarted()) { // 已经启动了所有的流程
appChangeUnderway = true;
appsThatChanged = appsToUnload.concat(
appsToLoad,
appsToUnmount,
appsToMount
);
return performAppChanges();
} else { // 第一次启动只需要做load流程
appsThatChanged = appsToLoad;
return loadApps();
}
function cancelNavigation() {
navigationIsCanceled = true;
}
function loadApps() {
return Promise.resolve().then(() => {
// 需要加载的app根据app的配置返回加载app的promise
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;
})
);
});
}
/**
* @description: 执行所有需要变更的app流程
* 按照如下顺序执行unmount->unload->load->mount
* @return {*}
*/
function performAppChanges() {
return Promise.resolve().then(() => {
// https://github.com/single-spa/single-spa/issues/545
window.dispatchEvent(
new CustomEvent(
appsThatChanged.length === 0
? "single-spa:before-no-app-change"
: "single-spa:before-app-change",
getCustomEventDetail(true)
)
);
window.dispatchEvent(
new CustomEvent(
"single-spa:before-routing-event",
getCustomEventDetail(true, { cancelNavigation })
)
);
if (navigationIsCanceled) {
window.dispatchEvent(
new CustomEvent(
"single-spa:before-mount-routing-event",
getCustomEventDetail(true)
)
);
finishUpAndReturn();
navigateToUrl(oldUrl);
return;
}
// 生成unload的promise
const unloadPromises = appsToUnload.map(toUnloadPromise);
// 执行了2个阶段的工作流程,先执行unmount再执行unload
const unmountUnloadPromises = appsToUnmount
.map(toUnmountPromise)
.map((unmountPromise) => unmountPromise.then(toUnloadPromise));
// 将unmount和unload的工作合成一个工作流程
const allUnmountPromises = unmountUnloadPromises.concat(unloadPromises);
const unmountAllPromise = Promise.all(allUnmountPromises);
unmountAllPromise.then(() => {
window.dispatchEvent(
new CustomEvent(
"single-spa:before-mount-routing-event",
getCustomEventDetail(true)
)
);
});
/* 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(() => {
/* Now that the apps that needed to be unmounted are unmounted, their DOM navigation
* events (like hashchange or popstate) should have been cleaned up. So it's safe
* to let the remaining captured event listeners to handle about the DOM event.
*/
callAllEventListeners();
return Promise.all(loadThenMountPromises.concat(mountPromises))
.catch((err) => {
pendingPromises.forEach((promise) => promise.reject(err));
throw err;
})
.then(finishUpAndReturn);
});
});
}
function finishUpAndReturn() {
const returnValue = getMountedApps();
pendingPromises.forEach((promise) => promise.resolve(returnValue));
try {
const appChangeEventName =
appsThatChanged.length === 0
? "single-spa:no-app-change"
: "single-spa:app-change";
window.dispatchEvent(
new CustomEvent(appChangeEventName, getCustomEventDetail())
);
window.dispatchEvent(
new CustomEvent("single-spa:routing-event", getCustomEventDetail())
);
} catch (err) {
/* We use a setTimeout because if someone else's event handler throws an error, single-spa
* needs to carry on. If a listener to the event throws an error, it's their own fault, not
* single-spa's.
*/
setTimeout(() => {
throw err;
});
}
/* Setting this allows for subsequent calls to reroute() to actually perform
* a reroute instead of just getting queued behind the current reroute call.
* We want to do this after the mounting/unmounting is done but before we
* resolve the promise for the `reroute` function.
*/
appChangeUnderway = false;
if (peopleWaitingOnAppChange.length > 0) {
/* While we were rerouting, someone else triggered another reroute that got queued.
* So we need reroute again.
*/
const nextPendingPromises = peopleWaitingOnAppChange;
peopleWaitingOnAppChange = [];
reroute(nextPendingPromises);
}
return returnValue;
}
/* We need to call all event listeners that have been delayed because they were
* waiting on single-spa. This includes haschange and popstate events for both
* the current run of performAppChanges(), but also all of the queued event listeners.
* We want to call the listeners in the same order as if they had not been delayed by
* single-spa, which means queued ones first and then the most recent one.
*/
function callAllEventListeners() {
pendingPromises.forEach((pendingPromise) => {
callCapturedEventListeners(pendingPromise.eventArguments);
});
callCapturedEventListeners(eventArguments);
}
function getCustomEventDetail(isBeforeChanges = false, extraProperties) {
const newAppStatuses = {};
const appsByNewStatus = {
// for apps that were mounted
[MOUNTED]: [],
// for apps that were unmounted
[NOT_MOUNTED]: [],
// apps that were forcibly unloaded
[NOT_LOADED]: [],
// apps that attempted to do something but are broken now
[SKIP_BECAUSE_BROKEN]: [],
};
if (isBeforeChanges) {
appsToLoad.concat(appsToMount).forEach((app, index) => {
addApp(app, MOUNTED);
});
appsToUnload.forEach((app) => {
addApp(app, NOT_LOADED);
});
appsToUnmount.forEach((app) => {
addApp(app, NOT_MOUNTED);
});
} else {
appsThatChanged.forEach((app) => {
addApp(app);
});
}
const result = {
detail: {
newAppStatuses,
appsByNewStatus,
totalAppChanges: appsThatChanged.length,
originalEvent: eventArguments?.[0],
oldUrl,
newUrl,
navigationIsCanceled,
},
};
if (extraProperties) {
assign(result.detail, extraProperties);
}
return result;
function addApp(app, status) {
const appName = toName(app);
status = status || getAppStatus(appName);
newAppStatuses[appName] = status;
const statusArr = (appsByNewStatus[status] =
appsByNewStatus[status] || []);
statusArr.push(appName);
}
}
}
loadApps加载子应用
toLoadPromise
主要就是执行子应用提供的load方法来加载子应用,并返回一个promise值是子应用的生命周期函数,并修改子应用的状态
performAppChanges执行全生命周期
- dispatchEvent主要是为了让用户能监测到single-spa内部的状态,来做个性化的扩展,并可以影响内部执行流程。
- 所有要处理的子应用执行对应的生命周期函数按照unmount->unload->load->mount顺序执行
- 执行生命周期的过程都是类似的,执行子应用暴露的生命周期方法,改变应用的状态,同时也有很多代码是保证程序的健壮性的
这里有一个通用函数需要看下,所有的生命周期执行都有时间上的控制,并传入了props,向子应用注入了single-spa 核心其实就一行代码appOrParcel[lifecycle](getProps(appOrParcel))
js
/**
* 生成一个promise,声明周期方法具有超时警告的能力
* dieOnTimeout如果为true直接超时失败,否则会一直出超时提示
* @param {*} appOrParcel
* @param {string} lifecycle 声明周期
* @returns
*/
export function reasonableTime(appOrParcel, lifecycle) {
const timeoutConfig = appOrParcel.timeouts[lifecycle];
const warningPeriod = timeoutConfig.warningMillis;
const type = objectType(appOrParcel);
return new Promise((resolve, reject) => {
let finished = false;
let errored = false;
appOrParcel[lifecycle](getProps(appOrParcel))
.then((val) => {
finished = true;
resolve(val);
})
.catch((val) => {
finished = true;
reject(val);
});
setTimeout(() => maybeTimingOut(1), warningPeriod);
setTimeout(() => maybeTimingOut(true), timeoutConfig.millis);
const errMsg = formatErrorMessage(
31,
__DEV__ &&
`Lifecycle function ${lifecycle} for ${type} ${toName(
appOrParcel
)} lifecycle did not resolve or reject for ${timeoutConfig.millis} ms.`,
lifecycle,
type,
toName(appOrParcel),
timeoutConfig.millis
);
function maybeTimingOut(shouldError) {
if (!finished) {
if (shouldError === true) {
errored = true;
if (timeoutConfig.dieOnTimeout) {
reject(Error(errMsg));
} else {
console.error(errMsg);
//don't resolve or reject, we're waiting this one out
}
} else if (!errored) {
const numWarnings = shouldError;
const numMillis = numWarnings * warningPeriod;
console.warn(errMsg);
if (numMillis + warningPeriod < timeoutConfig.millis) {
setTimeout(() => maybeTimingOut(numWarnings + 1), warningPeriod);
}
}
}
}
});
}
路由调度
在开发时可能会引入各种路由框架,single-spa要接管路由调度的能力,也防止其他框架劫持了路由能力,所以框架加载时劫持了原生的"hashchange", "popstate"
2个事件来保证single-spa在最外层处理路由跳转
如何监听路由变化
路由的2种模式
- hash模式 使用hashchange来监听
- history模式
pushState、replaceState
可以做到不刷新url更改浏览器的历史,也是众多路由框架使用的apipopstate
事件可以监听到浏览器历史记录的变化,例如前进后退,history.go()等一系列方法
实现路由劫持
在single-spa文件加载时会直接执行一段逻辑来处理劫持,如下 劫持重写了addEventListener
、pushState
、replaceState
三个方法
js
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) {
if (typeof fn === "function") {
if (
routingEventsListeningTo.indexOf(eventName) >= 0 &&
!find(capturedEventListeners[eventName], (listener) => listener === fn)
) {
capturedEventListeners[eventName].push(fn);
return;
}
}
return originalAddEventListener.apply(this, arguments);
};
window.removeEventListener = function (eventName, listenerFn) {
if (typeof listenerFn === "function") {
if (routingEventsListeningTo.indexOf(eventName) >= 0) {
capturedEventListeners[eventName] = capturedEventListeners[
eventName
].filter((fn) => fn !== listenerFn);
return;
}
}
return originalRemoveEventListener.apply(this, arguments);
};
window.history.pushState = patchedUpdateState(
window.history.pushState,
"pushState"
);
window.history.replaceState = patchedUpdateState(
window.history.replaceState,
"replaceState"
);
if (window.singleSpaNavigate) {
console.warn(
formatErrorMessage(
41,
__DEV__ &&
"single-spa has been loaded twice on the page. This can result in unexpected behavior."
)
);
} else {
/* For convenience in `onclick` attributes, we expose a global function for navigating to
* whatever an <a> tag's href is.
*/
window.singleSpaNavigate = navigateToUrl;
}
}
给pushState打补丁,官方解释,由一个子应用触发的url改变,重新触发一次popstate事件
js
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()) {
// fire an artificial popstate event once single-spa is started,
// so that single-spa applications know about routing that
// occurs in a different application
window.dispatchEvent(
createPopStateEvent(window.history.state, methodName)
);
} else {
// do not fire an artificial popstate event before single-spa is started,
// since no single-spa applications need to know about routing events
// outside of their own router.
reroute([]);
}
}
return result;
};
}
总结
single-spa只是实现了微前端的基础能力,提供了一套机制来管理子应用。保证url和子应用能够正常映射起来。 像沙箱环境,全局状态同步等能力没有提供,我感觉这是框架定位决定的。同时这也给了其他微前端方案机会,基于single-spa来封装出更复杂的能力