blog
blog copied to clipboard
【bigo】qiankun-JS沙箱原理解析
qiankun-JS沙箱原理解析
之前分享过qiankun在组内的落地情况,简单分析了JS沙箱的加载流程和隔离原理。本文基于之前的分享,对qiankun的JS沙箱隔离原理进一步进行解析。
qiankun提供了三种JS沙箱:
- SnapshotSandbox
- LegacySandbox
- ProxySnadbox
后面两种统称为代理沙箱,因为都是基于Proxy实现的;不同场景条件下使用不同的沙箱。先回顾下JS沙箱的加载流程,简单看下qiankun是如何初始化沙箱的。
沙箱加载流程
当前的版本,默认情况下,不管单例还是多例,用的都是ProxySandbox,若浏览器环境不支持Proxy,则使用SnapshotSandbox,如果想要使用LegacySandbox,需要手动配置sandbox: { loose: true }。
流程图
源码
// https://github.com/umijs/qiankun/blob/master/src/sandbox/index.ts
export function createSandboxContainer(
appName: string,
elementGetter: () => HTMLElement | ShadowRoot,
scopedCSS: boolean,
useLooseSandbox?: boolean,
excludeAssetFilter?: (url: string) => boolean,
globalContext?: typeof window,
) {
let sandbox: SandBox;
// 当前环境是否支持Proxy
if (window.Proxy) {
// 是否配置loose模式
sandbox = useLooseSandbox ? new LegacySandbox(appName, globalContext) : new ProxySandbox(appName, globalContext);
} else {
// 不支持Proxy
sandbox = new SnapshotSandbox(appName);
}
return {
instance: sandbox,
async mount() {
sandbox.active();
},
async unmount() {
sandbox.inactive();
},
};
}
沙箱隔离原理
SnapshopSandbox
SnapshopSandbox是基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器。该沙箱主要有两个中间变量:
-
windowSnapshot
用于沙箱激活时记录当前window快照。 -
modifyPropsMap
用于沙箱卸载时记录变更,沙箱激活时还原变更。
沙箱激活/卸载流程
- 沙箱激活时,先遍历window,保存中windowSnapshot中;然后判断modifyPropsMap是否有值,有的话遍历,还原上一次沙箱卸载前的数据到window上。
- window数据发生修改时,直接将更改的数据作用到window对象上。
- 沙箱卸载时,先遍历当前window,与快照windowSnapshot进行diff对比,将diff结果保存到modifyPropsMap,然后将windowSnapshot上的沙箱初始值还原到window上。
流程图
优点
兼容IE。
缺点
单例沙箱;遍历window进行diff,性能差;会污染到window。
源码
// https://github.com/umijs/qiankun/blob/master/src/sandbox/snapshotSandbox.ts
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 = {};
// 遍历window,从快照中获取初始值,恢复环境
iter(window, (prop) => {
if (window[prop] !== this.windowSnapshot[prop]) {
// 记录变更
this.modifyPropsMap[prop] = window[prop];
window[prop] = this.windowSnapshot[prop];
}
});
this.sandboxRunning = false;
}
}
LegacySandbox
LegacySandbox是基于Proxy实现的单例沙箱。该沙箱主要用到了三个变量:
- addedPropsMapInSandbox:用于记录沙箱激活期间新增的全局变量,用于还原window到初始状态。
- modifiedPropsOriginalValueMapInSandbox:用于记录沙箱激活期间更新的全局变量,用于还原window到初始状态。
- currentUpdatedPropsValueMap:持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot,用于沙箱激活时,还原window到上一次卸载前的状态。
沙箱激活/卸载流程
-
沙箱激活时,遍历currentUpdatedPropsValueMap,若有数据,则还原上一次卸载前的数据到window。
-
window对象发生修改时,使用代理的set方法进行拦截:
-
判断window是否有该属性,没有的话则为新增数据,将该属性添加到addedPropsMapInSandbox对象中。
-
判断modifiedPropsOriginalValueMapInSandbox是否有该属性,没有的话说明该属性暂未被记录过,记录该属性的初始值。
-
将该属性记录到currentUpdatedPropsValueMap中,方便随时保存快照。
-
将该属性的变更作用到window,保证下次get时能拿到已更新的数据。
-
-
沙箱卸载时,遍历modifiedPropsOriginalValueMapInSandbox,若有修改数据,则还原;遍历addedPropsMapInSandbox,若有新增数据,删除,还原window到初始状态。
流程图
优点
性能较snapshotSandbox好,因为不需要遍历进行diff。
缺点
单例;不支持IE;会污染window。
源码
// https://github.com/umijs/qiankun/blob/master/src/sandbox/legacy/sandbox.ts
export default class LegacySandbox 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;
globalContext: typeof window;
type: SandBoxType;
sandboxRunning = true;
// 操作window,用于激活恢复沙箱/卸载还原window
private setWindowProp(prop: PropertyKey, value: any, toDelete?: boolean) {
if (value === undefined && toDelete) {
// eslint-disable-next-line no-param-reassign
delete (this.globalContext as any)[prop];
} else if (isPropConfigurable(this.globalContext, prop) && typeof prop !== 'symbol') {
Object.defineProperty(this.globalContext, prop, { writable: true, configurable: true });
// eslint-disable-next-line no-param-reassign
(this.globalContext as any)[prop] = value;
}
}
active() {
if (!this.sandboxRunning) {
// 恢复window至上次卸载前的状态
this.currentUpdatedPropsValueMap.forEach((v, p) => this.setWindowProp(p, v));
}
this.sandboxRunning = true;
}
inactive() {
// 将window上修改过的属性还原
this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => this.setWindowProp(p, v));
// 将window上新增的属性还原
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;
// set拦截,用于保存变更,刷新window
const setTrap = (p: PropertyKey, value: any, originalValue: any, sync2Window = true) => {
if (this.sandboxRunning) {
if (!rawWindow.hasOwnProperty(p)) {
// 如果当前 window 对象不存在该属性,则将该属性记录到addedPropsMapInSandbox中
addedPropsMapInSandbox.set(p, value);
} else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
// 如果当前 window 对象存在该属性,且 record map 中未记录过,则记录该属性初始值
modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
}
// 将数据保存至快照
currentUpdatedPropsValueMap.set(p, value);
if (sync2Window) {
// 必须重新设置 window 对象保证下次 get 时能拿到已更新的数据
(rawWindow as any)[p] = value;
}
this.latestSetProp = p;
return true;
}
return true;
};
const proxy = new Proxy(fakeWindow, {
// proxy拦截,此处拦截了很多方法,具体见源码
set: (_: Window, p: PropertyKey, value: any): boolean => {
const originalValue = (rawWindow as any)[p];
return setTrap(p, value, originalValue, true);
},
});
this.proxy = proxy;
}
}
ProxySandbox
ProxySandbox是基于Proxy实现的沙箱,支持多例。
沙箱激活/卸载流程
- 激活沙箱后,每次获取window属性时,先从当前沙箱环境的fakeWindow里面查找,如果不存在,就从外部的rawWindow里面去查找。
- window对象发生修改时,使用代理的set方法进行拦截,直接操作代理对象fakeWindow,因此不会影响到全局的rawWindow,做到真正的隔离。
流程图
优点
支持多例;不会污染window。
缺点
不支持IE。
源码
// https://github.com/umijs/qiankun/blob/master/src/sandbox/proxySandbox.ts
const variableWhiteListInDev =
process.env.NODE_ENV === 'development' || window.__QIANKUN_DEVELOPMENT__
? ['__REACT_ERROR_OVERLAY_GLOBAL_HOOK__'] : [];
// who could escape the sandbox
const variableWhiteList: PropertyKey[] = ['System', '__cjsWrapper', ...variableWhiteListInDev ];
let activeSandboxCount = 0;
/**
* 基于 Proxy 实现的沙箱
*/
export default class ProxySandbox implements SandBox {
/** window 值变更记录 */
private updatedValueSet = new Set<PropertyKey>();
name: string;
type: SandBoxType;
proxy: WindowProxy;
globalContext: typeof window;
sandboxRunning = true;
latestSetProp: PropertyKey | null = null;
private registerRunningApp(name: string, proxy: Window) {
if (this.sandboxRunning) {
setCurrentRunningApp({ name, window: proxy });
nextTask(() => {
setCurrentRunningApp(null);
});
}
}
active() {
if (!this.sandboxRunning) activeSandboxCount++;
this.sandboxRunning = true;
}
inactive() {
if (--activeSandboxCount === 0) {
variableWhiteList.forEach((p) => {
if (this.proxy.hasOwnProperty(p)) {
// @ts-ignore
delete this.globalContext[p];
}
});
}
this.sandboxRunning = false;
}
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, {
set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
// 此处操作的全局对象target是代理的fakeWindow,不是真实的window对象。
if (this.sandboxRunning) {
this.registerRunningApp(name, proxy);
if (!target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, 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
globalContext[p] = value;
}
updatedValueSet.add(p);
this.latestSetProp = p;
return true;
}
return true;
},
get: (target: FakeWindow, p: PropertyKey): any => {
this.registerRunningApp(name, proxy);
// 若target(fakeWindow)存在该属性,则返回,不存在则从rawWindow上查找
const value = propertiesWithGetter.has(p)
? (globalContext as any)[p]
: p in target
? (target as any)[p]
: (globalContext as any)[p];
return getTargetValue(globalContext, value);
},
});
this.proxy = proxy;
activeSandboxCount++;
}
}
模拟
下面对几种沙箱的源码进行精简,在源代码的基础上进行删减,方便理解。模拟过程中使用了ts-node来跑ts脚本: npx ts-node a.ts
。
准备一些初始的数据:
// interface
export type SandBox = {
/** 沙箱导出的代理实体 */
proxy: MockWindow;
/** 沙箱是否在运行中 */
sandboxRunning: boolean;
/** latest set property */
active: () => void;
/** 关闭沙箱 */
inactive: () => void;
};
export type MockWindow = any;
// node 环境 模拟简单版的window
var window: MockWindow = {
name: "bigo",
a: {
b: {
c: {
d: 123
}
}
}
}
SnapshopSandbox
// snapshotSandbox.ts
import { SandBox, MockWindow } from "./interface";
function iter(obj: typeof window, callbackFn: (prop: any) => void) {
for (const prop in obj) {
if (obj.hasOwnProperty(prop) || prop === "clearInterval") {
callbackFn(prop);
}
}
}
class SnapshotSandbox implements SandBox {
sandboxRunning = true;
private windowSnapshot!: MockWindow;
private modifyPropsMap: Record<any, any> = {};
constructor() {
this.proxy = window;
this.modifyPropsMap = {};
}
proxy: WindowProxy;
active() {
// 记录当前快照
this.windowSnapshot = {} as MockWindow;
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];
}
});
this.sandboxRunning = false;
}
}
const snapshotSandbox = new SnapshotSandbox();
console.log(`window初始值: ${JSON.stringify(window)}\n`);
((window: MockWindow) => {
// 激活沙箱
snapshotSandbox.active();
window.name = "bigo-active";
window.a.b.c.d = 234;
console.log(`active1: ${window.name}, ${window.a.b.c.d}`);
// 退出沙箱
snapshotSandbox.inactive();
console.log(`inactive: ${window.name}, ${window.a.b.c.d}`);
// 激活沙箱
snapshotSandbox.active();
console.log(`re-active: ${window.name}, ${window.a.b.c.d}\n`);
})(snapshotSandbox.proxy);
console.warn(`沙箱激活/卸载后的window值: ${JSON.stringify(window)}`);
执行npx ts-node snapshotSandbox.ts
结果如下,可以看到window的复杂对象被污染了。
LegacySandbox
import { SandBox, MockWindow } from "./interface";
class LegacySandbox implements SandBox {
/** 沙箱期间新增的全局变量 */
private addedPropsMapInSandbox = new Map<PropertyKey, any>();
/** 沙箱期间更新的全局变量 */
private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>();
/** 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot */
private currentUpdatedPropsValueMap = new Map<PropertyKey, any>();
proxy: WindowProxy;
sandboxRunning = true;
constructor() {
const rawWindow = window;
const fakeWindow = Object.create(null) as MockWindow;
const setTrap = (p: PropertyKey, value: any, originalValue: any) => {
if (this.sandboxRunning) {
if (!rawWindow.hasOwnProperty(p)) {
// 新增字段,记录到addedPropsMapInSandbox
this.addedPropsMapInSandbox.set(p, value);
} else if (!this.modifiedPropsOriginalValueMapInSandbox.has(p)) {
// 如果当前 window 对象存在该属性,且 record map 中未记录过,则记录该属性初始值
this.modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
}
this.currentUpdatedPropsValueMap.set(p, value);
// 必须重新设置 window 对象保证下次 get 时能拿到已更新的数据
(rawWindow as any)[p] = value;
return true;
}
return true;
};
const proxy = new Proxy(fakeWindow, {
// 拦截方法
set: (_: MockWindow, p: PropertyKey, value: any): boolean => {
const originalValue = (rawWindow as any)[p];
return setTrap(p, value, originalValue);
},
get(_: MockWindow, p: PropertyKey): any {
return rawWindow[p];
},
});
this.proxy = proxy;
}
active() {
if (!this.sandboxRunning) {
// 激活,还原上次卸载前的数据
this.currentUpdatedPropsValueMap.forEach((v, p) => window[p] = v);
}
this.sandboxRunning = true;
}
inactive() {
// 卸载,还原window数据
this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => window[p] = v);
// 删除window新增数据
this.addedPropsMapInSandbox.forEach((_, p) => delete window[p]);
this.sandboxRunning = false;
}
}
let legacySandbox = new LegacySandbox();
console.log(`window初始值: ${JSON.stringify(window)}\n`);
((window: MockWindow) => {
// 激活沙箱
legacySandbox.active();
window.name = "bigo-active";
window.a.b.c.d = 234;
console.log(`active: ${window.name}, ${window.a.b.c.d}`);
// 退出沙箱
legacySandbox.inactive();
console.log(`inactive: ${window.name}, ${window.a.b.c.d}\n`);
// 激活沙箱
legacySandbox.active();
console.log(`re-active: ${window.name}, ${window.a.b.c.d}`);
legacySandbox.inactive();
console.log(`re-inactive: ${window.name}, ${window.a.b.c.d}\n`);
})(legacySandbox.proxy);
console.warn(`沙箱激活/卸载后的window值: ${JSON.stringify(window)}`);
执行npx ts-node legacySnadbox.ts
结果如下,可以看到window的复杂对象被污染了。
ProxySandbox
import type { SandBox, MockWindow } from "./interface";
type FakeWindow = MockWindow & Record<PropertyKey, any>;
class ProxySandbox implements SandBox {
proxy: WindowProxy;
sandboxRunning = true;
latestSetProp: PropertyKey | null = null;
active() {
this.sandboxRunning = true;
}
inactive() {
this.sandboxRunning = false;
}
constructor() {
// 深拷贝,简单版
const fakeWindow = JSON.parse(JSON.stringify(window))
const proxy = new Proxy(fakeWindow, {
set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
// 设置值时只操作fakeWindow
if (this.sandboxRunning) {
target[p] = value;
}
return true;
},
get: (target: FakeWindow, p: PropertyKey): any => {
// 先从fakeWindow获取,获取不到则从rawWindow获取
return target[p] ? target[p] : window[p];
},
});
this.proxy = proxy;
}
}
let proxysandbox1 = new ProxySandbox();
let proxySandbox2 = new ProxySandbox();
console.log(`window初始值: ${JSON.stringify(window)}\n`);
((window: MockWindow) => {
// 激活沙箱
proxysandbox1.active();
window.name = "bigo-active1";
window.a.b.c.d = 234;
console.log(`active1: ${window.name}, ${window.a.b.c.d}`);
// 退出沙箱
proxysandbox1.inactive();
console.log(`inactive1: ${window.name}, ${window.a.b.c.d}`);
// 激活沙箱
proxysandbox1.active();
console.log(`re-active1: ${window.name}, ${window.a.b.c.d}`);
proxysandbox1.inactive();
console.log(`re-inactive1: ${window.name}, ${window.a.b.c.d}\n`);
})(proxysandbox1.proxy);
((window: MockWindow) => {
// 激活沙箱
proxySandbox2.active();
window.name = "bigo-active2";
window.a.b.c.d = 345;
console.log(`active2: ${window.name}, ${window.a.b.c.d}`);
// 退出沙箱
proxysandbox1.inactive();
console.log(`inactive2: ${window.name}, ${window.a.b.c.d}`);
// 激活沙箱
proxysandbox1.active();
console.log(`re-active2: ${window.name}, ${window.a.b.c.d}\n`);
proxysandbox1.inactive();
console.log(`re-inactive2: ${window.name}, ${window.a.b.c.d}\n`);
})(proxySandbox2.proxy);
console.warn(`沙箱激活/卸载后的window值: ${JSON.stringify(window)}`);
执行npx ts-node proxySandbox.ts
结果如下,可以看到window的复杂对象没有被污染,可以做到真正意义上的隔离。
总结
以上就是我对qiankun沙箱原理的一些总结,qiankun源码的可读性比较强,推荐大伙一起去看看,也欢迎一起探讨学习,如有错误,欢迎指正~