qiankun源码分析-6.js沙箱

290 阅读7分钟

沙箱

什么是沙箱?沙箱是一种隔离机制,它可以将不同的代码隔离开,防止相互污染,举例来说:我们知道,子应用是可以通过window对象来访问主应用的全局变量,那么如果子应用修改了主应用的全局变量,那么主应用的全局变量就会被污染,这就是我们需要沙箱的原因。

众所周知,iframe是天然的沙箱,但是iframe也有缺点,比如通信问题、样式隔离、性能问题等,所以qiankun实现了自己的沙箱,qiankun沙箱有三种实现方式,分别是:

  • SnapshotSandbox
  • LegacySandbox
  • ProxySandbox

SnapshotSandbox

SnapshotSandbox是qiankun最早的沙箱实现,实现原理比较简单。

实现原理:在子应用加载之前,先将主应用的window对象做一份快照,子应用卸载的时候,再将快照还原,在这个过程中会做diff比较并记录修改的数据,这样就实现了子应用与主应用的隔离,但是这种方式有一个缺点,就是性能问题,因为每次子应用加载的时候,都需要做一次快照,快照是遍历window,这样会影响性能,所以后来qiankun又实现了LegacySandbox

适用场景:使用与不支持Proxy的浏览器。

缺点: 会污染全局window,有性能问题


function iter(obj: typeof window, callbackFn: (prop: any) => void) {
  // eslint-disable-next-line guard-for-in, no-restricted-syntax
  for (const prop in obj) {
    // patch for clearInterval for compatible reason, see #1490
    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;
    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 {}
}

关键逻辑就是在activeinactive方法中,active方法会将window对象做一份快照,并应用变更,inactive方法会将快照还原,并记录变更,这样就实现了隔离。

image.png

手撸沙箱

我们单独把沙箱拿出来,稍微修改下,实现思路和SnapshotSandbox一样,通过一个例子加深下我们的理解:

function iter(obj, callbackFn) {
  for (const prop in obj) {
    if (obj.hasOwnProperty(prop)) {
      callbackFn(prop);
    }
  }
}
class Sandbox {
  windowSnapshot;
  modifyPropsMap = {};
  constructor() {
    this.windowSnapshot = {};
    this.proxy = myWindow;
    this.sandboxRunning = true;
  }
  active() {
    this.windowSnapshot = {};
    iter(myWindow, (prop) => {
      this.windowSnapshot[prop] = myWindow[prop];
    });
    // 恢复之前的变更
    Object.keys(this.modifyPropsMap).forEach((p) => {
      myWindow[p] = this.modifyPropsMap[p];
    });
    this.sandboxRunning = true;
  }
  inactive() {
    iter(myWindow, (prop) => {
      if (myWindow[prop] !== this.windowSnapshot[prop]) {
        this.modifyPropsMap[prop] = myWindow[prop];
        myWindow[prop] = this.windowSnapshot[prop];
      }
    });
    this.sandboxRunning = false;
  }
}
// 这是我们模拟的全局window对象, 在上面有三个属性, name, desc, data,sayYes
const myWindow = {
  name: '这是模拟的全局window对象',
  desc: '这是主项目的描述',
  data: {
    age: 12,
  }
};

// 加载子应用的沙箱
const sandbox = new Sandbox();
((myWindow) => {
  // 子应用完成加载
// 在子应用中修改全局window对象(也就是我们的myWidow)
  sandbox.active();
  myWindow.name = '这是子应用修改后的全局window对象';
  myWindow.desc = '这是子应用修改后的描述';
  myWindow.newData = {
    age: 13,
  };
  console.log(myWindow);
// 输出结果为下面这样
// {"name": "这是子应用修改后的全局window对象", "desc": "这是子应用修改后的描述","data": { "age": 12},"newData": {"age": 13}}
// 可以看到在子应用中,我们的全局window对象已经被修改了

// 子应用卸载
// 失活沙箱
  sandbox.inactive();
  console.log(myWindow);
// 输出结果为下面这样
// { "name": "这是模拟的全局window对象", "desc": "这是主项目的描述", "data": { "age": 12 }, "newData": undefined}
// 从上面的结果可以看出, 子应用修改的全局window对象并没有影响原来的window, 这就是沙箱的作用
  sandbox.active();
  console.log(myWindow);
// 输出结果为下面这样
// {"name": "这是子应用修改后的全局window对象", "desc": "这是子应用修改后的描述","data": { "age": 12},"newData": {"age": 13}}
})(sandbox.proxy);

通过上面的的小栗子,我们更直观的看到了SnapshotSandbox的工作原理,上面的代码,我用了一个全局变量myWindow来模拟window对象,然后通过Sandbox类来实现沙箱,active方法会将window对象做一份快照,并应用变更,inactive方法会将快照还原,并记录变更,这样就实现了隔离。

LegacySandbox

qiankun基于Proxy实现了两种不同应用场景的沙箱,分别是LegacySandbox(单例)和ProxySandbox(多例),LegacySandbox虽然使用了代理,但仍然是对window的读写,所以这种沙箱只支持单例模式。

image.png

在沙箱激活和失活的时候他们的逻辑如下:

image.png

手撸沙箱

核心思想还是要把变更存储起来,方便在激活和失活的时候恢复环境,下面我们来把核心代码摘出来,逐行解析下

// 假设这是我们的全局window对象
const myWindow = {
  name: 'myWindow',
  age: 18
};
class LegacySandBox {
  constructor(name, globalContext = myWindow) {
    // 沙箱运行时新增的全局变量
    this.addedPropsMapInSandbox = new Map();
    // 沙箱运行时修改的全局变量
    this.modifiedPropsOriginalValueMapInSandbox = new Map();
    // 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot
    this.currentUpdatedPropsValueMap = new Map();
    // 沙箱是否在运行中
    this.sandboxRunning = true;
    // 记录最后修改的属性
    this.latestSetProp = '';

    this.name = name;
    this.globalContext = globalContext;

    // 全局window对象
    const rawWindow = globalContext;
    // 影子window对象,用来代理全局window对象
    const fakeWindow = Object.create(null);

    // 设置value,并记录数据变更
    const setTrap = (p, newValue, originValue, sync2Window) => {
      // 1. 记录数据变更
      if (this.sandboxRunning) {
        // 如果原始window上没有该属性,说明是新增的全局变量,记录到addedPropsMapInSandbox
        if(!rawWindow.hasOwnProperty(p)) {
          this.addedPropsMapInSandbox.set(p, newValue);
        } else if (!this.modifiedPropsOriginalValueMapInSandbox.has(p)) {
          // 如果原始window上有该属性,说明是修改的全局变量,记录到modifiedPropsOriginalValueMapInSandbox
          this.modifiedPropsOriginalValueMapInSandbox.set(p, originValue);
        }
        // 记录到currentUpdatedPropsValueMap
        this.currentUpdatedPropsValueMap.set(p, newValue);
        // 2. 通过原始window对象设置值
        if (sync2Window) {
          rawWindow[p] = newValue;
        }
      }
      return true;
    };

    const proxy = new Proxy(fakeWindow, {
      get(target, p) {
        const value = rawWindow[p];
        return value;
      },
      set(target, p, newValue) {
        const originalValue = rawWindow[p];
        return setTrap(p, newValue, originalValue, true);
      }
    });

    this.proxy = proxy;
  }

  // 给window对象设置属性, 1. 激活的时候恢复记录的值 2. 失活的时候删除记录的值
  setWindowProp(prop, value, toDelete) {
    if (value === undefined && toDelete) {
      // 删除值
      delete this.globalContext[prop];
    } else {
      this.globalContext[prop] = value;
    }
  }
  active() {
    // 1. 恢复沙箱运行时的全局变量
    if (!this.sandboxRunning) {
      this.currentUpdatedPropsValueMap.forEach((v, p) => this.setWindowProp(p, v));
    }
    // 2. 设置运行状态
    this.sandboxRunning = true;
  }
  inactive() {
    // 1. 重置沙箱运行时的全局变量
    this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => this.setWindowProp(p, v));
    this.addedPropsMapInSandbox.forEach((v, p) => this.setWindowProp(p, undefined, true));
    // 2. 设置运行状态
    this.sandboxRunning = false;
  }
}

