micro frontend framework Qiankun source code analysis

上一篇博客我们介绍了Qiankun的沙盒机制,也就是不同的子应用之间是如何做环境隔离的,这篇博客我们就基于上一篇博客讲一下如何利用沙盒去进行子应用的加载和切换。

Qiankun is based on single-spa implementation, all internal use of part of the single-spa interface, a brief introduction to the difference between single-spa and qiankun, single-spa only do sub-application registration, switching, routing monitoring, etc., has not yet reached a commercial level, and qiankun on top of this js and css sandbox isolation, according to the official doc, qiankun is to meet the commercial requirements.

globalState

I will add my own comments to the following code, but there are two points to note:

  • This global state, i.e. globalState, is unique, meaning that all child applications use the same global state object
  • But each sub-application can register its own callback function for the change of global state, and store it in the deps array with appInstanceId as the key. Although all deps will be triggered when the state changes, using appInstanceId as the key can be used in the sub-application. When destroyed, the callback function of the application is correctly cancelled.
1
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
/**
* @author dbkillerf6
* @since 2020-04-10
*/

import { cloneDeep } from 'lodash';
import type { OnGlobalStateChangeCallback, MicroAppStateActions } from './interfaces';

//Global state, shared by all sub-applications
let globalState: Record<string, any> = {};

//Callback function when the state changes, key is the id of the app, value is the callback function
const deps: Record<string, OnGlobalStateChangeCallback> = {};

//Trigger global listening, traverse all deps and trigger one by one
function emitGlobal(state: Record<string, any>, prevState: Record<string, any>) {
Object.keys(deps).forEach((id: string) => {
if (deps[id] instanceof Function) {
deps[id](cloneDeep(state), cloneDeep(prevState));
}
});
}

Expose an interface that provides a means to modify global state independent of sub-applications
export function initGlobalState(state: Record<string, any> = {}) {
if (process.env.NODE_ENV = 'development') {
console.warn(`[qiankun] globalState tools will be removed in 3.0, pls don't use it!`);
}

if (state = globalState) {
console.warn('[qiankun] state has not changed!');
} else {
const prevGlobalState = cloneDeep(globalState);
globalState = cloneDeep(state);
emitGlobal (globalState, prevGlobalState);
}
return getMicroAppStateActions(`global-${+new Date()}`, true);
}

export function getMicroAppStateActions(id: string, isMaster?: boolean): MicroAppStateActions {
return {
/**
* onGlobalStateChange global dependency listening
*
Dependencies that need to be triggered when collecting setState
*
* Restriction: Each sub-application has only one active global listener, the new listener overwrites the old listener, if only part of the listener properties, please use onGlobalStateChange
*
This design is to reduce memory explosion caused by global monitoring abuse
*
The dependent data structure is:
* {
* {id}: callback
* }
*
* @param callback
* @param fireImmediately
*/
onGlobalStateChange(callback: OnGlobalStateChangeCallback, fireImmediately?: boolean) {
if (!(callback instanceof Function)) {
console.error('[qiankun] callback must be function!');
return;
}
if (deps[id]) {
console.warn(`[qiankun] '${id}' global listener already exists before this, new listener will overwrite it.`);
}
deps[id] = callback;
if (fireImmediately) {
const cloneState = cloneDeep(globalState);
callback(cloneState, cloneState);
}
},

/**
* setGlobalState Update stored data
*
* 1. Verify the first-level properties of the input state. Only the first-level (bucket) properties declared during initialization will be changed
* 2. Modify the store and trigger global listening
*
* @param state
*/
setGlobalState(state: Record<string, any> = {}) {
if (state = globalState) {
console.warn('[qiankun] state has not changed!');
return false;
}

const changeKeys: string[] = [];
const prevGlobalState = cloneDeep(globalState);
globalState = cloneDeep(
Object.keys(state).reduce((_globalState, changeKey) => {
if (isMaster || _globalState.hasOwnProperty(changeKey)) {
changeKeys.push(changeKey);
return Object.assign(_globalState, { [changeKey]: state[changeKey] });
}
console.warn(`[qiankun] '${changeKey}' not declared when init state!`);
return _globalState;
}, globalState),
);
if (changeKeys.length = 0) {
console.warn('[qiankun] state has not changed!');
return false;
}
emitGlobal (globalState, prevGlobalState);
return true;
},

//Log off the dependencies under this app
offGlobalStateChange() {
delete deps[id];
return true;
},
};
}

prefetch

This is mainly to provide strategies and methods for preloading sub-applications

1
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
/**
* @author Kuitos
* @since 2019-02-26
*/

import type { Entry, ImportEntryOpts } from 'import-html-entry';
import { importEntry } from 'import-html-entry';
import { isFunction } from 'lodash';
import { getAppStatus, getMountedApps, NOT_LOADED } from 'single-spa';
import type { AppMetadata, PrefetchStrategy } from './interfaces';

declare global {
interface NetworkInformation {
saveData: boolean;
effectiveType: string;
}
}

//downgrade to setTimeout if the browser does not support requestIdleCallback
// RIC and shim for browsers setTimeout() without it
const requestIdleCallback =
window.requestIdleCallback ||
function requestIdleCallback(cb: CallableFunction) {
const start = Date.now();
return setTimeout(() => {
cb({
didTimeout: false,
timeRemaining() {
return Math.max(0, 50 - (Date.now() - start));
},
});
}, 1);
};

declare global {
interface Navigator {
connection: {
saveData: boolean;
effectiveType: string;
type: 'bluetooth' | 'cellular' | 'ethernet' | 'none' | 'wifi' | 'wimax' | 'other' | 'unknown';
};
}
}

const isSlowNetwork = navigator.connection
? navigator.connection.saveData ||
(navigator.connection.type ! 'wifi' &&
navigator.connection.type ! 'ethernet' &&
/([23])g/.test(navigator.connection.effectiveType))
: false;

/**
* prefetch assets, do nothing while in mobile network
Load the specified sub-application through the specified entry.
* @param entry
* @param opts
*/
function prefetch(entry: Entry, opts?: ImportEntryOpts): void {
if (!navigator.onLine || isSlowNetwork) {
// Don't prefetch if in a slow network or offline
return;
}

//Call the importEntry interface of single-spa to load the sub-application entry, return the function to load js and css, and then use the requestIdleCallback function to load at idle
requestIdleCallback(async () => {
const { getExternalScripts, getExternalStyleSheets } = await importEntry(entry, opts);
requestIdleCallback(getExternalStyleSheets);
requestIdleCallback(getExternalScripts);
});
}

//When the single-spa first-mount event is triggered, query all apps that have not yet been loaded, and then preload
function prefetchAfterFirstMounted(apps: AppMetadata[], opts?: ImportEntryOpts): void {
window.addEventListener('single-spa:first-mount', function listener() {
const notLoadedApps = apps.filter((app) => getAppStatus(app.name) = NOT_LOADED);

if (process.env.NODE_ENV = 'development') {
const mountedApps = getMountedApps();
console.log(`[qiankun] prefetch starting after ${mountedApps} mounted...`, notLoadedApps);
}

notLoadedApps.forEach(({ entry }) => prefetch(entry, opts));

window.removeEventListener('single-spa:first-mount', listener);
});
}

//Perform preload immediately and do not filter already loaded ones from apps
export function prefetchImmediately(apps: AppMetadata[], opts?: ImportEntryOpts): void {
if (process.env.NODE_ENV = 'development') {
console.log('[qiankun] prefetch starting for apps...', apps);
}

apps.forEach(({ entry }) => prefetch(entry, opts));
}

//Select the appropriate preload policy according to the policy configuration of parameter repassing
export function doPrefetchStrategy(
apps: AppMetadata[],
prefetchStrategy: PrefetchStrategy,
importEntryOpts?: ImportEntryOpts,
) {
const appsName2Apps = (names: string[]): AppMetadata[] => apps.filter((app) => names.includes(app.name));

if (Array.isArray(prefetchStrategy)) {
prefetchAfterFirstMounted(appsName2Apps(prefetchStrategy as string[]), importEntryOpts);
} else if (isFunction(prefetchStrategy)) {
(async () => {
// critical rendering apps would be prefetch as earlier as possible
const { criticalAppNames = [], minorAppsName = [] } = await prefetchStrategy(apps);
prefetchImmediately(appsName2Apps(criticalAppNames), importEntryOpts);
prefetchAfterFirstMounted(appsName2Apps(minorAppsName), importEntryOpts);
})();
} else {
switch (prefetchStrategy) {
case true:
prefetchAfterFirstMounted(apps, importEntryOpts);
break;

case 'all':
prefetchImmediately(apps, importEntryOpts);
break;

default:
break;
}
}
}

loader

Loader internal tool function

1
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
function assertElementExist(element: Element | null | undefined, msg?: string) {
if (!element) {
if (msg) {
throw new QiankunError(msg);
}

throw new QiankunError('element not existed!');
}
}

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();
}

async function validateSingularMode<T extends ObjectType>(
validate: FrameworkConfiguration['singular'],
app: LoadableApp<T>,
): Promise<boolean> {
return typeof validate = 'function' ? validate(app) : !!validate;
}

const supportShadowDOM = !!document.head.attachShadow || !!(document.head as any).createShadowRoot;

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;
}
}

if (process.env.NODE_ENV = 'development') {
console.warn(
`[qiankun] lifecycle not found from ${appName} entry exports, fallback to get from window['${appName}']`,
);
}

// 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`);
}

createElement

Create a new element with a parameter list of:

  • appContent: string type, need to pass in html text
  • strictStyleIsolation: Whether to use a separate shadow dom, if true, and the current browser supports shadow dom, then the content in appContent will be inside a shadow dom
  • scopedCSS: If true, all the css content in the html created this time will be prefixed. For details, see my other blog, how qiankun does css isolation
  • appInstanceId: string type, id of the sub-application
1
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
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;

if (appElement.attachShadow) {
shadow = appElement.attachShadow({ mode: 'open' });
} else {
// 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);
}

const styleNodes = appElement.querySelectorAll('style') || [];
forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
css.process(appElement!, stylesheetElement, appInstanceId);
});
}

return appElement;
}

getAppWrapperGetter

1
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
function getAppWrapperGetter(
appInstanceId: string,
useLegacyRender: boolean,
strictStyleIsolation: boolean,
scopedCSS: boolean,
elementGetter: () => HTMLElement | null,
) {
return () => {
if (useLegacyRender) {
if (strictStyleIsolation) throw new QiankunError('strictStyleIsolation can not be used with legacy render!');
if (scopedCSS) throw new QiankunError('experimentalStyleIsolation can not be used with legacy render!');

const appWrapper = document.getElementById(getWrapperId(appInstanceId));
assertElementExist(appWrapper, `Wrapper element for ${appInstanceId} is not existed!`);
return appWrapper!;
}

const element = elementGetter();
assertElementExist(element, `Wrapper element for ${appInstanceId} is not existed!`);

if (strictStyleIsolation && supportShadowDOM) {
return element!.shadowRoot!;
}

return element!;
};
}

getRender

