loadApp 加载微应用
# 目录
# loadApp
在 核心 API 中已经分析过,loadApp 这个函数会在 registerMicroApps、loadMicroApp 两个函数中调用。需要注意的是,需要保证 loadApp 在主程序 start 执行之后在执行,从 s-spa 的角度来说也就是 startSingleSpa 之后执行。主程序的 start 并不是一定得在注册微应用之后立即开启,主程序 start 的机会有两个,分别是 start 函数和 loadMicroApp 函数。
// src/loader.ts
export async function loadApp<T extends ObjectType>(
// 微应用配置
app: LoadableApp<T>,
// 主程序配置
configuration: FrameworkConfiguration = {},
// 主程序生命周期钩子
lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
const { entry, name: appName } = app;
// 通过微应用名称找到微应用实例 id
const appInstanceId = genAppInstanceIdByName(appName);
const {
singular = false,
sandbox = true,
excludeAssetFilter,
globalContext = window,
...importEntryOpts
} = configuration;
// 通过 import-html-entry.importEntry 获取到模板,entry 是微应用的地址,importEntryOpts 是获取模板时的配置
// get the entry html content and script executor
// template: Processed HTML template.
// execScripts:(sandbox?: object, strictGlobal?: boolean, execScriptsHooks?: ExecScriptsHooks): Promise<unknown> - the return value is the last property on window or proxy window which set by the entry script.
// assetPublicPath:Public path for assets.
// see https://github.com/kuitos/import-html-entry#execscriptsentry-scripts-proxy-opts
const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);
// as single-spa load and bootstrap new app parallel with other apps unmounting
// (see https://github.com/CanopyTax/single-spa/blob/master/src/navigation/reroute.js#L74)
// we need wait to load the app until all apps are finishing unmount in singular mode
// singular 支持传函数,所以这里判断最终是否开启 singular
if (await validateSingularMode(singular, app)) {
// singular模式中,由于 s-spa 中加载新应用和卸载其他应用时同时进行的,所以需要等其他应用卸载完毕
await (prevAppUnmountedDeferred && prevAppUnmountedDeferred.promise);
}
// 生成即将要插入到容器中的结构,注意这里使用了 Curry Function
// see https://jonsam.site/2021/09/01/js-tricks1/ Curry Function
const appContent = getDefaultTplWrapper(appInstanceId)(template);
// 是否配置了 strictStyleIsolation,如果配置为 true,则使用 ShadowDOM
// 这种方法有较大的局限性,see https://qiankun.umijs.org/zh/api#startopts sandbox
const strictStyleIsolation = typeof sandbox === 'object' && !!sandbox.strictStyleIsolation;
// 是否使用 experimentalStyleIsolation(基于样式前缀),而非 shadowDOM 的样式沙箱
const scopedCSS = isEnableScopedCSS(sandbox);
// 创建待插入的DOM节点元素
let initialAppWrapperElement: HTMLElement | null = createElement(
appContent,
strictStyleIsolation,
scopedCSS,
appInstanceId,
);
const initialContainer = 'container' in app ? app.container : undefined;
const legacyRender = 'render' in app ? app.render : undefined;
// 获取渲染策略
const render = getRender(appInstanceId, appContent, legacyRender);
// 第一次加载设置应用可见区域 dom 结构
// 确保每次应用加载前容器 dom 结构已经设置完毕
// 渲染模板文件到容器中,初始化微应用的 DOM 结构
render({ element: initialAppWrapperElement, loading: true, container: initialContainer }, 'loading');
// 生成获取微应用节点的方法
const initialAppWrapperGetter = getAppWrapperGetter(
appInstanceId,
!!legacyRender,
strictStyleIsolation,
scopedCSS,
() => initialAppWrapperElement,
);
// 拷贝 globalContext,以免污染 window
let global = globalContext;
let mountSandbox = () => Promise.resolve();
let unmountSandbox = () => Promise.resolve();
// 是否使用 loose 模式的沙箱
const useLooseSandbox = typeof sandbox === 'object' && !!sandbox.loose;
let sandboxContainer;
if (sandbox) {
// 创建沙箱容器
sandboxContainer = createSandboxContainer(
appInstanceId,
// FIXME should use a strict sandbox logic while remount, see https://github.com/umijs/qiankun/issues/518
initialAppWrapperGetter,
scopedCSS,
useLooseSandbox,
// 沙箱资源白名单过滤器
excludeAssetFilter,
global,
);
// 获取到沙箱中的全局代理对象,mount 方法和 unmount 方法
// 用沙箱的代理对象作为接下来使用的全局对象
global = sandboxContainer.instance.proxy as typeof window;
mountSandbox = sandboxContainer.mount;
unmountSandbox = sandboxContainer.unmount;
}
const { beforeUnmount = [], afterUnmount = [], afterMount = [], beforeMount = [], beforeLoad = [] } =
// mergeWith: This method is like _.merge except that it accepts customizer which is invoked to produce the merged values of the destination and source properties. If customizer returns undefined merging is handled by the method instead.
// see https://lodash.com/docs/4.17.15#mergeWith
// concat: Creates a new array concatenating array with any additional arrays and/or values.
// 合并内部插件中使用的生命周期和用户传入的生命周期回调
mergeWith({}, getAddOns(global, assetPublicPath), lifeCycles, (v1, v2) => concat(v1 ?? [], v2 ?? []));
// 执行所有 beforeLoad 的钩子链,因为当前处于 beforeLoad 阶段
await execHooksChain(toArray(beforeLoad), app, global);
// get the lifecycle hooks from module exports
// 执行模板中的 js 文件,执行 js 的沙箱为 global,如果使用 lose 模式的沙箱就不使用严格的 js 沙箱。
// scriptExports 为执行 js 之后的结果,微应用中的 js 通常打包为 umd
// 如果代码首次执行提供了沙箱,之后代码在运行时中都会在沙箱中运行
const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox);
// 从微应用中获取到相关的钩子函数
const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
scriptExports,
appName,
global,
// 之所以要使用 沙箱上的latestSetProp,是因为 execScripts 可能会在得到入口脚本的执行过程中将结果设置到 global 上,如果是代理沙箱,这个行为可以被记录下来,存放到 instance?.latestSetProp 上。参考 UMD 模块化。
sandboxContainer?.instance?.latestSetProp,
);
// 工厂方法:创建当前微应用的全局状态依赖管理工具函数,所有微应用的全局依赖存放在 globalState 文件中的 deps 中。
// 这里通过 appInstanceId 为当前微应用创建单独的依赖工具是利用工厂模式提升运行效率,同时可以隐藏 appInstanceId,尤其是暴露给外界 global 的id,体现其封装性
const { onGlobalStateChange, setGlobalState, offGlobalStateChange }: Record<string, CallableFunction> =
getMicroAppStateActions(appInstanceId);
// FIXME temporary way
const syncAppWrapperElement2Sandbox = (element: HTMLElement | null) => (initialAppWrapperElement = element);
// container 是可选的,有默认值
// loadApp 返回 parcelConfigGetter,通过 container 动态创建传递给 s-spa 的配置。
// 这里借鉴了 curry function 的思想
const parcelConfigGetter: ParcelConfigObjectGetter = (remountContainer = initialContainer) => {
let appWrapperElement: HTMLElement | null;
let appWrapperGetter: ReturnType<typeof getAppWrapperGetter>;
// 动态创建 s-spa.mountRootParcel 的配置
const parcelConfig: ParcelConfigObject = {
name: appInstanceId,
// bootstrap, mount, unmount, update 是从微应用中获取到的钩子函数
bootstrap,
// 注意这里 mount 中各个钩子函数都是同步执行的(async),这是为了保证钩子的执行顺序,同时也是因为 s-spa 无法确保外部的钩子是同步还是异步的,所以统一使用(await)处理。
mount: [
// 如果是 singular 模式,等待之前的微应用卸载完毕
async () => {
if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
return prevAppUnmountedDeferred.promise;
}
return undefined;
},
// initial wrapper element before app mount/remount
// 重新初始化内部的容器节点和容器 getter 方法
async () => {
appWrapperElement = initialAppWrapperElement;
appWrapperGetter = getAppWrapperGetter(
appInstanceId,
!!legacyRender,
strictStyleIsolation,
scopedCSS,
() => appWrapperElement,
);
},
// 添加 mount hook, 确保每次应用加载前容器 dom 结构已经设置完毕
async () => {
const useNewContainer = remountContainer !== initialContainer;
// 如果容器节点不存在,可能是已经被卸载了,则重新创建容器节点,并且缓存到 initialAppWrapperElement
if (useNewContainer || !appWrapperElement) {
// element will be destroyed after unmounted, we need to recreate it if it not exist
// or we try to remount into a new container
appWrapperElement = createElement(appContent, strictStyleIsolation, scopedCSS, appInstanceId);
syncAppWrapperElement2Sandbox(appWrapperElement);
}
// 确保模板中 dom 结构已经渲染完毕,状态为 mounting
render({ element: appWrapperElement, loading: true, container: remountContainer }, 'mounting');
},
// 执行沙箱中的 mount 钩子,初始化沙箱环境
mountSandbox,
// 执行插件和用户自定义的生命周期钩子 beforeMount
// exec the chain after rendering to keep the behavior with beforeLoad
async () => execHooksChain(toArray(beforeMount), app, global),
// 收集 s-spa 的回调参数和内部的参数,传递给微应用的 mount 钩子,执行微应用的 mount 钩子,注意这里的 setGlobalState 和 onGlobalStateChange 是当前微应用对全局状态监听的副作用
async (props) => mount({ ...props, container: appWrapperGetter(), setGlobalState, onGlobalStateChange }),
// finish loading after app mounted
// 渲染模板 DOM, 状态为 mounted
async () => render({ element: appWrapperElement, loading: false, container: remountContainer }, 'mounted'),
// 执行插件和用户自定义的生命周期钩子 afterMount
async () => execHooksChain(toArray(afterMount), app, global),
// initialize the unmount defer after app mounted and resolve the defer after it unmounted
async () => {
if (await validateSingularMode(singular, app)) {
// 应用已经 mount,初始化 prevAppUnmountedDeferred
prevAppUnmountedDeferred = new Deferred<void>();
}
},
],
unmount: [
// 执行插件和用户自定义的生命周期钩子 beforeUnmount
async () => execHooksChain(toArray(beforeUnmount), app, global),
// 收集属性调用微应用的钩子 unmount
async (props) => unmount({ ...props, container: appWrapperGetter() }),
// 卸载沙箱
unmountSandbox,
// 执行插件和用户自定义的生命周期钩子 afterUnmount
async () => execHooksChain(toArray(afterUnmount), app, global),
async () => {
// 渲染模板,状态为 unmounted
render({ element: null, loading: false, container: remountContainer }, 'unmounted');
// 卸载全局状态监听effect
offGlobalStateChange(appInstanceId);
// 重置 appWrapperElement 和 initialAppWrapperElement
// for gc
appWrapperElement = null;
syncAppWrapperElement2Sandbox(appWrapperElement);
},
async () => {
if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
// unmount 执行完毕,resolve prevAppUnmountedDeferred
prevAppUnmountedDeferred.resolve();
}
},
],
};
// 如果需要能支持主应用手动 update 微应用,需要微应用 entry 再多导出一个 update 钩子
if (typeof update === 'function') {
parcelConfig.update = update;
}
return parcelConfig;
};
return parcelConfigGetter;
}
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
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
函数的核心作用:
- 获取模板、渲染模板节点,执行模板脚本获取微应用生命周期钩子,并且在 s-spa 的生命周期中调用。
- 初始化沙箱,并且在微应用 mount 和 unmount 时挂载和卸载沙箱。
- 维护微应用的生命周期、执行插件在生命周期的回调和用户自定义的生命周期的回调。
- 收集微应用监听全局状态的 effect。
# createElement
createElement 创建模板节点。
const supportShadowDOM = document.head.attachShadow || document.head.createShadowRoot;
function createElement(
appContent: string,
strictStyleIsolation: boolean,
scopedCSS: boolean,
appInstanceId: string,
): HTMLElement {
const containerElement = document.createElement('div');
containerElement.innerHTML = appContent;
// appContent always wrapped with a singular div
const appElement = containerElement.firstChild as HTMLElement;
if (strictStyleIsolation) {
if (!supportShadowDOM) {
console.warn(
'[qiankun]: As current browser not support shadow dom, your strictStyleIsolation configuration will be ignored!',
);
} else {
const { innerHTML } = appElement;
appElement.innerHTML = '';
let shadow: ShadowRoot;
// attachShadow 是 shadowDOM v1 规范
if (appElement.attachShadow) {
// 使用 shadowDOM
shadow = appElement.attachShadow({ mode: 'open' });
} else {
// createShadowRoot 是 shadowDOM v0 规范,已经废弃
// createShadowRoot was proposed in initial spec, which has then been deprecated
shadow = (appElement as any).createShadowRoot();
}
shadow.innerHTML = innerHTML;
}
}
if (scopedCSS) {
const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr);
if (!attr) {
appElement.setAttribute(css.QiankunCSSRewriteAttr, appInstanceId);
}
// 如果采用了 scopedCSS,获取 appElement 上所有的样式并且加上样式前缀
const styleNodes = appElement.querySelectorAll('style') || [];
forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
css.process(appElement!, stylesheetElement, appInstanceId);
});
}
return appElement;
}
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
- 配置了 strictStyleIsolation,使用 shadowDOM 隔离样式。
- 配置了 scopedCSS,使用样式前缀隔离样式。
# execHooksChain
execHooksChain 执行钩子集合。
function execHooksChain<T extends ObjectType>(
hooks: Array<LifeCycleFn<T>>,
app: LoadableApp<T>,
global = window,
): Promise<any> {
if (hooks.length) {
return hooks.reduce((chain, hook) => chain.then(() => hook(app, global)), Promise.resolve());
}
return Promise.resolve();
}
2
3
4
5
6
7
8
9
10
11
execHooksChain 执行链式的返回 Promise 的钩子函数,这里用的很巧妙,初始化为空的 Promise,在 Promise.then 中执行 hook。 Promise.resolve().then(() => {})
返回一个 Promise,促使 reduce 继续执行。而且,每个 hook 都在微任务中执行,知道最后一个 hook 执行返回 promise。
/**
* Represents the completion of an asynchronous operation
*/
interface Promise<T> {
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
* @param onrejected The callback to execute when the Promise is rejected.
* @returns A Promise for the completion of which ever callback is executed.
*/
then<TResult1 = T, TResult2 = never>(onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | undefined | null, onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | undefined | null): Promise<TResult1 | TResult2>;
/**
* Attaches a callback for only the rejection of the Promise.
* @param onrejected The callback to execute when the Promise is rejected.
* @returns A Promise for the completion of the callback.
*/
catch<TResult = never>(onrejected?: ((reason: any) => TResult | PromiseLike<TResult>) | undefined | null): Promise<T | TResult>;
/**
* Attaches a callback that is invoked when the Promise is settled (fulfilled or rejected). The
* resolved value cannot be modified from the callback.
* @param onfinally The callback to execute when the Promise is settled (fulfilled or rejected).
* @returns A Promise for the completion of the callback.
*/
finally(onfinally?: (() => void) | undefined | null): Promise<T | TResult>
}
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
以上是 Promise 类型定义,then、catch、finally 返回的都是 Promise。
# getRender
获取渲染模板 DOM 节点到容器的方法。
const rawAppendChild = HTMLElement.prototype.appendChild;
const rawRemoveChild = HTMLElement.prototype.removeChild;
/**
* Get the render function
* If the legacy render function is provide, used as it, otherwise we will insert the app element to target container by qiankun
* @param appInstanceId
* @param appContent
* @param legacyRender
*/
function getRender(appInstanceId: string, appContent: string, legacyRender?: HTMLContentRender) {
const render: ElementRender = ({ element, loading, container }, phase) => {
if (legacyRender) {
return legacyRender({ loading, appContent: element ? appContent : '' });
}
// 获取外部配置的要挂载的容器
const containerElement = getContainer(container!);
// The container might have be removed after micro app unmounted.
// Such as the micro app unmount lifecycle called by a react componentWillUnmount lifecycle, after micro app unmounted, the react component might also be removed
if (phase !== 'unmounted') {
// 获取不同状态的报错信息
const errorMsg = (() => {
switch (phase) {
case 'loading':
case 'mounting':
return `Target container with ${container} not existed while ${appInstanceId} ${phase}!`;
case 'mounted':
return `Target container with ${container} not existed after ${appInstanceId} ${phase}!`;
default:
return `Target container with ${container} not existed while ${appInstanceId} rendering!`;
}
})();
assertElementExist(containerElement, errorMsg);
}
if (containerElement && !containerElement.contains(element)) {
// 清空容器
// clear the container
while (containerElement!.firstChild) {
rawRemoveChild.call(containerElement, containerElement!.firstChild);
}
// 插入元素
// append the element to container if it exist
if (element) {
rawAppendChild.call(containerElement, element);
}
}
return undefined;
};
return render;
}
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
# getLifecyclesFromExports
getLifecyclesFromExports 函数从微应用 js 的执行结果里尝试获取生命周期函数。
/** 校验子应用导出的 生命周期 对象是否正确 */
export function validateExportLifecycle(exports: any) {
const { bootstrap, mount, unmount } = exports ?? {};
return isFunction(bootstrap) && isFunction(mount) && isFunction(unmount);
}
function getLifecyclesFromExports(
scriptExports: LifeCycles<any>,
appName: string,
global: WindowProxy,
globalLatestSetProp?: PropertyKey | null,
) {
if (validateExportLifecycle(scriptExports)) {
return scriptExports;
}
// fallback to sandbox latest set property if it had
if (globalLatestSetProp) {
const lifecycles = (<any>global)[globalLatestSetProp];
if (validateExportLifecycle(lifecycles)) {
return lifecycles;
}
}
// fallback to global variable who named with ${appName} while module exports not found
const globalVariableExports = (global as any)[appName];
if (validateExportLifecycle(globalVariableExports)) {
return globalVariableExports;
}
throw new QiankunError(`You need to export lifecycle functions in ${appName} entry`);
}
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
三种尝试分别为 scriptExports、 global[sandboxContainer?.instance?.latestSetProp]
和 global[appName]
。
# 扩展
# UMD
在 getLifecyclesFromExports 中,我们知道了 qiankun 会从三中情况获取微应用 js 执行结果的 生命周期钩子,这个步骤至关重要,因为 k 框架本身依赖微应用本身的 mount、unmount 钩子挂载和卸载微应用,如 React 中的 ReactDOM.render
和 Vue3 中的 createApp().mount
。在打包工具中,通常将输出 js 格式设置为 UMD 格式。
UMD (Universal Module Definition), 希望提供一个前后端跨平台的解决方案 (支持 AMD 与 CommonJS 模块方式)。
实现原理:
- 先判断是否支持 Node.js 模块格式(exports 是否存在),存在则使用 Node.js 模块格式。
- 再判断是否支持 AMD(define 是否存在),存在则使用 AMD 方式加载模块。
- 前两个都不存在,则将模块公开到全局(window 或 global)。
各种具体的实现方式,可以查看 UMD 的 github。我这里举例一个 jQuery 使用的,按照如上方式实现的代码:
// if the module has no dependencies, the above pattern can be simplified to
(function (root, factory) {
// AMD
if (typeof define === 'function' && define.amd) {
// AMD. Register as an anonymous module.
define([], factory);
// CJS
} else if (typeof exports === 'object') {
// Node. Does not work with strict CommonJS, but
// only CommonJS-like environments that support module.exports,
// like Node.
module.exports = factory();
} else {
// Global
// Browser globals (root is window)
root.returnExports = factory();
}
}(this, function () {
// Just return a value to define the module export.
// This example returns an object, but the module
// can return a function as the exported value.
return {};
}));
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
这里使用了 DI(依赖注入模式)的思想。
参考:
# 一些技巧
# 生成唯一 id 的思路
一般对于需要创建唯一 id 时,通常会生成一个随机数(UUID)或者哈希值,还有一种简单的方法类似于排队的领号,每个人领到的号都不一样。
// 获取原生的 window
export const nativeGlobal = new Function('return this')();
// 在 nativeGlobal 上 配置 __app_instance_name_map__, 这里返回 __app_instance_name_map__
// once: Creates a function that is restricted to invoking func once. Repeat calls to the function return the value of the first call. The func is invoked with the this binding and arguments of the created function.
// see https://www.lodashjs.com/docs/lodash.once
// app instance id generator compatible with nested sandbox
// genAppInstanceIdByName 同时调用多次时,可能会发生资源争夺,利用闭包让函数只执行一次,缓存执行的结果
const getGlobalAppInstanceMap = once<() => Record<string, number>>(() => {
if (!nativeGlobal.hasOwnProperty('__app_instance_name_map__')) {
Object.defineProperty(nativeGlobal, '__app_instance_name_map__', {
enumerable: false,
configurable: true,
writable: true,
value: {},
});
}
return nativeGlobal.__app_instance_name_map__;
});
/**
* 根据实例名称获取实例 id,内部计数自增
* Get app instance name with the auto-increment approach
* @param appName
*/
export const genAppInstanceIdByName = (appName: string): string => {
// 获取到应用实例缓存 map
const globalAppInstanceMap = getGlobalAppInstanceMap();
if (!(appName in globalAppInstanceMap)) {
nativeGlobal.__app_instance_name_map__[appName] = 0;
return appName;
}
globalAppInstanceMap[appName]++;
return `${appName}_${globalAppInstanceMap[appName]}`;
};
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
# 获取原生的 window
const window = new Function('return this')();
可以获取原生的 window,这里利用了构造函数创建的 Function 的一些特性。
由 Function 构造函数创建的函数不会创建当前环境的闭包,它们总是被创建于全局环境,因此在运行时它们只能访问全局变量和自己的局部变量,不能访问它们被 Function 构造函数创建时所在的作用域的变量。这一点与使用 eval () 执行创建函数的代码不同。
参考: