blog icon indicating copy to clipboard operation
blog copied to clipboard

【bigo】qiankun-JS沙箱原理解析

Open Husbin opened this issue 2 years ago • 0 comments

qiankun-JS沙箱原理解析

之前分享过qiankun在组内的落地情况,简单分析了JS沙箱的加载流程和隔离原理。本文基于之前的分享,对qiankun的JS沙箱隔离原理进一步进行解析。

qiankun提供了三种JS沙箱:

  1. SnapshotSandbox
  2. LegacySandbox
  3. ProxySnadbox

后面两种统称为代理沙箱,因为都是基于Proxy实现的;不同场景条件下使用不同的沙箱。先回顾下JS沙箱的加载流程,简单看下qiankun是如何初始化沙箱的。

沙箱加载流程

当前的版本,默认情况下,不管单例还是多例,用的都是ProxySandbox,若浏览器环境不支持Proxy,则使用SnapshotSandbox,如果想要使用LegacySandbox,需要手动配置sandbox: { loose: true }。

流程图

image-20211108210159066

源码

// 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 的低版本浏览器。该沙箱主要有两个中间变量:

  1. windowSnapshot用于沙箱激活时记录当前window快照。
  2. modifyPropsMap用于沙箱卸载时记录变更,沙箱激活时还原变更。
沙箱激活/卸载流程
  1. 沙箱激活时,先遍历window,保存中windowSnapshot中;然后判断modifyPropsMap是否有值,有的话遍历,还原上一次沙箱卸载前的数据到window上。
  2. window数据发生修改时,直接将更改的数据作用到window对象上。
  3. 沙箱卸载时,先遍历当前window,与快照windowSnapshot进行diff对比,将diff结果保存到modifyPropsMap,然后将windowSnapshot上的沙箱初始值还原到window上。
流程图

image-20211112100516519

优点

兼容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实现的单例沙箱。该沙箱主要用到了三个变量:

  1. addedPropsMapInSandbox:用于记录沙箱激活期间新增的全局变量,用于还原window到初始状态。
  2. modifiedPropsOriginalValueMapInSandbox:用于记录沙箱激活期间更新的全局变量,用于还原window到初始状态。
  3. currentUpdatedPropsValueMap:持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot,用于沙箱激活时,还原window到上一次卸载前的状态。
沙箱激活/卸载流程
  1. 沙箱激活时,遍历currentUpdatedPropsValueMap,若有数据,则还原上一次卸载前的数据到window。

  2. window对象发生修改时,使用代理的set方法进行拦截:

    1. 判断window是否有该属性,没有的话则为新增数据,将该属性添加到addedPropsMapInSandbox对象中。

    2. 判断modifiedPropsOriginalValueMapInSandbox是否有该属性,没有的话说明该属性暂未被记录过,记录该属性的初始值。

    3. 将该属性记录到currentUpdatedPropsValueMap中,方便随时保存快照。

    4. 将该属性的变更作用到window,保证下次get时能拿到已更新的数据。

    企业微信截图_1c3cf345-b403-4c46-a468-e5f7c4fbcd0f
  3. 沙箱卸载时,遍历modifiedPropsOriginalValueMapInSandbox,若有修改数据,则还原;遍历addedPropsMapInSandbox,若有新增数据,删除,还原window到初始状态。

流程图

image-20211112100452625

优点

性能较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实现的沙箱,支持多例。

沙箱激活/卸载流程
  1. 激活沙箱后,每次获取window属性时,先从当前沙箱环境的fakeWindow里面查找,如果不存在,就从外部的rawWindow里面去查找。
  2. window对象发生修改时,使用代理的set方法进行拦截,直接操作代理对象fakeWindow,因此不会影响到全局的rawWindow,做到真正的隔离。
流程图
企业微信截图_14b7ab34-27c8-4ca8-8886-7a2f70bdd64e
优点

支持多例;不会污染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的复杂对象被污染了。

image-20211116203206903

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的复杂对象被污染了。

image-20211116203044417

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的复杂对象没有被污染,可以做到真正意义上的隔离。

image-20211116203345642

总结

以上就是我对qiankun沙箱原理的一些总结,qiankun源码的可读性比较强,推荐大伙一起去看看,也欢迎一起探讨学习,如有错误,欢迎指正~

Husbin avatar Nov 24 '21 10:11 Husbin