1
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
/**
* 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) {
if (process.env.NODE_ENV = 'development') {
console.error(
'[qiankun] Custom rendering function is deprecated and will be removed in 3.0, you can use the container element setting instead!',
);
}

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;
}

loadAPP

The code is very long, let’s briefly analyze what they did:

  • Call the’importEntry 'interface of single-spa to get the loading entry of the sub-application and other information
  • The html entry parsed in the previous step will be passed to the createElement function as appContent, create a new node, mount the html entry under the node, and then return the new node and assign it to’initialAppWrapperElement’
  • call’getRender ‘to get the render function, assign to’render’
  • pass initialAppWrapperElement into render function
  • Call’getAppWrapperGetter ‘to get the enhanced function’initialAppWrapperGetter’, some judgment will be made internally, if there is no problem, the returned function’initialAppWrapperGetter ‘will return’initialAppWrapperElement’
  • If you choose to start the sandbox function, call createSandboxContainer to return the sandboxContainer, and assign the global object to the proxy of the sandboxContainer to start the sandbox function
  • Get all lifecycle functions by calling getAddOns
  • call execHooksChain to execute the beforeLoad function in the lifecycle obtained in the previous step
  • Get the lifecycle hook function of the sub-application through execScripts (the parsing result of the single-spa in the first step) and getLifecyclesFromExports
  • Eventually generate the parcelConfigGetter function, which returns the bootstrap, mount, and unmount methods for the child application, which will call the different lifecycle functions obtained earlier in sequence
1
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
export async function loadApp<T extends ObjectType>(
app: LoadableApp<T>,
configuration: FrameworkConfiguration = {},
lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
const { entry, name: appName } = app;
const appInstanceId = genAppInstanceIdByName(appName);

const markName = `[qiankun] App ${appInstanceId} Loading`;
if (process.env.NODE_ENV = 'development') {
performanceMark(markName);
}

const {
singular = false,
sandbox = true,
excludeAssetFilter,
globalContext = window,
...importEntryOpts
} = configuration;

// get the entry html content and script executor
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
if (await validateSingularMode(singular, app)) {
await (prevAppUnmountedDeferred && prevAppUnmountedDeferred.promise);
}

const appContent = getDefaultTplWrapper(appInstanceId)(template);

const strictStyleIsolation = typeof sandbox = 'object' && !!sandbox.strictStyleIsolation;

if (process.env.NODE_ENV = 'development' && strictStyleIsolation) {
console.warn(
"[qiankun] strictStyleIsolation configuration will be removed in 3.0, pls don't depend on it or use experimentalStyleIsolation instead!",
);
}

const scopedCSS = isEnableScopedCSS(sandbox);
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);

//first load settings apply visible region dom structure
//Make sure the container dom structure is set before each app load
render({ element: initialAppWrapperElement, loading: true, container: initialContainer }, 'loading');

const initialAppWrapperGetter = getAppWrapperGetter(
appInstanceId,
!!legacyRender,
strictStyleIsolation,
scopedCSS,
() => initialAppWrapperElement,
);

let global = globalContext;
let mountSandbox = () => Promise.resolve();
let unmountSandbox = () => Promise.resolve();
const useLooseSandbox = typeof sandbox = 'object' && !!sandbox.loose;
const speedySandbox = typeof sandbox = 'object' && !!sandbox.speedy;
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,
speedySandbox,
);
//Use the proxy object of the sandbox as the global object to use next
global = sandboxContainer.instance.proxy as typeof window;
mountSandbox = sandboxContainer.mount;
unmountSandbox = sandboxContainer.unmount;
}

const {
beforeUnmount = [],
afterUnmount = [],
afterMount = [],
beforeMount = [],
beforeLoad = [],
} = mergeWith({}, getAddOns(global, assetPublicPath), lifeCycles, (v1, v2) => concat(v1 ?? [], v2 ?? []));

await execHooksChain(toArray(beforeLoad), app, global);

// get the lifecycle hooks from module exports
const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox, {
scopedGlobalVariables: speedySandbox ? trustedGlobals : [],
});
const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
scriptExports,
appName,
global,
sandboxContainer?.instance?.latestSetProp,
);

const { onGlobalStateChange, setGlobalState, offGlobalStateChange }: Record<string, CallableFunction> =
getMicroAppStateActions(appInstanceId);

// FIXME temporary way
const syncAppWrapperElement2Sandbox = (element: HTMLElement | null) => (initialAppWrapperElement = element);

const parcelConfigGetter: ParcelConfigObjectGetter = (remountContainer = initialContainer) => {
let appWrapperElement: HTMLElement | null;
let appWrapperGetter: ReturnType<typeof getAppWrapperGetter>;

const parcelConfig: ParcelConfigObject = {
name: appInstanceId,
bootstrap,
mount: [
async () => {
if (process.env.NODE_ENV = 'development') {
const marks = performanceGetEntriesByName(markName, 'mark');
// mark length is zero means the app is remounting
if (marks && !marks.length) {
performanceMark(markName);
}
}
},
async () => {
if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
return prevAppUnmountedDeferred.promise;
}

return undefined;
},
// initial wrapper element before app mount/remount
async () => {
appWrapperElement = initialAppWrapperElement;
appWrapperGetter = getAppWrapperGetter(
appInstanceId,
!!legacyRender,
strictStyleIsolation,
scopedCSS,
() => appWrapperElement,
);
},
//Add mount hook, make sure the container dom structure has been set before each app load
async () => {
const useNewContainer = remountContainer ! initialContainer;
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);
}

render({ element: appWrapperElement, loading: true, container: remountContainer }, 'mounting');
},
mountSandbox,
// exec the chain after rendering to keep the behavior with beforeLoad
async () => execHooksChain(toArray(beforeMount), app, global),
async (props) => mount({ ...props, container: appWrapperGetter(), setGlobalState, onGlobalStateChange }),
// finish loading after app mounted
async () => render({ element: appWrapperElement, loading: false, container: remountContainer }, 'mounted'),
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)) {
prevAppUnmountedDeferred = new Deferred<void>();
}
},
async () => {
if (process.env.NODE_ENV = 'development') {
const measureName = `[qiankun] App ${appInstanceId} Loading Consuming`;
performanceMeasure(measureName, markName);
}
},
],
unmount: [
async () => execHooksChain(toArray(beforeUnmount), app, global),
async (props) => unmount({ ...props, container: appWrapperGetter() }),
unmountSandbox,
async () => execHooksChain(toArray(afterUnmount), app, global),
async () => {
render({ element: null, loading: false, container: remountContainer }, 'unmounted');
offGlobalStateChange(appInstanceId);
// for gc
appWrapperElement = null;
syncAppWrapperElement2Sandbox(appWrapperElement);
},
async () => {
if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
prevAppUnmountedDeferred.resolve();
}
},
],
};

if (typeof update = 'function') {
parcelConfig.update = update;
}

return parcelConfig;
};

return parcelConfigGetter;
}

Instrumental approach

error

This is just declaring an Error class

1
2
3
4
5
export class QiankunError extends Error {
constructor(message: string) {
super(`[qiankun]: ${message}`);
}
}

errorHandler

Exposing a method to add a global error handling listening function

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* @author Kuitos
* @since 2020-02-21
*/

