微前端框架 Qiankun 沙箱原理

为什么需要微前端

微前端其实诞生两个大的背景下,在提倡拥抱变化的前端社区可以看到新的框架、技术、概念层出不穷,并且随着WEB标准的演进,前端应用已经具备更好的性能、更快的开发效率。但随着而来的是应用的复杂程度更高、涉及的团队规模更广、更高的性能要求,应用复杂度已经成为阻塞业务发展的重要瓶颈。如何让现有系统拥抱最新技术提高生产力、并且解耦单体应用,是现在前端工程不得不面临的挑战。

如果你遇到以下的情况,可能你需要微前端:

  • 你的单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用后,随之而来的应用不可维护的问题
  • 作为一个门户网站,需要集成很多的系统,这些系统由不同的团队维护、有风格各异的代码、有形形色色的技术栈,为了聚合只能采取iframe或者使用MPA的形式进行聚合

为什么需要沙箱

在微前端的场景,由于多个独立的应用被组织到了一起,在没有类似iframe的原生隔离下,势必会出现冲突,如全局变量冲突、样式冲突,这些冲突可能会导致应用样式异常,甚至功能不可用。所以想让微前端达到生产可用的程度,让每个子应用之间达到一定程度隔离的沙箱机制是必不可少的。

如何实现一个沙箱

手动执行代码

常规的脚本加载,是通过script标签去执行的,要实现沙箱,因为需要控制沙箱的开启和关闭,我们就需要精确掌握脚本的执行时机,所以我们需要寻找一种合适的能手动执行代码的方法

首先我们想到的是eval,由于eval有安全、性能等问题,同时也不利于调试,所以在以前我们听到的都是不推荐使用eval这个api。

但是在微前端的沙箱场景,eval确实是一个比较好的解决方案,比如qiankun就采用了eval作为代码执行器。

new Function通过传入一个string作为函数的的主体同时返回一个新函数,可以作为eval的一个替代品

对比eval,有两点比较重要的不同:

  • 不能访问当前环境的变量,但是可以访问全局变量,安全性更高
  • 仅需要处理传入的字符串一次,后面重复执行都是同一个函数,而eval需要每次都处理,性能更高

快照沙箱

顾名思义,即在某个阶段给当前的运行环境打一个快照,再在需要的时候把快照恢复,从而实现隔离。

这个东西的原理和操作系统中的CPU进程切换很像,每个进程获得CPU使用权时把PCB(进程控制块)中的上下文装载到对应的寄存器中,然后CPU开始继续执行,等到失去使用权时再将当前寄存器中的信息重新保存回PCB中。快照沙箱也是这样,对于每一个子应用,运行时将其内部保存的上下文加载到对应的变量上,销毁时再将当前浏览器环境中各个变量的值保存到快照中。

我们来看一下qiankun中快照沙箱的源码,源码上添加了我的注释

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
/**
* @author Hydrogen
* @since 2020-3-8
*/
import type { SandBox } from '../interfaces';
import { SandBoxType } from '../interfaces';

function iter(obj: typeof window, callbackFn: (prop: any) => void) {
// eslint-disable-next-line guard-for-in, no-restricted-syntax
for (const prop in obj) {
// patch for clearInterval for compatible reason, see #1490
if (obj.hasOwnProperty(prop) || prop === 'clearInterval') {
callbackFn(prop);
}
}
}

/**
* 基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器
*/
export default class SnapshotSandbox implements SandBox {
proxy: WindowProxy;

name: string;

type: SandBoxType;

sandboxRunning = true;

// windows对象的快照,用于保存当前沙盒active之前的window对象的状态
private windowSnapshot!: Window;

// 当前沙盒在active和inactive之间window对象上修改过的值
private modifyPropsMap: Record<any, any> = {};

constructor(name: string) {
this.name = name;
this.proxy = window;
this.type = SandBoxType.Snapshot;
}

active() {
// 记录当前快照,用于在inactive时比较哪些属性发生了变化
this.windowSnapshot = {} as Window;
iter(window, (prop) => {
this.windowSnapshot[prop] = window[prop];
});

// 恢复之前的变更,也就是上一次active和inactive之间修改过的变量
Object.keys(this.modifyPropsMap).forEach((p: any) => {
window[p] = this.modifyPropsMap[p];
});

this.sandboxRunning = true;
}

inactive() {
this.modifyPropsMap = {};

// 比较当前window对象上的属性和active之前的window对象上的属性,如果有不同,记录进modifyPropsMap中,下次在active时重新赋值回window
iter(window, (prop) => {
if (window[prop] !== this.windowSnapshot[prop]) {
// 记录变更,恢复环境
this.modifyPropsMap[prop] = window[prop];
window[prop] = this.windowSnapshot[prop];
}
});

if (process.env.NODE_ENV === 'development') {
console.info(`[qiankun:sandbox] ${this.name} origin window restore...`, Object.keys(this.modifyPropsMap));
}

this.sandboxRunning = false;
}
}

