核心 API
# 目录
注意
- s-spa:文章中对 single-spa 的简称。
- 微应用:框架所管理的微服务子应用。
- 主程序:框架主程序本身。
- 文件路径:src/apis.ts
# registerMicroApps
注册微应用。
// 已经注册的子应用
let microApps: Array<RegistrableApp<Record<string, unknown>>> = [];
// 提供数组配置子应用的方法,内部直接调用 S-Spa.registerApplication 注册子应用,加强了部分功能,如 loader
// https://qiankun.umijs.org/zh/api#registermicroappsapps-lifecycles
export function registerMicroApps<T extends ObjectType>(
apps: Array<RegistrableApp<T>>,
lifeCycles?: FrameworkLifeCycles<T>,
) {
// Each app only needs to be registered once
// registeredApp name 具有唯一性,过滤掉重名 registeredApp
const unregisteredApps = apps.filter((app) => !microApps.some((registeredApp) => registeredApp.name === app.name));
microApps = [...microApps, ...unregisteredApps];
unregisteredApps.forEach((app) => {
const { name, activeRule, loader = noop, props, ...appConfig } = app;
// 为没有注册的配置注册应用,调用 single-spa
registerApplication({
name,
// app 参数中是 Loading Function or Application
// see https://single-spa.js.org/docs/configuration#registering-applications
app: async () => {
loader(true);
// 等待应用启动,即是 frameworkStartedDefer.promise 被 resolve
await frameworkStartedDefer.promise;
// 挂载子应用,获得子应用的钩子和配置
// loadApp 是在应用启动(start 函数)之后执行的,但是再次之前在 s-spa 中已经注册了应用
const {
mount,
...otherMicroAppConfigs
} = // frameworkConfiguration 是 start 中 merge 而成
(await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles))();
return {
// 在 single-spa 中注册子应用,这是返回给 single-spa 的配置参数
// 此处 loader 是包裹在 mount 函数前后的钩子函数
mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
...otherMicroAppConfigs,
};
},
// Activity function
activeWhen: activeRule,
// Custom props
customProps: props,
});
});
}
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
这里 loadApp 等待 start () 执行完毕在执行,可以看下这里是如何处理的?
const frameworkStartedDefer = new Deferred<void>();
export class Deferred<T> {
promise: Promise<T>;
resolve!: (value: T | PromiseLike<T>) => void;
reject!: (reason?: any) => void;
constructor() {
this.promise = new Promise((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
这种方法还是很巧妙的。利用 Promise 来实现程序等待,可以把很多需要 promise.then 的程序代码进行解耦。
# start
registerMicroApps 的注册的微应用还需要进一步触发 start,并且进行一系列的应用配置。注册和开启应用分离,这也是为了提高性能考虑,这也是 single-spa 的逻辑。原因如下:
The start() api must be called by your single spa config in order for applications to actually be mounted. Before start is called, applications will be loaded, but not bootstrapped/mounted/unmounted. The reason for start is to give you control over performance.
更多详细原因参考:Calling singleSpa.start() (opens new window)
// 全局的框架配置
export let frameworkConfiguration: FrameworkConfiguration = {};
// 对 s-spa 的 startSingleSpa 方法进行包装,并且加入 prefetch、singular、sandbox、和 importEntry 的配置
// see https://qiankun.umijs.org/zh/api#startopts
// FrameworkConfiguration 包含 qiankun 独有的配置,s-spa的配置和 import-html-entry 的配置
export function start(opts: FrameworkConfiguration = {}) {
// 合并默认配置
// singular:单实例场景,单实例指的是同一时间只会渲染一个微应用
// frameworkConfiguration 被保存为全局变量,方便配置共享
frameworkConfiguration = { prefetch: true, singular: true, sandbox: true, ...opts };
const {
prefetch,
sandbox,
singular,
urlRerouteOnly = defaultUrlRerouteOnly,
// 导入 html 模板时的配置
...importEntryOpts
} = frameworkConfiguration;
// 开启预加载
if (prefetch) {
// 处理不同预加载策略
doPrefetchStrategy(microApps, prefetch, importEntryOpts);
}
// 对旧版本浏览器的配置进行优雅降级
frameworkConfiguration = autoDowngradeForLowVersionBrowser(frameworkConfiguration);
startSingleSpa({ urlRerouteOnly });
// 全局标记应用的启动
started = true;
// 通知已经在 s-spa 中注册的子应用,prefetch 的配置已经更新,可以继续运行
frameworkStartedDefer.resolve();
}
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
start 函数的主要作用是:对 s-spa 的 startSingleSpa 方法进行包装,并且加入 prefetch、singular、sandbox、和 importEntry 的配置。
doPrefetchStrategy 函数对不同的预加载策略进行处理,后文详述。
autoDowngradeForLowVersionBrowser 针对旧版本浏览器的配置进行优雅降级,我们来看看做了什么?
// 对旧版浏览器进行降级
const autoDowngradeForLowVersionBrowser = (configuration: FrameworkConfiguration): FrameworkConfiguration => {
const { sandbox, singular } = configuration;
if (sandbox) {
// 浏览器不支持 window.Proxy,因为 window 不能够被代码,所以切换到快照模式,对 window 做快照处理,loose
if (!window.Proxy) {
console.warn('[qiankun] Miss window.Proxy, proxySandbox will degenerate into snapshotSandbox');
// singular 是否为单实例场景,单实例指的是同一时间只会渲染一个微应用。
// 不支持 window.Proxy 的浏览器在运行多实例时可能有问题
if (singular === false) {
console.warn(
'[qiankun] Setting singular as false may cause unexpected behavior while your browser not support window.Proxy',
);
}
return { ...configuration, sandbox: typeof sandbox === 'object' ? { ...sandbox, loose: true } : { loose: true } };
}
}
return configuration;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
这里引出了 window.Proxy,我们先来简单了解一下:
The Proxy object enables you to create a proxy for another object, which can intercept and redefine fundamental operations for that object. Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。
在 Vue3 源码中这个重要的底层 API,这里不再赘述。
参考:MDN: Proxy (opens new window)
# loadMicroApp
除了 start 中自动开启应用并匹配路由挂载微应用,还可以通过 loadMicroApp 手动挂载微应用。这个函数相对来说较为复杂,代码如下:
// 应用实例缓存 Map<appContainerXPathKey, Promise<(container) => parcelConfig>>
const appConfigPromiseGetterMap = new Map<string, Promise<ParcelConfigObjectGetter>>();
// 微应用列表的缓存
const containerMicroAppsMap = new Map<string, MicroApp[]>();
// 手动加载一个微应用,通常这种场景下微应用是一个不带路由的可独立运行的业务组件。
// see https://qiankun.umijs.org/zh/api#loadmicroappapp-configuration
// 了解微应用的类型,see https://single-spa.js.org/docs/module-types
export function loadMicroApp<T extends ObjectType>(
app: LoadableApp<T>,
configuration?: FrameworkConfiguration & { autoStart?: boolean },
lifeCycles?: FrameworkLifeCycles<T>,
): MicroApp {
const { props, name } = app;
const container = 'container' in app ? app.container : undefined;
// Must compute the container xpath at beginning to keep it consist around app running
// If we compute it every time, the container dom structure most probably been changed and result in a different xpath value
// 计算 container 的 XPath 路径
// 必须在加载应用之前计算,因为之后容器的 DOM 结构可能会改变
// 使用 name + XPath 作为微应用在缓存的标志,必须保证此标志是不变的!所以只初始化一次
const containerXPath = getContainerXPath(container);
const appContainerXPathKey = `${name}-${containerXPath}`;
let microApp: MicroApp;
// 重新挂载相同的微应用,需要对容器中其他应用的挂载状态做容错
// 在重新挂载微应用之前,要保证所有之前的应用的 unmount 钩子都执行完毕
const wrapParcelConfigForRemount = (config: ParcelConfigObject): ParcelConfigObject => {
let microAppConfig = config;
if (container) {
if (containerXPath) {
// 获取所在 container 的缓存中的微应用列表
const containerMicroApps = containerMicroAppsMap.get(appContainerXPathKey);
if (containerMicroApps?.length) {
// 在 mount 钩子中添加额外的逻辑
const mount = [
async () => {
// While there are multiple micro apps mounted on the same container, we must wait until the prev instances all had unmounted
// Otherwise it will lead some concurrent issues
// 当前应用之前的已挂载的微应用
const prevLoadMicroApps = containerMicroApps.slice(0, containerMicroApps.indexOf(microApp));
// 过滤掉错误的微应用
const prevLoadMicroAppsWhichNotBroken = prevLoadMicroApps.filter(
(v) => v.getStatus() !== 'LOAD_ERROR' && v.getStatus() !== 'SKIP_BECAUSE_BROKEN',
);
// 将 prevLoadMicroAppsWhichNotBroken 所有微应用的 unmountPromise 收集起来,并等待所有的 unmountPromise 都已经 resolve,以免之前的应用实例没卸载干净
await Promise.all(prevLoadMicroAppsWhichNotBroken.map((v) => v.unmountPromise));
},
...toArray(microAppConfig.mount),
];
microAppConfig = {
...config,
mount,
};
}
}
}
return {
...microAppConfig,
// empty bootstrap hook which should not run twice while it calling from cached micro app
bootstrap: () => Promise.resolve(),
};
};
/**
* using name + container xpath as the micro app instance id,
* it means if you rendering a micro app to a dom which have been rendered before,
* the micro app would not load and evaluate its lifecycles again
*/
const memorizedLoadingFn = async (): Promise<ParcelConfigObject> => {
// 合并配置项,并且对旧浏览器优雅降级
const userConfiguration = autoDowngradeForLowVersionBrowser(
configuration ?? { ...frameworkConfiguration, singular: false },
);
// $$cacheLifecycleByAppName 是内部实验性的,默认为 false
const { $$cacheLifecycleByAppName } = userConfiguration;
if (container) {
// using appName as cache for internal experimental scenario
if ($$cacheLifecycleByAppName) {
const parcelConfigGetterPromise = appConfigPromiseGetterMap.get(name);
if (parcelConfigGetterPromise) return wrapParcelConfigForRemount((await parcelConfigGetterPromise)(container));
}
if (containerXPath) {
// 检查微应用是否在缓存中,如果在,直接取出应用实例
const parcelConfigGetterPromise = appConfigPromiseGetterMap.get(appContainerXPathKey);
// 重新挂载微应用之前需要确保不会与之前的微应用产生冲突
if (parcelConfigGetterPromise) return wrapParcelConfigForRemount((await parcelConfigGetterPromise)(container));
}
}
// loadApp 是一个 async 函数,parcelConfigObjectGetterPromise 是一个应用实例的 Promise
const parcelConfigObjectGetterPromise = loadApp(app, userConfiguration, lifeCycles);
if (container) {
if ($$cacheLifecycleByAppName) {
appConfigPromiseGetterMap.set(name, parcelConfigObjectGetterPromise);
} else if (containerXPath)
// 设置缓存
appConfigPromiseGetterMap.set(appContainerXPathKey, parcelConfigObjectGetterPromise);
}
// (await parcelConfigGetterPromise)(container) 返回 parcelConfig,这是传给 s-spa 的
return (await parcelConfigObjectGetterPromise)(container);
};
// 如果配置了自动启动,注意 loadMicroApp 之前所有的应用都应该注册过
// 这种情况通常不用于路由匹配模式
if (!started && configuration?.autoStart !== false) {
// We need to invoke start method of single-spa as the popstate event should be dispatched while the main app calling pushState/replaceState automatically,
// but in single-spa it will check the start status before it dispatch popstate
// github 页面链接可以定位到具体的代码行
// see https://github.com/single-spa/single-spa/blob/f28b5963be1484583a072c8145ac0b5a28d91235/src/navigation/navigation-events.js#L101
// ref https://github.com/umijs/qiankun/pull/1071
// 需要调用 startSingleSpa 是因为 single-spa 在代理 popstate 之前需要检查主应用是否 start,但是只需要调用一次即可
// 虽然 parcel 微应用不太需要监听路由变化,但是需要监听 popState 以自动卸载应用
startSingleSpa({ urlRerouteOnly: frameworkConfiguration.urlRerouteOnly ?? defaultUrlRerouteOnly });
}
// 手动挂载 parcel 微应用不需要匹配路由挂载,需要手动挂载到容器,这里调用 s-spa.mountRootParcel
// see https://single-spa.js.org/docs/parcels-api#mountrootparcel
// microApp 为 Parcel object
microApp = mountRootParcel(memorizedLoadingFn, { domElement: document.createElement('div'), ...props });
// 在 mount 时将微应用添加到应用队列,并且在 unmount 时删除微应用
if (container) {
if (containerXPath) {
// Store the microApps which they mounted on the same container
// 取出缓存中当前 container 中的微应用列表
const microAppsRef = containerMicroAppsMap.get(appContainerXPathKey) || [];
// 将当前已经挂载的微应用加入到微应用列表中
microAppsRef.push(microApp);
containerMicroAppsMap.set(appContainerXPathKey, microAppsRef);
const cleanup = () => {
// 在微任务列表中删除当前应用
const index = microAppsRef.indexOf(microApp);
microAppsRef.splice(index, 1);
// @ts-ignore
microApp = null;
};
// gc after unmount
// 挂载卸载的钩子函数,在微任务卸载时执行 GC 函数
microApp.unmountPromise.then(cleanup).catch(cleanup);
}
}
return microApp;
}
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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
loadMicroApp 中最重要的重要是调用 mountRootParcel 来手动挂载微应用,并且在 s-spa 的上层做了一层缓存层。包括对 AppLoading 函数的缓存和微应用的缓存。其次比较重要的是如果程序还没有启动且配置了 autoStart 就调用 startSingleSpa 来启动主程序。
# 一些技巧
# TS 中,如何添加互斥的属性?
ts 添加排他性(互斥)的属性,如 render 和 container 是互斥的。
// just for manual loaded apps, in single-spa it called parcel
export type LoadableApp<T extends ObjectType> = AppMetadata & {
/* props pass through to app */ props?: T;
} & (
| {
// legacy mode, the render function all handled by user
render: HTMLContentRender;
}
| {
// where the app mount to, mutual exclusive with the legacy custom render function
container: string | HTMLElement;
}
);
2
3
4
5
6
7
8
9
10
11
12
13
# 如何计算计算元素在 DOM 文档中的 XPath 路径?
export function getContainer(container: string | HTMLElement): HTMLElement | null {
return typeof container === 'string' ? document.querySelector(container) : container;
}
export function getContainerXPath(container?: string | HTMLElement): string | void {
if (container) {
// 获取容器 DOM 元素
const containerElement = getContainer(container);
if (containerElement) {
return getXPathForElement(containerElement, document);
}
}
return undefined;
}
/**
* 计算元素在 DOM 文档中的 XPath 路径
* copy from https://developer.mozilla.org/zh-CN/docs/Using_XPath
* @param el
* @param document
*/
export function getXPathForElement(el: Node, document: Document): string | void {
// not support that if el not existed in document yet(such as it not append to document before it mounted)
// 元素不在 document 中
if (!document.body.contains(el)) {
return undefined;
}
let xpath = '';
let pos;
let tmpEle;
let element = el;
while (element !== document.documentElement) {
pos = 0;
tmpEle = element;
while (tmpEle) {
if (tmpEle.nodeType === 1 && tmpEle.nodeName === element.nodeName) {
// If it is ELEMENT_NODE of the same name
pos += 1;
}
tmpEle = tmpEle.previousSibling;
}
xpath = `*[name()='${element.nodeName}'][${pos}]/${xpath}`;
element = element.parentNode!;
}
xpath = `/*[name()='${document.documentElement.nodeName}']/${xpath}`;
xpath = xpath.replace(/\/$/, '');
return xpath;
}
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