Apps
本节讲解 single-spa 的顶层 API(应用层 API),从整体了解 s-spa 中应用管理的部分的原理,包括应用 register、unregister、unload 等,初步了解 s-spa 中应用的生命周期脉络,方便后文深入。
# 目录
以下代码位置: src/applications/apps.js
# registerApplication
registerApplication 注册微应用。
// src/applications/apps.js
const apps = [];
export const isInBrowser = typeof window !== "undefined";
export function registerApplication(
appNameOrConfig,
appOrLoadApp,
activeWhen,
customProps
) {
// 参数消毒
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.push(
assign(
{
loadErrorTime: null,
status: NOT_LOADED,
parcels: {},
devtools: {
overlays: {
options: {},
selectors: [],
},
},
},
registration
)
);
if (isInBrowser) {
// 代理 jq 的路由
ensureJQuerySupport();
// 调整路由监听,初始化或者应用配置变化
reroute();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
- 参数消毒
sanitizeArguments 这样的思想在设计复杂的门面 API 时可以参考,既可以过滤掉非法的参数,也可以对参数的传参行为进行加强。
- map 与 mapper
export function getAppNames() {
return apps.map(toName);
}
export function toName(app) {
return app.name;
}
2
3
4
5
6
将 map、forEach 等传入回调函数的方法与回调本身分离开来,赋予 mapper 以一定的逻辑功能点,能够提供复用代码的目的。
- formatErrorMessage
export function formatErrorMessage(code, msg, ...args) {
return `single-spa minified message #${code}: ${
msg ? msg + " " : ""
}See https://single-spa.js.org/error/?code=${code}${
args.length ? `&arg=${args.join("&arg=")}` : ""
}`;
}
2
3
4
5
6
7
错误信息日志在很多框架中都有设计,可以参考各种写法,有些可能需要与官方文档向关联。但是这种把 code 耦合在逻辑代码中的做法有点不妥。
# unregisterApplication
unregisterApplication 删除微应用。
export function unregisterApplication(appName) {
// 未找到卸载微应用
if (apps.filter((app) => toName(app) === appName).length === 0) {
// ......
}
return unloadApplication(appName).then(() => {
// 卸载应用并且将应用从箣竹列表中删除
const appIndex = apps.map(toName).indexOf(appName);
apps.splice(appIndex, 1);
});
}
2
3
4
5
6
7
8
9
10
11
12
# unloadApplication
unloadApplication unload 微应用。
export function unloadApplication(appName, opts = { waitForUnmount: false }) {
// ......
const appUnloadInfo = getAppUnloadInfo(toName(app));
// 在 unload 之前需要等待应用 unmount(即等应用 unmount 之后才能 unload)
if (opts && opts.waitForUnmount) {
// We need to wait for unmount before unloading the app
if (appUnloadInfo) {
// Someone else is already waiting for this, too
// 应用已经正在 unload,返回这个 promise
return appUnloadInfo.promise;
} else {
// We're the first ones wanting the app to be resolved.
// 将应用加入到 unload 队列,并且返回这个 promise,promise 将在应用 unload 时 resolve
const promise = new Promise((resolve, reject) => {
addAppToUnload(app, () => promise, resolve, reject);
});
return promise;
}
} else {
/* We should unmount the app, unload it, and remount it immediately.*/
let resultPromise;
// 不必等待 unmount,直接将之 unload
if (appUnloadInfo) {
// Someone else is already waiting for this app to unload
resultPromise = appUnloadInfo.promise;
// 立即 unload 应用,因为之前已经等待式 unload,现在又立即 unload,所以将之前的 promise.resolve 传入
immediatelyUnloadApp(app, appUnloadInfo.resolve, appUnloadInfo.reject);
} else {
// We're the first ones wanting the app to be resolved.
// 创建 promise,在 promise 中将应用加入到 unload 列表,并立即 unload 应用
resultPromise = new Promise((resolve, reject) => {
addAppToUnload(app, () => resultPromise, resolve, reject);
immediatelyUnloadApp(app, resolve, reject);
});
}
// 返回 unload promise
return resultPromise;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
- 这里提出了 unmount 和 unload 的概念。之后会详细了解两者的区别。
- 分成了四种情况,是否在 unload 之前等待 unmount、是否已经存在于 unload 列表中。可见 unload 本身应该是异步的,因此才返回一个 promise,并承诺应用 unload 时在 promise 中会 resolve。这其中 unmount 的过程是个很重要的因素,因为 unmount 的阻塞才需要区分是否去等待 unmount 的过程,才需要用 unload 的队列来管理这样一个异步的任务队列。可见这里的设计很巧妙。
- 等待 unmount 会等待一个怎样的过程,这个过程如何借宿并且自动触发 unload,我们下文知晓。
# immediatelyUnloadApp
立即 unload 应用。
在 unloadApplication 中的 waitForUnmount
为 false 时,立即 unload 应用,不用等待 unmount。
function immediatelyUnloadApp(app, resolve, reject) {
toUnmountPromise(app)
.then(toUnloadPromise)
.then(() => {
resolve();
setTimeout(() => {
// reroute, but the unload promise is done、
// 宏任务:应用 unload 之后重新调整路由监听
reroute();
});
})
.catch(reject);
}
2
3
4
5
6
7
8
9
10
11
12
13
- 按照 unload 的顺序,应该先 unmount,再 unload,两者都是异步的操作,因此采用 promise。
toUnmountPromise
unmount 应用,并返回 promise;toUnloadPromise
unload 应用并返回 promise。unload 之后先 resolve 整个 unload 任务,再在宏任务里调整路由监听。代码虽少,封装性很强,设计的很巧妙。全程使用 promise 替代 async/await,提升代码运行效率,同时将整个异步的任务处理的很巧妙。 toUnloadPromise
和toUnmountPromise
后文详述。
# getAppNames
获取所有应用名称。
export function getAppNames() {
return apps.map(toName);
}
2
3
# getMountedApps
getMountedApps 获取已经 mount 的应用。
export function isActive(app) {
return app.status === MOUNTED;
}
export function getMountedApps() {
return apps.filter(isActive).map(toName);
}
2
3
4
5
6
# getAppStatus
获取应用的状态。
export function getAppStatus(appName) {
const app = find(apps, (app) => toName(app) === appName);
return app ? app.status : null;
}
2
3
4
# checkActivityFunctions
获取当前匹配到路由的应用名称。
export function checkActivityFunctions(location = window.location) {
return apps.filter((app) => app.activeWhen(location)).map(toName);
}
2
3
# pathToActiveWhen
将 path(正则或者 string) 转换成 activity function(ActiveWhen)。
ActiveWhen 支持 ActivityFunction,也支持直接传入 path,如果直接传入 path,需要内部转换为 Activity Function。
export function pathToActiveWhen(path, exactMatch) {
// 将 path 转换为正则
const regex = toDynamicPathValidatorRegex(path, exactMatch);
// 返回 Activity function
return (location) => {
// compatible with IE10
let origin = location.origin;
if (!origin) {
origin = `${location.protocol}//${location.host}`;
}
// 链接去除 origin、search string
const route = location.href
.replace(origin, "")
.replace(location.search, "")
.split("?")[0];
return regex.test(route);
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
下面我们来看下,如何将 path 转换为路由匹配正则表达式。
# toDynamicPathValidatorRegex
function toDynamicPathValidatorRegex(path, exactMatch) {
let lastIndex = 0,
inDynamic = false,
regexStr = "^";
// 无论是正则还是string 路径,都应该以 / 开头
if (path[0] !== "/") {
path = "/" + path;
}
// 解析 path
for (let charIndex = 0; charIndex < path.length; charIndex++) {
const char = path[charIndex];
const startOfDynamic = !inDynamic && char === ":";
const endOfDynamic = inDynamic && char === "/";
if (startOfDynamic || endOfDynamic) {
appendToRegex(charIndex);
}
}
appendToRegex(path.length);
return new RegExp(regexStr, "i");
function appendToRegex(index) {
const anyCharMaybeTrailingSlashRegex = "[^/]+/?";
const commonStringSubPath = escapeStrRegex(path.slice(lastIndex, index));
regexStr += inDynamic
? anyCharMaybeTrailingSlashRegex
: commonStringSubPath;
// path 解析完毕,根据 exactMatch 补全正则
if (index === path.length) {
if (inDynamic) {
if (exactMatch) {
// Ensure exact match paths that end in a dynamic portion don't match
// urls with characters after a slash after the dynamic portion.
regexStr += "$";
}
} else {
// For exact matches, expect no more characters. Otherwise, allow
// any characters.
const suffix = exactMatch ? "" : ".*";
regexStr =
// use charAt instead as we could not use es6 method endsWith
regexStr.charAt(regexStr.length - 1) === "/"
? `${regexStr}${suffix}$`
: `${regexStr}(/${suffix})?(#.*)?$`;
}
}
inDynamic = !inDynamic;
lastIndex = index;
}
// escape 正则中的特殊符号
function escapeStrRegex(str) {
// borrowed from https://github.com/sindresorhus/escape-string-regexp/blob/master/index.js
return str.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
这段代码将 string 或者正则的 path 转换成正则表达式,同时支持精确匹配和开头匹配。
# getAppChanges
getAppChanges 根据路由的变化和应用的状态,对应用执行相应的变化。当路由改变后,getAppChanges 将重新评估应用的行为,将之区分为 appsToUnload
, appsToUnmount
, appsToLoad
, appsToMount
四类。
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) => {
// 判断应用是否应该被路由匹配上,即 active。
const appShouldBeActive =
app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);
switch (app.status) {
case LOAD_ERROR:
// 如果应用 load 失败,但应该被路由匹配,且失败超时 200 ms,则尝试重新排队 load 应用
if (appShouldBeActive && currentTime - app.loadErrorTime >= 200) {
appsToLoad.push(app);
}
break;
case NOT_LOADED:
case LOADING_SOURCE_CODE:
// 如果应用没有 load 或者正在 load 源码,而应用被匹配上了,则排队 load 应用
if (appShouldBeActive) {
appsToLoad.push(app);
}
break;
case NOT_BOOTSTRAPPED:
case NOT_MOUNTED:
// 如果应用还没有 bootstrap 或者 mount,应用未被路由匹配,且应用正在排队 unload,则排队 unload 应用
if (!appShouldBeActive && getAppUnloadInfo(toName(app))) {
appsToUnload.push(app);
} else if (appShouldBeActive) {
// 如果应用还没有 bootstrap 或者 mount,但是应用被匹配上,则排列 mount 应用
appsToMount.push(app);
}
break;
case MOUNTED:
// 如果应用已经 mount,没有被路由匹配,则排列 unmount 应用。
if (!appShouldBeActive) {
appsToUnmount.push(app);
}
break;
// all other statuses are ignored
}
});
return { appsToUnload, appsToUnmount, appsToLoad, appsToMount };
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
这个函数是针对路由变化执行的,此函数在 reroute
中执行。总结起来就是,路由变化时应用的行为应该发生相应的变化,很像是状态模式的思想。路由捕获到匹配时:load 错误超时的应用重试、未 load 完成的尽快 load;路由失去匹配时,已经 unmount 的应用尽快 unload、尚未 unmount 的应用 尽快 unmount。
这里体现出应用生命周期状态的一些细节:
// registered
export const NOT_LOADED = "NOT_LOADED";
// load
export const LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE";
export const NOT_BOOTSTRAPPED = "NOT_BOOTSTRAPPED";
// bootstrap
export const BOOTSTRAPPING = "BOOTSTRAPPING";
export const NOT_MOUNTED = "NOT_MOUNTED";
// mount
export const MOUNTING = "MOUNTING";
export const MOUNTED = "MOUNTED";
// update
export const UPDATING = "UPDATING";
// unmount
export const UNMOUNTING = "UNMOUNTING";
// unload
export const UNLOADING = "UNLOADING";
// error
export const LOAD_ERROR = "LOAD_ERROR";
export const SKIP_BECAUSE_BROKEN = "SKIP_BECAUSE_BROKEN";
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
这样上面的状态的变化就一目了然了。
# globalTimeoutConfig
globalTimeoutConfig 对应用生命周期的超时时间做管理。
// src/applications/timeouts.js
const globalTimeoutConfig = {
bootstrap: {
millis: 4000, // 超时时间
dieOnTimeout: false, // 是否在 timeout 时放弃重试
warningMillis: defaultWarningMillis, // 多长时间未成功就 warning
},
mount: {
millis: 3000,
dieOnTimeout: false,
warningMillis: defaultWarningMillis,
},
unmount: {
millis: 3000,
dieOnTimeout: false,
warningMillis: defaultWarningMillis,
},
unload: {
millis: 3000,
dieOnTimeout: false,
warningMillis: defaultWarningMillis,
},
update: {
millis: 3000,
dieOnTimeout: false,
warningMillis: defaultWarningMillis,
},
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
除此之外,sspa 支持用户修改 timeout 的配置,如:
export function setBootstrapMaxTime(time, dieOnTimeout, warningMillis) {
// 参数校验
if (typeof time !== "number" || time <= 0) {
// ......
}
globalTimeoutConfig.bootstrap = {
millis: time,
dieOnTimeout,
warningMillis: warningMillis || defaultWarningMillis,
};
}
2
3
4
5
6
7
8
9
10
11
12
将 timeout 分度分离出来,并且提供 timeout 的配置并在应用的生命周期中进行注入,这种写法值得借鉴。将生命周期中的模块与生命周期本身分别开来,以注入的方式进行功能加强,这很类似于装饰器模式的思想。
# start
参数 | 描述 |
---|---|
urlRerouteOnly | A boolean that defaults to false. If set to true, calls to history.pushState () and history.replaceState () will not trigger a single-spa reroute unless the client side route was changed. Setting this to true can be better for performance in some situations. 设置为 true, history.pushState 和 history.replaceState 将不会触发 reroute。用于性能考量。 |
// src/start.js
let started = false;
export function start(opts) {
started = true;
if (opts && opts.urlRerouteOnly) {
// 设置 urlRerouteOnly
setUrlRerouteOnly(opts.urlRerouteOnly);
}
if (isInBrowser) {
// 调整路由监听
reroute();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
为什么需要 start 函数?
Must be called by your single spa config. Before start is called, applications will be loaded, but will never be bootstrapped, mounted or unmounted. The reason for start is to give you control over the performance of your single page application. 【参考 Applications API | single-spa (opens new window)】