代理沙箱

代理沙箱主要是通过为每个沙盒创建一个fakeWindow,然后为这个fakeWindow设置代理,通过代理来访问这个fakeWindow,所以它的源码主要分为两部分:

  • 通过createFakeWindow函数创建一个fakeWindow,这个fakeWindow上只会克隆window对象上可以修改的属性
  • 创建ProxySandbox类,内部使用createFakeWindow创建的fakeWindow

我们先看一下createFakeWindow,说起来这个函数做的事情就是它注释上说的:copy the non-configurable property of global to fakeWindow

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
function createFakeWindow(globalContext: Window) {
// map always has the fastest performance in has check scenario
// see https://jsperf.com/array-indexof-vs-set-has/23
const propertiesWithGetter = new Map<PropertyKey, boolean>();
const fakeWindow = {} as FakeWindow;

/*
copy the non-configurable property of global to fakeWindow
see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/getOwnPropertyDescriptor
> A property cannot be reported as non-configurable, if it does not exist as an own property of the target object or if it exists as a configurable own property of the target object.
*/
Object.getOwnPropertyNames(globalContext)
.filter((p) => {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
return !descriptor?.configurable;
})
.forEach((p) => {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
if (descriptor) {
const hasGetter = Object.prototype.hasOwnProperty.call(descriptor, 'get');

/*
make top/self/window property configurable and writable, otherwise it will cause TypeError while get trap return.
see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/get
> The value reported for a property must be the same as the value of the corresponding target object property if the target object property is a non-writable, non-configurable data property.
*/
if (
p === 'top' ||
p === 'parent' ||
p === 'self' ||
p === 'window' ||
(process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop'))
) {
descriptor.configurable = true;
/*
The descriptor of window.window/window.top/window.self in Safari/FF are accessor descriptors, we need to avoid adding a data descriptor while it was
Example:
Safari/FF: Object.getOwnPropertyDescriptor(window, 'top') -> {get: function, set: undefined, enumerable: true, configurable: false}
Chrome: Object.getOwnPropertyDescriptor(window, 'top') -> {value: Window, writable: false, enumerable: true, configurable: false}
*/
if (!hasGetter) {
descriptor.writable = true;
}
}

if (hasGetter) propertiesWithGetter.set(p, true);

// freeze the descriptor to avoid being modified by zone.js
// see https://github.com/angular/zone.js/blob/a5fe09b0fac27ac5df1fa746042f96f05ccb6a00/lib/browser/define-property.ts#L71
rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
}
});

return {
fakeWindow,
propertiesWithGetter,
};
}

然后是ProxySandbox的实现了,比较关键的就是它的构造函数中通过proxy劫持了对fakeWindow的set方法,然后通过这种方式去统计有那些属性发生了改变,同时如果是globalVariableWhiteList中的成员,那么就将其descriptor存储到globalWhitelistPrevDescriptor中,在inactive的时候恢复

我们首先看下这个globalVariableWhiteList是什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const variableWhiteListInDev =
process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'development' || window.__QIANKUN_DEVELOPMENT__
? [
// for react hot reload
// see https://github.com/facebook/create-react-app/blob/66bf7dfc43350249e2f09d138a20840dae8a0a4a/packages/react-error-overlay/src/index.js#L180
'__REACT_ERROR_OVERLAY_GLOBAL_HOOK__',
]
: [];
// who could escape the sandbox
const globalVariableWhiteList: string[] = [
// FIXME System.js used a indirect call with eval, which would make it scope escape to global
// To make System.js works well, we write it back to global window temporary
// see https://github.com/systemjs/systemjs/blob/457f5b7e8af6bd120a279540477552a07d5de086/src/evaluate.js#L106
'System',

// see https://github.com/systemjs/systemjs/blob/457f5b7e8af6bd120a279540477552a07d5de086/src/instantiate.js#L357
'__cjsWrapper',
...variableWhiteListInDev,
];

然后看一下ProxySandbox的大致结构,这里我先把Proxy隐藏,后面再一个个介绍:

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
export default class ProxySandbox implements SandBox {
/** window 值变更记录 */
private updatedValueSet = new Set<PropertyKey>();

name: string;

type: SandBoxType;

proxy: WindowProxy;
sandboxRunning = true;
latestSetProp: PropertyKey | null = null;

active() {
if (!this.sandboxRunning) activeSandboxCount++;
this.sandboxRunning = true;
}

inactive() {
if (process.env.NODE_ENV === 'development') {
console.info(`[qiankun:sandbox] ${this.name} modified global properties restore...`, [
...this.updatedValueSet.keys(),
]);
}

if (process.env.NODE_ENV === 'test' || --activeSandboxCount === 0) {
// reset the global value to the prev value
Object.keys(this.globalWhitelistPrevDescriptor).forEach((p) => {
const descriptor = this.globalWhitelistPrevDescriptor[p];
if (descriptor) {
Object.defineProperty(this.globalContext, p, descriptor);
} else {
// @ts-ignore
delete this.globalContext[p];
}
});
}

this.sandboxRunning = false;
}

// the descriptor of global variables in whitelist before it been modified
globalWhitelistPrevDescriptor: { [p in typeof globalVariableWhiteList[number]]: PropertyDescriptor | undefined } = {};
globalContext: typeof window;

constructor(name: string, globalContext = window) {
this.name = name;
this.globalContext = globalContext;
this.type = SandBoxType.Proxy;
const { updatedValueSet } = this;

const { fakeWindow, propertiesWithGetter } = createFakeWindow(globalContext);

const descriptorTargetMap = new Map<PropertyKey, SymbolTarget>();
const hasOwnProperty = (key: PropertyKey) => fakeWindow.hasOwnProperty(key) || globalContext.hasOwnProperty(key);

const proxy = new Proxy(fakeWindow, {
// ...
});

this.proxy = proxy;

activeSandboxCount++;
}

private registerRunningApp(name: string, proxy: Window) {
if (this.sandboxRunning) {
const currentRunningApp = getCurrentRunningApp();
if (!currentRunningApp || currentRunningApp.name !== name) {
setCurrentRunningApp({ name, window: proxy });
}
// FIXME if you have any other good ideas
// remove the mark in next tick, thus we can identify whether it in micro app or not
// this approach is just a workaround, it could not cover all complex cases, such as the micro app runs in the same task context with master in some case
nextTask(() => {
setCurrentRunningApp(null);
});
}
}
}

这里我们暂时看不出这latestSetProp, globalWhitelistPrevDescriptor几个变量的作用,我们继续看一下Proxy中的各个配置:

set

set拦截其中会检测fakeWindow和globalContext是否都有当前要修改的属性,如果有,那么直接给fakeWindow上的该属性赋值,如果没有,只有globalContext上有,看一下这个属性是否是writable或者有set方法,只有这两个有其一,才表明这个属性本身是可以修改的,我们给fakeWindow上添加这个属性并修改才是有意义的

同时set方法中也会查看当前修改的属性是否是globalVariableWhiteList中的一个,如果是,将其descriptor添加到globalWhitelistPrevDescriptor中

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
set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
if (this.sandboxRunning) {
this.registerRunningApp(name, proxy);
// We must keep its description while the property existed in globalContext before
if (!target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
const { writable, configurable, enumerable, set } = descriptor!;
// only writable property can be overwritten
// here we ignored accessor descriptor of globalContext as it makes no sense to trigger its logic(which might make sandbox escaping instead)
// we force to set value by data descriptor
if (writable || set) {
Object.defineProperty(target, p, { configurable, enumerable, writable: true, value });
}
} else {
target[p] = value;
}

// sync the property to globalContext
if (typeof p === 'string' && globalVariableWhiteList.indexOf(p) !== -1) {
this.globalWhitelistPrevDescriptor[p] = Object.getOwnPropertyDescriptor(globalContext, p);
// @ts-ignore
globalContext[p] = value;
}

updatedValueSet.add(p);

this.latestSetProp = p;

return true;
}

if (process.env.NODE_ENV === 'development') {
console.warn(`[qiankun] Set window.${p.toString()} while sandbox destroyed or inactive in ${name}!`);
}

// 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
return true;
},