export { addErrorHandler, removeErrorHandler } from 'single-spa';

export function addGlobalUncaughtErrorHandler(errorHandler: OnErrorEventHandlerNonNull): void {
window.addEventListener('error', errorHandler);
window.addEventListener('unhandledrejection', errorHandler);
}

export function removeGlobalUncaughtErrorHandler(errorHandler: (...args: any[]) => any) {
window.removeEventListener('error', errorHandler);
window.removeEventListener('unhandledrejection', errorHandler);
}

utils

Here only lists the function signature and its role, the specific code implementation will not be posted here

  • function toArray(array: T | T[]): T[]
  • function sleep(ms: number)
  • function nextTask(cb: () => void): void
  • function isConstructable(fn: () => any | FunctionConstructor)
  • function isCallable(fn: any)
  • function isPropertyFrozen(target: any, p?: PropertyKey): boolean
  • function isBoundedFunction(fn: CallableFunction)
  • const qiankunHeadTagName = ‘qiankun-head’;
  • function getDefaultTplWrapper(name: string)
  • function getWrapperId(name: string)
  • const nativeGlobal = new Function(‘return this’)()
  • const genAppInstanceIdByName = (appName: string): string
  • function validateExportLifecycle(exports: any)
  • function performanceGetEntriesByName(markName: string, type?: string)
  • function performanceMark(markName: string)
  • function performanceMeasure(measureName: string, markName: string)
  • function isEnableScopedCSS(sandbox: FrameworkConfiguration[‘sandbox’])
  • function getXPathForElement(el: Node, document: Document): string | void

Qiankun

This part is the main API that qiankun exposes and uses.

Tools to introduce

1
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
import { noop } from 'lodash';
import type { ParcelConfigObject } from 'single-spa';
import { mountRootParcel, registerApplication, start as startSingleSpa } from 'single-spa';
import type {
FrameworkConfiguration,
FrameworkLifeCycles,
LoadableApp,
MicroApp,
ObjectType,
RegistrableApp,
} from './interfaces';
import type { ParcelConfigObjectGetter } from './loader';
import { loadApp } from './loader';
import { doPrefetchStrategy } from './prefetch';
import { Deferred, getContainerXPath, toArray } from './utils';