// 创建沙箱
const sandbox = new LegacySandBox('test');
((myWindow) => {
  // 新增全局变量
  myWindow.subName = 'subname from sandbox';
  // 修改全局变量
  myWindow.age = 20;
// 输出全局变量
  console.log(myWindow.subName, myWindow.age); // subName: 'subname from sandbox', age: 20
// 失活沙箱
  sandbox.inactive();
// 输出全局变量
  console.log(myWindow.name, myWindow.age); // myWindow 18
  // 激活沙箱
  sandbox.active();
  console.log(myWindow.subName, myWindow.age); // subName: 'subname from sandbox', age: 20

})(sandbox.proxy);

ProxySandbox

LegacySandbox虽然使用了代理,但仍然是对window的读写,在多个实例运行时肯定会冲突,当我们需要同时启动多个实例时,就需要用到ProxySandboxProxySandbox也是基于Proxy实现的,支持多例模式,ProxySandbox同样是对get和set做了拦截,对fakewindow进行代理,并将window 的 document、location、top、window 等属性拷贝一份,当我们修改window对象的时候,会将修改同步到fakewindow,当我们获取属性时,优先从fakewindow中获取,如果fakewindow中没有,再从window中获取,这样就实现了隔离。 由于我们的数据都存储在了当前沙箱对应的fakeWindow上,所以运行多个子应用时,他们的沙箱环境也是独立的,互不影响。

image.png

手撸沙箱

我们忽略掉所有的边界处理,实现一个最简单的ProxySandbox,来加深下我们的理解:

let activeSandboxCount = 0;

class ProxySandbox {
  constructor(name, globalContext = myWindow) {
    this.name = name;
    this.sandboxRunning = true;
    this.globalContext = globalContext;

    const fakeWindow = {};

    const proxy = new Proxy(fakeWindow, {
      get: (target, key) => {
        const actualTarget = key in target ? target : this.globalContext;
        return actualTarget[key];
      },
      set: (target, p, newValue) => {
        if (this.sandboxRunning) {
          target[p] = newValue;
        }
      }
    });

    this.proxy = proxy;
  }
  active() {
    if (!this.sandboxRunning) activeSandboxCount++;
    this.sandboxRunning = true;
  }
  inactive() {
    this.sandboxRunning = false;
  }
}

const myWindow = {
  name: 'myWindow',
  age: 18,
};

const sandbox1 = new ProxySandbox('box1');
const sandbox2 = new ProxySandbox('box2');

((myWindow) => {
  myWindow.subName = 'subname from sandbox1';
  myWindow.age = 20;
// 输出全局变量
  console.log(myWindow.subName, myWindow.age); // subname from sandbox1 20
// 失活沙箱
  sandbox1.inactive();
  // 激活沙箱
  sandbox1.active();
  console.log(myWindow.subName, myWindow.age); // subname from sandbox1 20
})(sandbox1.proxy);
((myWindow) => {
  myWindow.subName = 'subname from sandbox2';
  myWindow.age = 30;
// 输出全局变量
  console.log(myWindow.subName, myWindow.age); // subname from sandbox2 30
// 失活沙箱
  sandbox2.inactive();
  // 激活沙箱
  sandbox2.active();
  console.log(myWindow.subName, myWindow.age); // subname from sandbox2 30
})(sandbox2.proxy);
console.log(myWindow.name, myWindow.age); // myWindow 18

总结

qiankun的三种沙箱我们都已经介绍完了,可以分为两大类,一种是快照模式,一种是代理模式,代理模式又有两种适用场景,单例模式和多例模式

  • 快照模式:SnapshotSandbox,单例,适用于不支持Proxy的浏览器
  • 代理模式:LegacySandbox,单例,适用于支持Proxy的浏览器
  • 代理模式:ProxySandbox,单例、多例,适用于支持Proxy的浏览器