get

这个get其实就是做了很多的检测,比如如果要获取的属性是window或者self,这种,返回proxy本身这种,具体很多分支大家可以看一下下面的代码

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: (target: FakeWindow, p: PropertyKey): any => {
this.registerRunningApp(name, proxy);

if (p === Symbol.unscopables) return unscopables;
// avoid who using window.window or window.self to escape the sandbox environment to touch the really window
// see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
if (p === 'window' || p === 'self') {
return proxy;
}

// hijack globalWindow accessing with globalThis keyword
if (p === 'globalThis') {
return proxy;
}

if (
p === 'top' ||
p === 'parent' ||
(process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop'))
) {
// if your master app in an iframe context, allow these props escape the sandbox
if (globalContext === globalContext.parent) {
return proxy;
}
return (globalContext as any)[p];
}

// proxy.hasOwnProperty would invoke getter firstly, then its value represented as globalContext.hasOwnProperty
if (p === 'hasOwnProperty') {
return hasOwnProperty;
}

if (p === 'document') {
return document;
}

if (p === 'eval') {
return eval;
}

const actualTarget = propertiesWithGetter.has(p) ? globalContext : p in target ? target : globalContext;
const value = actualTarget[p];

// frozen value should return directly, see https://github.com/umijs/qiankun/issues/2015
if (isPropertyFrozen(actualTarget, p)) {
return value;
}

/* Some dom api must be bound to native window, otherwise it would cause exception like 'TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation'
See this code:
const proxy = new Proxy(window, {});
const proxyFetch = fetch.bind(proxy);
proxyFetch('https://qiankun.com');
*/
const boundTarget = useNativeWindowForBindingsProps.get(p) ? nativeGlobal : globalContext;
return getTargetValue(boundTarget, value);
},

其他

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
has(target: FakeWindow, p: string | number | symbol): boolean {
return p in unscopables || p in target || p in globalContext;
},

getOwnPropertyDescriptor(target: FakeWindow, p: string | number | symbol): PropertyDescriptor | undefined {
/*
as the descriptor of top/self/window/mockTop in raw window are configurable but not in proxy target, we need to get it from target to avoid TypeError
see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/getOwnPropertyDescriptor
> A property cannot be reported as non-configurable, if it does not exists as an own property of the target object or if it exists as a configurable own property of the target object.
*/
if (target.hasOwnProperty(p)) {
const descriptor = Object.getOwnPropertyDescriptor(target, p);
descriptorTargetMap.set(p, 'target');
return descriptor;
}

if (globalContext.hasOwnProperty(p)) {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
descriptorTargetMap.set(p, 'globalContext');
// A property cannot be reported as non-configurable, if it does not exists as an own property of the target object
if (descriptor && !descriptor.configurable) {
descriptor.configurable = true;
}
return descriptor;
}

return undefined;
},

// trap to support iterator with sandbox
ownKeys(target: FakeWindow): ArrayLike<string | symbol> {
return uniq(Reflect.ownKeys(globalContext).concat(Reflect.ownKeys(target)));
},

defineProperty(target: Window, p: PropertyKey, attributes: PropertyDescriptor): boolean {
const from = descriptorTargetMap.get(p);
/*
Descriptor must be defined to native window while it comes from native window via Object.getOwnPropertyDescriptor(window, p),
otherwise it would cause a TypeError with illegal invocation.
*/
switch (from) {
case 'globalContext':
return Reflect.defineProperty(globalContext, p, attributes);
default:
return Reflect.defineProperty(target, p, attributes);
}
},

deleteProperty: (target: FakeWindow, p: string | number | symbol): boolean => {
this.registerRunningApp(name, proxy);
if (target.hasOwnProperty(p)) {
// @ts-ignore
delete target[p];
updatedValueSet.delete(p);

return true;
}

return true;
},

// makes sure `window instanceof Window` returns truthy in micro app
getPrototypeOf() {
return Reflect.getPrototypeOf(globalContext);
},

patchers

劫持interval

这部分功能主要是用于拦截sandbox运行期间的定时器,收集有哪些定时器,并能够在sandbox退出事把所有定时器清空

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
import { noop } from 'lodash';

const rawWindowInterval = window.setInterval;
const rawWindowClearInterval = window.clearInterval;

export default function patch(global: Window) {
let intervals: number[] = [];

global.clearInterval = (intervalId: number) => {
intervals = intervals.filter((id) => id !== intervalId);
return rawWindowClearInterval.call(window, intervalId as any);
};

global.setInterval = (handler: CallableFunction, timeout?: number, ...args: any[]) => {
const intervalId = rawWindowInterval(handler, timeout, ...args);
intervals = [...intervals, intervalId];
return intervalId;
};

return function free() {
intervals.forEach((id) => global.clearInterval(id));
global.setInterval = rawWindowInterval;
global.clearInterval = rawWindowClearInterval;

return noop;
};
}

劫持eventListener

这个的功能与劫持interval相同,这里是劫持所有的eventListener,收集所有的事件监听器,并提供函数能在sandbox退出时清空所有的事件监听器

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
import { noop } from 'lodash';

const rawAddEventListener = window.addEventListener;
const rawRemoveEventListener = window.removeEventListener;

export default function patch(global: WindowProxy) {
const listenerMap = new Map<string, EventListenerOrEventListenerObject[]>();

global.addEventListener = (
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
) => {
const listeners = listenerMap.get(type) || [];
listenerMap.set(type, [...listeners, listener]);
return rawAddEventListener.call(window, type, listener, options);
};

global.removeEventListener = (
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions,
) => {
const storedTypeListeners = listenerMap.get(type);
if (storedTypeListeners && storedTypeListeners.length && storedTypeListeners.indexOf(listener) !== -1) {
storedTypeListeners.splice(storedTypeListeners.indexOf(listener), 1);
}
return rawRemoveEventListener.call(window, type, listener, options);
};

return function free() {
listenerMap.forEach((listeners, type) =>
[...listeners].forEach((listener) => global.removeEventListener(type, listener)),
);
global.addEventListener = rawAddEventListener;
global.removeEventListener = rawRemoveEventListener;

return noop;
};
}

劫持historyListener(针对umi框架)

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
import { isFunction, noop } from 'lodash';

export default function patch() {
// FIXME umi unmount feature request
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let rawHistoryListen = (_: any) => noop;
const historyListeners: Array<typeof noop> = [];
const historyUnListens: Array<typeof noop> = [];

if ((window as any).g_history && isFunction((window as any).g_history.listen)) {
rawHistoryListen = (window as any).g_history.listen.bind((window as any).g_history);

(window as any).g_history.listen = (listener: typeof noop) => {
historyListeners.push(listener);

const unListen = rawHistoryListen(listener);
historyUnListens.push(unListen);

return () => {
unListen();
historyUnListens.splice(historyUnListens.indexOf(unListen), 1);
historyListeners.splice(historyListeners.indexOf(listener), 1);
};
};
}

return function free() {
let rebuild = noop;

/*
还存在余量 listener 表明未被卸载,存在两种情况
1. 应用在 unmout 时未正确卸载 listener
2. listener 是应用 mount 之前绑定的,
第二种情况下应用在下次 mount 之前需重新绑定该 listener
*/
if (historyListeners.length) {
rebuild = () => {
// 必须使用 window.g_history.listen 的方式重新绑定 listener,从而能保证 rebuild 这部分也能被捕获到,否则在应用卸载后无法正确的移除这部分副作用
historyListeners.forEach((listener) => (window as any).g_history.listen(listener));
};
}

// 卸载余下的 listener
historyUnListens.forEach((unListen) => unListen());

// restore
if ((window as any).g_history && isFunction((window as any).g_history.listen)) {
(window as any).g_history.listen = rawHistoryListen;
}

return rebuild;
};
}

patch sandbox

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
export function patchLooseSandbox(
appName: string,
appWrapperGetter: () => HTMLElement | ShadowRoot,
proxy: Window,
mounting = true,
scopedCSS = false,
excludeAssetFilter?: CallableFunction,
): Freer {
let dynamicStyleSheetElements: Array<HTMLLinkElement | HTMLStyleElement> = [];

const unpatchDynamicAppendPrototypeFunctions = patchHTMLDynamicAppendPrototypeFunctions(
/*
check if the currently specified application is active
While we switch page from qiankun app to a normal react routing page, the normal one may load stylesheet dynamically while page rendering,
but the url change listener must to wait until the current call stack is flushed.
This scenario may cause we record the stylesheet from react routing page dynamic injection,
and remove them after the url change triggered and qiankun app is unmouting
see https://github.com/ReactTraining/history/blob/master/modules/createHashHistory.js#L222-L230
*/
() => checkActivityFunctions(window.location).some((name) => name === appName),
() => ({
appName,
appWrapperGetter,
proxy,
strictGlobal: false,
speedySandbox: false,
scopedCSS,
dynamicStyleSheetElements,
excludeAssetFilter,
}),
);

if (!mounting) calcAppCount(appName, 'increase', 'bootstrapping');
if (mounting) calcAppCount(appName, 'increase', 'mounting');

return function free() {
if (!mounting) calcAppCount(appName, 'decrease', 'bootstrapping');
if (mounting) calcAppCount(appName, 'decrease', 'mounting');

// release the overwrite prototype after all the micro apps unmounted
if (isAllAppsUnmounted()) unpatchDynamicAppendPrototypeFunctions();

recordStyledComponentsCSSRules(dynamicStyleSheetElements);

// As now the sub app content all wrapped with a special id container,
// the dynamic style sheet would be removed automatically while unmoutting

return function rebuild() {
rebuildCSSRules(dynamicStyleSheetElements, (stylesheetElement) => {
const appWrapper = appWrapperGetter();
if (!appWrapper.contains(stylesheetElement)) {
// Using document.head.appendChild ensures that appendChild invocation can also directly use the HTMLHeadElement.prototype.appendChild method which is overwritten at mounting phase
document.head.appendChild.call(appWrapper, stylesheetElement);
return true;
}

return false;
});

// As the patcher will be invoked every mounting phase, we could release the cache for gc after rebuilding
if (mounting) {
dynamicStyleSheetElements = [];
}
};
};
}


// Get native global window with a sandbox disgusted way, thus we could share it between qiankun instances🤪
Object.defineProperty(nativeGlobal, '__proxyAttachContainerConfigMap__', { enumerable: false, writable: true });

// Share proxyAttachContainerConfigMap between multiple qiankun instance, thus they could access the same record
nativeGlobal.__proxyAttachContainerConfigMap__ =
nativeGlobal.__proxyAttachContainerConfigMap__ || new WeakMap<WindowProxy, ContainerConfig>();
const proxyAttachContainerConfigMap: WeakMap<WindowProxy, ContainerConfig> =
nativeGlobal.__proxyAttachContainerConfigMap__;

const elementAttachContainerConfigMap = new WeakMap<HTMLElement, ContainerConfig>();

const docCreatePatchedMap = new WeakMap<typeof document.createElement, typeof document.createElement>();
function patchDocumentCreateElement() {
const docCreateElementFnBeforeOverwrite = docCreatePatchedMap.get(document.createElement);

if (!docCreateElementFnBeforeOverwrite) {
const rawDocumentCreateElement = document.createElement;
Document.prototype.createElement = function createElement<K extends keyof HTMLElementTagNameMap>(
this: Document,
tagName: K,
options?: ElementCreationOptions,
): HTMLElement {
const element = rawDocumentCreateElement.call(this, tagName, options);
if (isHijackingTag(tagName)) {
const { window: currentRunningSandboxProxy } = getCurrentRunningApp() || {};
if (currentRunningSandboxProxy) {
const proxyContainerConfig = proxyAttachContainerConfigMap.get(currentRunningSandboxProxy);
if (proxyContainerConfig) {
elementAttachContainerConfigMap.set(element, proxyContainerConfig);
}
}
}

return element;
};

// It means it have been overwritten while createElement is an own property of document
if (document.hasOwnProperty('createElement')) {
document.createElement = Document.prototype.createElement;
}

docCreatePatchedMap.set(Document.prototype.createElement, rawDocumentCreateElement);
}

return function unpatch() {
if (docCreateElementFnBeforeOverwrite) {
Document.prototype.createElement = docCreateElementFnBeforeOverwrite;
document.createElement = docCreateElementFnBeforeOverwrite;
}
};
}

export function patchStrictSandbox(
appName: string,
appWrapperGetter: () => HTMLElement | ShadowRoot,
proxy: Window,
mounting = true,
scopedCSS = false,
excludeAssetFilter?: CallableFunction,
speedySandbox = false,
): Freer {
let containerConfig = proxyAttachContainerConfigMap.get(proxy);
if (!containerConfig) {
containerConfig = {
appName,
proxy,
appWrapperGetter,
dynamicStyleSheetElements: [],
strictGlobal: true,
speedySandbox,
excludeAssetFilter,
scopedCSS,
};
proxyAttachContainerConfigMap.set(proxy, containerConfig);
}
// all dynamic style sheets are stored in proxy container
const { dynamicStyleSheetElements } = containerConfig;

const unpatchDocumentCreate = patchDocumentCreateElement();

const unpatchDynamicAppendPrototypeFunctions = patchHTMLDynamicAppendPrototypeFunctions(
(element) => elementAttachContainerConfigMap.has(element),
(element) => elementAttachContainerConfigMap.get(element)!,
);

if (!mounting) calcAppCount(appName, 'increase', 'bootstrapping');
if (mounting) calcAppCount(appName, 'increase', 'mounting');

return function free() {
if (!mounting) calcAppCount(appName, 'decrease', 'bootstrapping');
if (mounting) calcAppCount(appName, 'decrease', 'mounting');

// release the overwritten prototype after all the micro apps unmounted
if (isAllAppsUnmounted()) {
unpatchDynamicAppendPrototypeFunctions();
unpatchDocumentCreate();
}

recordStyledComponentsCSSRules(dynamicStyleSheetElements);

// As now the sub app content all wrapped with a special id container,
// the dynamic style sheet would be removed automatically while unmoutting

return function rebuild() {
rebuildCSSRules(dynamicStyleSheetElements, (stylesheetElement) => {
const appWrapper = appWrapperGetter();
if (!appWrapper.contains(stylesheetElement)) {
const mountDom =
stylesheetElement[styleElementTargetSymbol] === 'head' ? getAppWrapperHeadElement(appWrapper) : appWrapper;
rawHeadAppendChild.call(mountDom, stylesheetElement);
return true;
}

return false;
});
};
};
}

patchers 集成导出

简单总结下patcher是做什么的:

  • 执行过程中会对一些全局的定时器或者监听器或者某些全局变量进行拦截统计,我们统称为副作用,然后返回free函数用于子应用卸载时(即unmount)调用
  • free函数内部会对上一部统计到的副作用进行清空,同时如果有部分副作用是需要下次子应用重新激活时重新启动的,放到free函数最终返回的rebuild中,最终会在mount时调用rebuild
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
import type { Freer, SandBox } from '../../interfaces';
import { SandBoxType } from '../../interfaces';
import * as css from './css';
import { patchLooseSandbox, patchStrictSandbox } from './dynamicAppend';
import patchHistoryListener from './historyListener';
import patchInterval from './interval';
import patchWindowListener from './windowListener';

export function patchAtMounting(
appName: string,
elementGetter: () => HTMLElement | ShadowRoot,
sandbox: SandBox,
scopedCSS: boolean,
excludeAssetFilter?: CallableFunction,
speedySandBox?: boolean,
): Freer[] {
const basePatchers = [
() => patchInterval(sandbox.proxy),
() => patchWindowListener(sandbox.proxy),
() => patchHistoryListener(),
];

const patchersInSandbox = {
[SandBoxType.LegacyProxy]: [
...basePatchers,
() => patchLooseSandbox(appName, elementGetter, sandbox.proxy, true, scopedCSS, excludeAssetFilter),
],
[SandBoxType.Proxy]: [
...basePatchers,
() =>
patchStrictSandbox(appName, elementGetter, sandbox.proxy, true, scopedCSS, excludeAssetFilter, speedySandBox),
],
[SandBoxType.Snapshot]: [
...basePatchers,
() => patchLooseSandbox(appName, elementGetter, sandbox.proxy, true, scopedCSS, excludeAssetFilter),
],
};

return patchersInSandbox[sandbox.type]?.map((patch) => patch());
}

export function patchAtBootstrapping(
appName: string,
elementGetter: () => HTMLElement | ShadowRoot,
sandbox: SandBox,
scopedCSS: boolean,
excludeAssetFilter?: CallableFunction,
speedySandBox?: boolean,
): Freer[] {
const patchersInSandbox = {
[SandBoxType.LegacyProxy]: [
() => patchLooseSandbox(appName, elementGetter, sandbox.proxy, false, scopedCSS, excludeAssetFilter),
],
[SandBoxType.Proxy]: [
() =>
patchStrictSandbox(appName, elementGetter, sandbox.proxy, false, scopedCSS, excludeAssetFilter, speedySandBox),
],
[SandBoxType.Snapshot]: [
() => patchLooseSandbox(appName, elementGetter, sandbox.proxy, false, scopedCSS, excludeAssetFilter),
],
};

return patchersInSandbox[sandbox.type]?.map((patch) => patch());
}

export { css };

Sandbox集合

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
import type { Freer, Rebuilder, SandBox } from '../interfaces';
import LegacySandbox from './legacy/sandbox';
import { patchAtBootstrapping, patchAtMounting } from './patchers';
import ProxySandbox from './proxySandbox';
import SnapshotSandbox from './snapshotSandbox';

export { getCurrentRunningApp } from './common';
export { css } from './patchers';

/**
* 生成应用运行时沙箱
*
* 沙箱分两个类型:
* 1. app 环境沙箱
* app 环境沙箱是指应用初始化过之后,应用会在什么样的上下文环境运行。每个应用的环境沙箱只会初始化一次,因为子应用只会触发一次 bootstrap 。
* 子应用在切换时,实际上切换的是 app 环境沙箱。
* 2. render 沙箱
* 子应用在 app mount 开始前生成好的的沙箱。每次子应用切换过后,render 沙箱都会重现初始化。
*
* 这么设计的目的是为了保证每个子应用切换回来之后,还能运行在应用 bootstrap 之后的环境下。
*
* @param appName
* @param elementGetter
* @param scopedCSS
* @param useLooseSandbox
* @param excludeAssetFilter
* @param globalContext
* @param speedySandBox
*/
export function createSandboxContainer(
appName: string,
elementGetter: () => HTMLElement | ShadowRoot,
scopedCSS: boolean,
useLooseSandbox?: boolean,
excludeAssetFilter?: (url: string) => boolean,
globalContext?: typeof window,
speedySandBox?: boolean,
) {
let sandbox: SandBox;
if (window.Proxy) {
sandbox = useLooseSandbox ? new LegacySandbox(appName, globalContext) : new ProxySandbox(appName, globalContext);
} else {
sandbox = new SnapshotSandbox(appName);
}

// some side effect could be be invoked while bootstrapping, such as dynamic stylesheet injection with style-loader, especially during the development phase
const bootstrappingFreers = patchAtBootstrapping(
appName,
elementGetter,
sandbox,
scopedCSS,
excludeAssetFilter,
speedySandBox,
);
// mounting freers are one-off and should be re-init at every mounting time
let mountingFreers: Freer[] = [];

let sideEffectsRebuilders: Rebuilder[] = [];

return {
instance: sandbox,

/**
* 沙箱被 mount
* 可能是从 bootstrap 状态进入的 mount
* 也可能是从 unmount 之后再次唤醒进入 mount
*/
async mount() {
/* ------------------------------------------ 因为有上下文依赖(window),以下代码执行顺序不能变 ------------------------------------------ */

/* ------------------------------------------ 1. 启动/恢复 沙箱------------------------------------------ */
sandbox.active();

const sideEffectsRebuildersAtBootstrapping = sideEffectsRebuilders.slice(0, bootstrappingFreers.length);
const sideEffectsRebuildersAtMounting = sideEffectsRebuilders.slice(bootstrappingFreers.length);

// must rebuild the side effects which added at bootstrapping firstly to recovery to nature state
if (sideEffectsRebuildersAtBootstrapping.length) {
sideEffectsRebuildersAtBootstrapping.forEach((rebuild) => rebuild());
}

/* ------------------------------------------ 2. 开启全局变量补丁 ------------------------------------------*/
// render 沙箱启动时开始劫持各类全局监听,尽量不要在应用初始化阶段有 事件监听/定时器 等副作用
mountingFreers = patchAtMounting(appName, elementGetter, sandbox, scopedCSS, excludeAssetFilter, speedySandBox);

/* ------------------------------------------ 3. 重置一些初始化时的副作用 ------------------------------------------*/
// 存在 rebuilder 则表明有些副作用需要重建
if (sideEffectsRebuildersAtMounting.length) {
sideEffectsRebuildersAtMounting.forEach((rebuild) => rebuild());
}

// clean up rebuilders
sideEffectsRebuilders = [];
},

/**
* 恢复 global 状态,使其能回到应用加载之前的状态
*/
async unmount() {
// record the rebuilders of window side effects (event listeners or timers)
// note that the frees of mounting phase are one-off as it will be re-init at next mounting
sideEffectsRebuilders = [...bootstrappingFreers, ...mountingFreers].map((free) => free());

sandbox.inactive();
},
};
}