let microApps: Array<RegistrableApp<Record<string, unknown>>> = [];

export let frameworkConfiguration: FrameworkConfiguration = {};

let started = false;
const defaultUrlRerouteOnly = true;

const frameworkStartedDefer = new Deferred<void>();

const autoDowngradeForLowVersionBrowser = (configuration: FrameworkConfiguration): FrameworkConfiguration => {
const { sandbox, singular } = configuration;
if (sandbox) {
if (!window.Proxy) {
console.warn('[qiankun] Miss window.Proxy, proxySandbox will degenerate into snapshotSandbox');

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;
};

registerMicroApps

Use the single-spa interface’registerApplication 'to register sub-applications

1
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
export function registerMicroApps<T extends ObjectType>(
apps: Array<RegistrableApp<T>>,
lifeCycles?: FrameworkLifeCycles<T>,
) {
// Each app only needs to be registered once
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;

registerApplication({
name,
app: async () => {
loader(true);
await frameworkStartedDefer.promise;

const { mount, ...otherMicroAppConfigs } = (
await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
)();

return {
mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
...otherMicroAppConfigs,
};
},
activeWhen: activeRule,
customProps: props,
});
});
}

loadMicroApp

The function of this function is to call the loadApp of the loader, and then use the startSingleSpa of single-spa to start

1
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
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
const containerXPath = getContainerXPath(container);
const appContainerXPathKey = `${name}-${containerXPath}`;

let microApp: MicroApp;
const wrapParcelConfigForRemount = (config: ParcelConfigObject): ParcelConfigObject => {
let microAppConfig = config;
if (container) {
if (containerXPath) {
const containerMicroApps = containerMicroAppsMap.get(appContainerXPathKey);
if (containerMicroApps?.length) {
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',
);
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 },
);
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));
}
}

const parcelConfigObjectGetterPromise = loadApp(app, userConfiguration, lifeCycles);

if (container) {
if ($$cacheLifecycleByAppName) {
appConfigPromiseGetterMap.set(name, parcelConfigObjectGetterPromise);
} else if (containerXPath) appConfigPromiseGetterMap.set(appContainerXPathKey, parcelConfigObjectGetterPromise);
}

return (await parcelConfigObjectGetterPromise)(container);
};

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
// 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({ urlRerouteOnly: frameworkConfiguration.urlRerouteOnly ?? defaultUrlRerouteOnly });
}

microApp = mountRootParcel(memorizedLoadingFn, { domElement: document.createElement('div'), ...props });

if (container) {
if (containerXPath) {
// Store the microApps which they mounted on the same 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
microApp.unmountPromise.then(cleanup).catch(cleanup);
}
}

return microApp;
}

start

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export function start(opts: FrameworkConfiguration = {}) {
frameworkConfiguration = { prefetch: true, singular: true, sandbox: true, ...opts };
const {
prefetch,
sandbox,
singular,
urlRerouteOnly = defaultUrlRerouteOnly,
...importEntryOpts
} = frameworkConfiguration;

if (prefetch) {
doPrefetchStrategy(microApps, prefetch, importEntryOpts);
}

frameworkConfiguration = autoDowngradeForLowVersionBrowser(frameworkConfiguration);

startSingleSpa({ urlRerouteOnly });
started = true;

frameworkStartedDefer.resolve();
}

Summary

In general, the internal API division of labor is as follows:

  • prefetch provides a way to preload all child application resources,
  • loader provides the ability to load resources based on the Sub-App entry, execute the entry file, and then mount the resulting html to a newly created div
  • Qiankun has two main APIs exposed to the outside world
    • registerMicroApps mainly calls the register interface of sigle-spa to register sub-applications
    • loadMicroApp will call the method in the loader to load the sub-application. In fact, the loader eventually calls the load method of single-spa, just doing some sandbox isolation, css isolation, etc.