15分钟快速理解qiankun的js沙箱原理及其实现
15分钟快速理解qiankun的js沙箱原理及其实现
前言
qiankun框架为了实现js隔离,提供了三种不同场景使用的沙箱,分别是 snapshotSandbox
、proxySandbox
、legacySandbox
。
快照沙箱(snapshotSandbox)
从名字上我们可以理解快照就是给你着一张相片,来记录你此刻的状态。qiankun
的快照沙箱是基于diff
来实现的,主要用于不支持window.Proxy
的低版本浏览器,而且也只适应单个的子应用。(文章末尾附带demo地址)
snapshotSandbox原理
激活沙箱时,将
window
的快照信息存到windowSnapshot
中, 如果modifyPropsMap
有值,还需要还原上次的状态;激活期间,可能修改了window
的数据;退出沙箱时,将修改过的信息存到modifyPropsMap
里面,并且把window
还原成初始进入的状态。
snapshotSandbox源码
function iter(obj: typeof window, callbackFn: (prop: any) => void) {
// eslint-disable-next-line guard-for-in, no-restricted-syntax
for (const prop in obj) {
if (obj.hasOwnProperty(prop)) {
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;
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;
}
}
复制代码
snapshotSandbox优劣势
可以很明显的看到,snapshotSandbox
会污染全局window,但是可以支持不兼容Proxy
的浏览器。
snapshotSandbox Demo
demo是对源码的一个精简,去除了一些不必要的变量,方便理解。
const iter = (window, callback) => {
for (const prop in window) {
if(window.hasOwnProperty(prop)) {
callback(prop);
}
}
}
class SnapshotSandbox {
constructor() {
this.proxy = window;
this.modifyPropsMap = {};
}
// 激活沙箱
active() {
// 缓存active状态的window
this.windowSnapshot = {};
iter(window, (prop) => {
this.windowSnapshot[prop] = window[prop];
});
Object.keys(this.modifyPropsMap).forEach(p => {
window[p] = this.modifyPropsMap[p];
})
}
// 退出沙箱
inactive(){
iter(window, (prop) => {
if(this.windowSnapshot[prop] !== window[prop]) {
// 记录变更
this.modifyPropsMap[prop] = window[prop];
// 还原window
window[prop] = this.windowSnapshot[prop];
}
})
}
}
复制代码
一个SnapshotSandbox
的类我们就实现了,然后来测试一下
const sandbox = new SnapshotSandbox();
((window) => {
// 激活沙箱
sandbox.active();
window.sex= '男';
window.age = '22';
console.log(window.sex, window.age);
// 退出沙箱
sandbox.inactive();
console.log(window.sex, window.age);
// 激活沙箱
sandbox.active();
console.log(window.sex, window.age);
})(sandbox.proxy);
复制代码
打开浏览器,可以看到成功实现了一个快照沙箱隔离。
代理沙箱(proxySandbox)
qiankun
基于es6
的Proxy
实现了两种应用场景不同的沙箱,一种是legacySandbox
(单例),一种是proxySandbox
(多例)。因为都是基于Proxy实现的,所以都称为代理沙箱。
legacySandbox(单例沙箱)
legacySandbox原理
legacySandbox
设置了三个参数来记录全局变量,分别是记录沙箱新增的全局变量addedPropsMapInSandbox
、记录沙箱更新的全局变量modifiedPropsOriginalValueMapInSandbox
、持续记录更新的(新增和修改的)全局变量,用于在任意时刻做snapshot的currentUpdatedPropsValueMap
。
直接看流程图
legacySandbox源码
function isPropConfigurable(target: typeof window, prop: PropertyKey) {
const descriptor = Object.getOwnPropertyDescriptor(target, prop);
return descriptor ? descriptor.configurable : true;
}
function setWindowProp(prop: PropertyKey, value: any, toDelete?: boolean) {
if (value === undefined && toDelete) {
delete (window as any)[prop];
} else if (isPropConfigurable(window, prop) && typeof prop !== 'symbol') {
Object.defineProperty(window, prop, { writable: true, configurable: true });
(window as any)[prop] = value;
}
}
/**
* 基于 Proxy 实现的沙箱
* TODO: 为了兼容性 singular 模式下依旧使用该沙箱,等新沙箱稳定之后再切换
*/
export default class SingularProxySandbox implements SandBox {
/** 沙箱期间新增的全局变量 */
private addedPropsMapInSandbox = new Map<PropertyKey, any>();
/** 沙箱期间更新的全局变量 */
private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>();
/** 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot */
private currentUpdatedPropsValueMap = new Map<PropertyKey, any>();
name: string;
proxy: WindowProxy;
type: SandBoxType;
sandboxRunning = true;
latestSetProp: PropertyKey | null = null;
active() {
if (!this.sandboxRunning) {
this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v));
}
this.sandboxRunning = true;
}
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);
// restore global props to initial snapshot
this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => setWindowProp(p, v));
this.addedPropsMapInSandbox.forEach((_, p) => setWindowProp(p, undefined, true));
this.sandboxRunning = false;
}
constructor(name: string) {
this.name = name;
this.type = SandBoxType.LegacyProxy;
const { addedPropsMapInSandbox, modifiedPropsOriginalValueMapInSandbox, currentUpdatedPropsValueMap } = this;
const rawWindow = window;
const fakeWindow = Object.create(null) as Window;
const proxy = new Proxy(fakeWindow, {
set: (_: Window, p: PropertyKey, value: any): boolean => {
if (this.sandboxRunning) {
if (!rawWindow.hasOwnProperty(p)) {
addedPropsMapInSandbox.set(p, value);
} else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
// 如果当前 window 对象存在该属性,且 record map 中未记录过,则记录该属性初始值
const originalValue = (rawWindow as any)[p];
modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
}
currentUpdatedPropsValueMap.set(p, value);
// 必须重新设置 window 对象保证下次 get 时能拿到已更新的数据
// eslint-disable-next-line no-param-reassign
(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;
},
get(_: Window, p: PropertyKey): any {
// avoid who using window.window or window.self to escape the sandbox environment to touch the really window
// or use window.top to check if an iframe context
// see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') {
return proxy;
}
const value = (rawWindow as any)[p];
return getTargetValue(rawWindow, value);
},
// trap in operator
// see https://github.com/styled-components/styled-components/blob/master/packages/styled-components/src/constants.js#L12
has(_: Window, p: string | number | symbol): boolean {
return p in rawWindow;
},
getOwnPropertyDescriptor(_: Window, p: PropertyKey): PropertyDescriptor | undefined {
const descriptor = Object.getOwnPropertyDescriptor(rawWindow, p);
// 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;
},
});
this.proxy = proxy;
}
}
复制代码
legacySandbox优劣势
同样会对window造成污染,但是性能比快照沙箱好,不用遍历window对象。
legacySandbox Demo
对源码的一个精简,去除了一些不必要的变量,方便理解。
class Legacy {
constructor() {
// 沙箱期间新增的全局变量
this.addedPropsMapInSandbox = {};
// 沙箱期间更新的全局变量
this.modifiedPropsOriginalValueMapInSandbox = {};
// 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot
this.currentUpdatedPropsValueMap = {};
const rawWindow = window;
const fakeWindow = Object.create(null);
this.sandboxRunning = true;
const proxy = new Proxy(fakeWindow, {
set: (target, prop, value) => {
// 如果是激活状态
if(this.sandboxRunning) {
// 判断当前window上存不存在该属性
if(!rawWindow.hasOwnProperty(prop)) {
// 记录新增值
this.addedPropsMapInSandbox[prop] = value;
} else if(!this.modifiedPropsOriginalValueMapInSandbox[prop]) {
// 记录更新值的初始值
const originValue = rawWindow[prop]
this.modifiedPropsOriginalValueMapInSandbox[prop] = originValue;
}
// 纪录此次修改的属性
this.currentUpdatedPropsValueMap[prop] = value;
// 将设置的属性和值赋给了当前window,还是污染了全局window变量
rawWindow[prop] = value;
return true;
}
return true;
},
get: (target, prop) => {
return rawWindow[prop];
}
})
this.proxy = proxy;
}
active() {
if (!this.sandboxRunning) {
// 还原上次修改的值
for(const key in this.currentUpdatedPropsValueMap) {
window[key] = this.currentUpdatedPropsValueMap[key];
}
}
this.sandboxRunning = true;
}
inactive() {
// 将更新值的初始值还原给window
for(const key in this.modifiedPropsOriginalValueMapInSandbox) {
window[key] = this.modifiedPropsOriginalValueMapInSandbox[key];
}
// 将新增的值删掉
for(const key in this.addedPropsMapInSandbox) {
delete window[key];
}
this.sandboxRunning = false;
}
}
复制代码
然后来测试一下
window.sex= '男';
let LegacySandbox = new Legacy();
((window) => {
// 激活沙箱
LegacySandbox.active();
window.age = '22';
window.sex= '女';
console.log('激活', window.sex, window.age, LegacySandbox);
})(LegacySandbox.proxy);
复制代码
打开控制台,可以看到,在激活沙箱前,我们在window
上设置的sex='男'
这个属性,再开启沙箱后,又修改了sex
,成功的记录到了modifiedPropsOriginalValueMapInSandbox
里面。激活期间,修改的age
和sex
两个属性也记录到了currentUpdatedPropsValueMap
里面,同时,window
上新增的属性age
也记录到了addedPropsMapInSandbox
里面,与预期结果一样。 补充完整测试代码
+ // 退出沙箱
+ LegacySandbox.inactive();
+ console.log('退出', window.sex, window.age, LegacySandbox);
+ // 激活沙箱
+ LegacySandbox.active();
+ console.log('再次激活', window.sex, window.age, LegacySandbox);
复制代码
打开浏览器,可以看到最新的运行结果
proxySandbox(多例沙箱)
proxySandbox原理
激活沙箱后,每次对
window
取值的时候,先从自己沙箱环境的fakeWindow
里面找,如果不存在,就从rawWindow
(外部的window
)里去找;当对沙箱内部的window
对象赋值的时候,会直接操作fakeWindow
,而不会影响到rawWindow
。
proxySandbox源码
ProxySandbox类的实现,这里只展示了get
和set
方法,实际源码代理的方法还有has
、ownKeys
、getOwnPropertyDescriptor
、defineProperty
、deleteProperty
等方法。
/**
* 基于 Proxy 实现的沙箱
*/
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 (--activeSandboxCount === 0) {
variableWhiteList.forEach((p) => {
if (this.proxy.hasOwnProperty(p)) {
// @ts-ignore
delete window[p];
}
});
}
this.sandboxRunning = false;
}
constructor(name: string) {
this.name = name;
this.type = SandBoxType.Proxy;
const { updatedValueSet } = this;
const rawWindow = window;
const { fakeWindow, propertiesWithGetter } = createFakeWindow(rawWindow);
const descriptorTargetMap = new Map<PropertyKey, SymbolTarget>();
const hasOwnProperty = (key: PropertyKey) => fakeWindow.hasOwnProperty(key) || rawWindow.hasOwnProperty(key);
const proxy = new Proxy(fakeWindow, {
set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
if (this.sandboxRunning) {
// We must kept its description while the property existed in rawWindow before
if (!target.hasOwnProperty(p) && rawWindow.hasOwnProperty(p)) {
const descriptor = Object.getOwnPropertyDescriptor(rawWindow, p);
const { writable, configurable, enumerable } = descriptor!;
if (writable) {
Object.defineProperty(target, p, {
configurable,
enumerable,
writable,
value,
});
}
} else {
// @ts-ignore
target[p] = value;
}
if (variableWhiteList.indexOf(p) !== -1) {
// @ts-ignore
rawWindow[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 {
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;
}
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 (rawWindow === rawWindow.parent) {
return proxy;
}
return (rawWindow as any)[p];
}
// proxy.hasOwnProperty would invoke getter firstly, then its value represented as rawWindow.hasOwnProperty
if (p === 'hasOwnProperty') {
return hasOwnProperty;
}
// mark the symbol to document while accessing as document.createElement could know is invoked by which sandbox for dynamic append patcher
if (p === 'document' || p === 'eval') {
setCurrentRunningSandboxProxy(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
nextTick(() => setCurrentRunningSandboxProxy(null));
switch (p) {
case 'document':
return document;
case 'eval':
// eslint-disable-next-line no-eval
return eval;
// no default
}
}
// eslint-disable-next-line no-nested-ternary
const value = propertiesWithGetter.has(p)
? (rawWindow as any)[p]
: p in target
? (target as any)[p]
: (rawWindow as any)[p];
return getTargetValue(rawWindow, value);
}
});
this.proxy = proxy;
activeSandboxCount++;
}
}
复制代码
可以看到,源码里面是对fakeWindow
这个对象进行了代理,而这个对象是通过createFakeWindow
方法得到的,所以把createFakeWindow
方法的源码也展示一下,这个方法是将window
的document
、location
、top
、window
等等属性拷贝一份,给到fakeWindow
上,对于我们现实的demo,可以不用太过于关注。
function createFakeWindow(global: 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 exists as an own property of the target object or if it exists as a configurable own property of the target object.
*/
Object.getOwnPropertyNames(global)
.filter((p) => {
const descriptor = Object.getOwnPropertyDescriptor(global, p);
return !descriptor?.configurable;
})
.forEach((p) => {
const descriptor = Object.getOwnPropertyDescriptor(global, 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优劣势
不会污染全局window,支持多个子应用同时加载。
proxySandbox Demo
class ProxySandbox {
active() {
this.sandboxRunning = true;
}
inactive() {
this.sandboxRunning = false;
}
constructor() {
const rawWindow = window;
const fakeWindow = {};
const proxy = new Proxy(fakeWindow, {
set: (target, prop, value) => {
if(this.sandboxRunning) {
target[prop] = value;
return true;
}
},
get: (target, prop) => {
// 如果fakeWindow里面有,就从fakeWindow里面取,否则,就从外部的window里面取
let value = prop in target ? target[prop] : rawWindow[prop];
return value
}
})
this.proxy = proxy;
}
}
复制代码
直接测试demo
window.sex = '男';
let proxy1 = new ProxySandbox();
let proxy2 = new ProxySandbox();
((window) => {
proxy1.active();
console.log('修改前proxy1的sex', window.sex);
window.sex = '女';
console.log('修改后proxy1的sex', window.sex);
})(proxy1.proxy);
console.log('外部window.sex=>1', window.sex);
((window) => {
proxy2.active();
console.log('修改前proxy2的sex', window.sex);
window.sex = '111';
console.log('修改后proxy2的sex', window.sex);
})(proxy2.proxy);
console.log('外部window.sex=>2', window.sex);
复制代码
浏览器打印结果 可以看到,只有proxySandbox才是对window进行了一个真正的无污染环境。
如果看完对你有帮助的话,别忘了👍噢。