1、Js隔离机制三种沙箱的发展史
照沙箱:
最初乾坤框架只有一种沙箱,即快照沙箱,它使用 SnapshotSandbox 类来实现。但是快照沙箱有一个缺点,就是需要遍历 window 上的所有属性,性能较差
。
LegacySandbox:
随着ES6的普及,可以使用 Proxy 来解决这个问题,于是就诞生了 LegacySandbox 可以实现和快照沙箱一样的功能,但是性能更好。由于 LegacySandbox 也会污染全局的 window,因此它仅允许页面同时运行一个微应用,我们也称之为支持单应用的代理沙箱。
ProxySandbox:
随着业务的发展,我们需要支持一个页面运行多个微应用,于是就有了 ProxySandbox,它可以支持多个微应用同时运行。因此,我们称之为支持多应用的代理沙箱。实际上,LegacySandbox在未来可能会被淘汰,因为 ProxySandbox 可以做到 LegacySandbox 的所有功能,而快照沙箱由于向下兼容的原因,可能会长期存在。
2、三个沙箱的核心逻辑编码实现
快照沙箱
快照沙箱的核心逻辑非常简单,它在激活和失活时各做两件事情。在激活时,它会记录window的状态,也就是快照,以便在失活时恢复到之前的状态。同时,它会恢复上一次失活时记录的沙箱运行过程中对window做的状态改变,保持一致。在失活时,它会记录window上发生了哪些状态变化,并清除沙箱在激活后对window做的状态改变,以便恢复到未改变之前的状态。
然而,快照沙箱存在两个重要的问题。首先,它会改变全局window的属性,如果同时运行多个微应用,多个应用同时改写window上的属性,就会出现状态混乱。这也是为什么快照沙箱无法支持多个微应用同时运行的原因。其次,它会通过for(prop in window){}的方式来遍历window上的所有属性,这是一件非常耗费性能的事情,因为window属性众多。
为了解决这些问题,乾坤框架引入了支持单应用的代理沙箱和支持多应用的代理沙箱。这两种沙箱机制都可以规避快照沙箱的问题。支持单应用的代理沙箱使用Proxy来代理window对象,以便在微应用运行时不会污染全局的window属性。支持多应用的代理沙箱则可以支持多个微应用同时运行,因为它可以为每个微应用创建一个独立的沙箱环境,避免了多个应用之间的状态混乱问题。同时,支持多应用的代理沙箱也不需要遍历window上的所有属性,因为它只会代理微应用需要的属性,从而提高了性能。
js
import type { SandBox } from '../interfaces';
import { SandBoxType } from '../interfaces';
function iter(obj: typeof window, callbackFn: (prop: any) => void) {
for (const prop in obj) {
if (obj.hasOwnProperty(prop) || prop === 'clearInterval') {
callbackFn(prop);
}
}
}
/**
* 基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器
*/
export default class SnapshotSandbox implements SandBox {
proxy: WindowProxy;
name: string;
type: SandBoxType;
sandboxRunning = true;
// 存储属性快照
private windowSnapshot!: Window;
// 修改的属性对象
private modifyPropsMap: Record<any, any> = {};
constructor(name: string) {
this.name = name;
this.proxy = window;
this.type = SandBoxType.Snapshot;
}
active() {
// 记录当前快照
this.windowSnapshot = {} as Window;
// 从 window 上获取属性
iter(window, (prop) => {
this.windowSnapshot[prop] = window[prop];
});
// 激活恢复快照数据
Object.keys(this.modifyPropsMap).forEach((p: any) => {
window[p] = this.modifyPropsMap[p];
});
this.sandboxRunning = true;
}
inactive() {
this.modifyPropsMap = {};
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;
}
patchDocument(): void {}
}
LegacySandbox 沙箱(支持单应用的代理沙箱)
- addedPropsMapInSandbox:用于记录沙箱激活期间新增的全局变量。
- modifiedPropsOriginalValueMapInSandbox:用于记录沙箱激活期间更新的全局变量。
- currentUpdatedPropsValueMap:持续记录更新的(新增和修改的)全局变量。
类似于快照沙箱的功能,即记录window对象的状态,并在沙箱失活时恢复window对象的状态。不同之处在于,LegacySandbox使用了三个变量来记录沙箱激活后window发生变化过的所有属性,避免了遍历window的所有属性来进行对比,提高了程序运行的性能。但是,这种机制仍然会改变window的状态,因此无法承担同时支持多个微应用运行的任务。因此,乾坤框架引入了支持单应用的代理沙箱和支持多应用的代理沙箱,以避免这个问题。支持单应用的代理沙箱使用Proxy来代理window对象,以便在微应用运行时不会污染全局的window属性。支持多应用的代理沙箱则可以为每个微应用创建一个独立的沙箱环境,避免了多个应用之间的状态混乱问题。
js
/**
* @author Kuitos
* @since 2019-04-11
*/
import type { SandBox } from '../../interfaces';
import { SandBoxType } from '../../interfaces';
import { getTargetValue } from '../common';
function isPropConfigurable(target: WindowProxy, prop: PropertyKey) {
const descriptor = Object.getOwnPropertyDescriptor(target, prop);
return descriptor ? descriptor.configurable : true;
}
/**
* 基于 Proxy 实现的沙箱
* TODO: 为了兼容性 singular 模式下依旧使用该沙箱,等新沙箱稳定之后再切换
*/
export default class LegacySandbox implements SandBox {
// 新增的变量
private addedPropsMapInSandbox = new Map<PropertyKey, any>();
// 更新的变量
private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>();
// 记录全部新增和修改的变量
private currentUpdatedPropsValueMap = new Map<PropertyKey, any>();
name: string;
proxy: WindowProxy; // proxy 拦截器
// 保存全局属性
globalContext: typeof window;
type: SandBoxType;
sandboxRunning = true;
// 记录最后更新的属性 key
latestSetProp: PropertyKey | null = null;
// 设置、删除 globalContext 的属性值
private setWindowProp(prop: PropertyKey, value: any, toDelete?: boolean) {
if (value === undefined && toDelete) {
// 删除属性
delete (this.globalContext as any)[prop];
} else if (isPropConfigurable(this.globalContext, prop) && typeof prop !== 'symbol') {
// 设置属性
Object.defineProperty(this.globalContext, prop, { writable: true, configurable: true });
(this.globalContext as any)[prop] = value;
}
}
// 启动沙箱 - 重置 globalContext
active() {
if (!this.sandboxRunning) {
this.currentUpdatedPropsValueMap.forEach((v, p) => this.setWindowProp(p, v));
}
this.sandboxRunning = true;
}
// 关闭沙箱 - 重置 globalContext
inactive() {
if (process.env.NODE_ENV === 'development') {
console.info(`[qiankun:sandbox] ${this.name} modified global properties restore...`, [
...this.addedPropsMapInSandbox.keys(),
...this.modifiedPropsOriginalValueMapInSandbox.keys(),
]);
}
// renderSandboxSnapshot = snapshot(currentUpdatedPropsValueMapForSnapshot);
this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => this.setWindowProp(p, v));
this.addedPropsMapInSandbox.forEach((_, p) => this.setWindowProp(p, undefined, true));
this.sandboxRunning = false;
}
constructor(name: string, globalContext = window) {
this.name = name;
this.globalContext = globalContext;
this.type = SandBoxType.LegacyProxy;
const {
addedPropsMapInSandbox,
modifiedPropsOriginalValueMapInSandbox,
currentUpdatedPropsValueMap,
} = this;
const rawWindow = globalContext;
const fakeWindow = Object.create(null) as Window;
// 设置属性
const setTrap = (p: PropertyKey, value: any, originalValue: any, sync2Window = true) => {
// 运行中才可以设置值
if (this.sandboxRunning) {
if (!rawWindow.hasOwnProperty(p)) {
// rawWindow 上不存在 p 属性
addedPropsMapInSandbox.set(p, value);
} else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
// 沙箱更新期间不存在
modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
}
// 记录全部变更的值
currentUpdatedPropsValueMap.set(p, value);
// 必须重新设置 window 对象保证下次 get 时能拿到已更新的数据
if (sync2Window) {
(rawWindow as any)[p] = value;
}
// 最后更新的属性
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;
};
const proxy = new Proxy(fakeWindow, {
// 设置属性值
set: (_: Window, p: PropertyKey, value: any): boolean => {
const originalValue = (rawWindow as any)[p];
return setTrap(p, value, originalValue, true);
},
// 获取属性值
get(_: Window, p: PropertyKey): any {
if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') {
return proxy;
}
const value = (rawWindow as any)[p];
return getTargetValue(rawWindow, value);
},
// 是否包含某属性
has(_: Window, p: string | number | symbol): boolean {
return p in rawWindow;
},
// 获取属性描述
getOwnPropertyDescriptor(_: Window, p: PropertyKey): PropertyDescriptor | undefined {
const descriptor = Object.getOwnPropertyDescriptor(rawWindow, p);
if (descriptor && !descriptor.configurable) {
descriptor.configurable = true;
}
return descriptor;
},
// 新增属性
defineProperty(_: Window, p: string | symbol, attributes: PropertyDescriptor): boolean {
const originalValue = (rawWindow as any)[p];
const done = Reflect.defineProperty(rawWindow, p, attributes);
const value = (rawWindow as any)[p];
setTrap(p, value, originalValue, false);
return done;
},
});
this.proxy = proxy;
}
patchDocument(): void {}
}
ProxySandbox 沙箱(支持多应用的代理沙箱)
- 在沙箱激活后,每次获取window属性时,会先从当前沙箱环境的fakeWindow里面查找,如果不存在,就从外部的window里面去查找。这样做可以保证沙箱内部的操作不会影响到全局的window对象。
- 同时,当window对象发生修改时,使用代理的set方法进行拦截,直接操作代理对象fakeWindow,而不是全局的window对象,从而实现真正的隔离。这种机制可以避免不同微应用之间的状态混乱问题,保证微应用之间的独立性。
ProxySandbox不存在状态恢复的逻辑,因为所有的变化都是沙箱内部的变化,和window没有关系,window上的属性自始至终都没有受到过影响。ProxySandbox支持多个微应用同时运行,也支持单个微应用运行,因此已经成为了乾坤框架的主要沙箱机制。而LegacySandbox则因为历史原因而存在,其在未来的意义不大。而SnapshotSandbox则因为Proxy在低版本浏览器中无法兼容而长期存在。虽然这里的代码逻辑很简单,但是在实际应用中,ProxySandbox需要支持多个微应用运行,因此其内部的逻辑会比SnapshotSandbox和LegacySandbox更加丰富。总之,理解了上述沙箱机制的思路,就可以理解乾坤框架的Js隔离机制
js
/* eslint-disable no-param-reassign */
import { without } from 'lodash';
/**
* @author Kuitos
* @since 2020-3-31
*/
import type { SandBox } from '../interfaces';
import { SandBoxType } from '../interfaces';
import { isPropertyFrozen, nativeGlobal, nextTask } from '../utils';
import {
clearCurrentRunningApp,
getCurrentRunningApp,
getTargetValue,
setCurrentRunningApp,
} from './common';
import { globals } from './globals';
type SymbolTarget = 'target' | 'globalContext';
type FakeWindow = Window & Record<PropertyKey, any>;
// 数据中唯一方法
function uniq(array: Array<string | symbol>) {
return array.filter(function filter(this: PropertyKey[], element) {
return element in this ? false : ((this as any)[element] = true);
}, Object.create(null));
}
// zone.js will overwrite Object.defineProperty
const rawObjectDefineProperty = Object.defineProperty;
const variableWhiteListInDev =
process.env.NODE_ENV === 'test' ||
process.env.NODE_ENV === 'development' ||
window.__QIANKUN_DEVELOPMENT__
? ['__REACT_ERROR_OVERLAY_GLOBAL_HOOK__', 'event']
: [];
// 白名单字段
const globalVariableWhiteList: string[] = ['System', '__cjsWrapper', ...variableWhiteListInDev];
const inTest = process.env.NODE_ENV === 'test';
const mockSafariTop = 'mockSafariTop';
const mockTop = 'mockTop';
const mockGlobalThis = 'mockGlobalThis';
// these globals should be recorded while accessing every time
const accessingSpiedGlobals = ['document', 'top', 'parent', 'eval'];
const overwrittenGlobals = ['window', 'self', 'globalThis', 'hasOwnProperty'].concat(
inTest ? [mockGlobalThis] : [],
);
export const cachedGlobals = Array.from(
new Set(
without(
globals.concat(overwrittenGlobals).concat('requestAnimationFrame'),
...accessingSpiedGlobals,
),
),
);
// transform cachedGlobals to object for faster element check
const cachedGlobalObjects = cachedGlobals.reduce(
(acc, globalProp) => ({ ...acc, [globalProp]: true }),
{},
);
const unscopables = without(
cachedGlobals,
...accessingSpiedGlobals.concat(overwrittenGlobals),
).reduce((acc, key) => ({ ...acc, [key]: true }), Object.create(null));
const useNativeWindowForBindingsProps = new Map<PropertyKey, boolean>([
['fetch', true],
['mockDomAPIInBlackList', process.env.NODE_ENV === 'test'],
]);
// 伪造 Window
function createFakeWindow(globalContext: Window, speedy: boolean) {
const propertiesWithGetter = new Map<PropertyKey, boolean>();
const fakeWindow = {} as FakeWindow;
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');
if (
p === 'top' ||
p === 'parent' ||
p === 'self' ||
p === 'window' ||
// window.document is overwriting in speedy mode
(p === 'document' && speedy) ||
(inTest && (p === mockTop || p === mockSafariTop))
) {
descriptor.configurable = true;
if (!hasGetter) {
descriptor.writable = true;
}
}
if (hasGetter) propertiesWithGetter.set(p, true);
rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
}
});
return {
fakeWindow,
propertiesWithGetter,
};
}
// 记录激活沙箱的数量
let activeSandboxCount = 0;
/**
* 基于 Proxy 实现的沙箱
*/
export default class ProxySandbox implements SandBox {
// 值变更记录
private updatedValueSet = new Set<PropertyKey>();
private document = document;
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 (inTest || --activeSandboxCount === 0) {
// 将全局值重置为前一个值
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;
}
public patchDocument(doc: Document) {
this.document = doc;
}
// 白名单中未被修改的全局变量的描述符
globalWhitelistPrevDescriptor: {
[p in (typeof globalVariableWhiteList)[number]]: PropertyDescriptor | undefined;
} = {};
globalContext: typeof window;
constructor(name: string, globalContext = window, opts?: { speedy: boolean }) {
this.name = name;
this.globalContext = globalContext;
this.type = SandBoxType.Proxy;
const { updatedValueSet } = this;
const { speedy } = opts || {};
const { fakeWindow, propertiesWithGetter } = createFakeWindow(globalContext, !!speedy);
const descriptorTargetMap = new Map<PropertyKey, SymbolTarget>();
const proxy = new Proxy(fakeWindow, {
// 设置属性值
set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
if (this.sandboxRunning) {
// 设置沙箱状态
this.registerRunningApp(name, proxy);
// sync the property to globalContext
if (typeof p === 'string' && globalVariableWhiteList.indexOf(p) !== -1) {
this.globalWhitelistPrevDescriptor[p] = Object.getOwnPropertyDescriptor(
globalContext,
p,
);
globalContext[p] = value;
} else {
if (!target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
const { writable, configurable, enumerable, set } = descriptor!;
if (writable || set) {
Object.defineProperty(target, p, {
configurable,
enumerable,
writable: true,
value,
});
}
} else {
target[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: (target: FakeWindow, p: PropertyKey): any => {
this.registerRunningApp(name, proxy);
if (p === Symbol.unscopables) return unscopables;
if (p === 'window' || p === 'self') {
return proxy;
}
if (p === 'globalThis' || (inTest && p === mockGlobalThis)) {
return proxy;
}
if (p === 'top' || p === 'parent' || (inTest && (p === mockTop || p === mockSafariTop))) {
if (globalContext === globalContext.parent) {
return proxy;
}
return (globalContext as any)[p];
}
if (p === 'hasOwnProperty') {
return hasOwnProperty;
}
if (p === 'document') {
return this.document;
}
if (p === 'eval') {
return eval;
}
if (p === 'string' && globalVariableWhiteList.indexOf(p) !== -1) {
// @ts-ignore
return globalContext[p];
}
const actualTarget = propertiesWithGetter.has(p)
? globalContext
: p in target
? target
: globalContext;
const value = actualTarget[p];
if (isPropertyFrozen(actualTarget, p)) {
return value;
}
const boundTarget = useNativeWindowForBindingsProps.get(p) ? nativeGlobal : globalContext;
return getTargetValue(boundTarget, value);
},
has(target: FakeWindow, p: string | number | symbol): boolean {
return p in cachedGlobalObjects || p in target || p in globalContext;
},
getOwnPropertyDescriptor(
target: FakeWindow,
p: string | number | symbol,
): PropertyDescriptor | undefined {
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');
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);
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);
},
});
this.proxy = proxy;
activeSandboxCount++;
function hasOwnProperty(this: any, key: PropertyKey): boolean {
// calling from hasOwnProperty.call(obj, key)
if (this !== proxy && this !== null && typeof this === 'object') {
return Object.prototype.hasOwnProperty.call(this, key);
}
return fakeWindow.hasOwnProperty(key) || globalContext.hasOwnProperty(key);
}
}
// 设置当前沙箱状态
private registerRunningApp(name: string, proxy: Window) {
if (this.sandboxRunning) {
const currentRunningApp = getCurrentRunningApp();
if (!currentRunningApp || currentRunningApp.name !== name) {
setCurrentRunningApp({ name, window: proxy });
}
nextTask(clearCurrentRunningApp);
}
}
}