Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
Husbin opened this issue Nov 24, 2021 · 0 comments
Open

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

Husbin opened this issue Nov 24, 2021 · 0 comments

Comments

@Husbin
Copy link

Husbin commented Nov 24, 2021

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源码的可读性比较强,推荐大伙一起去看看,也欢迎一起探讨学习,如有错误,欢迎指正~

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant