likes
comments
collection
share

Qiankun实践——实现一个 JS 沙箱

作者站长头像
站长
· 阅读数 17

前言

哈喽,大家好,我是海怪。

今天继续来讲 Qiankun 的 JS 沙箱。想直接看源码的同学可以在 src/sandbox 目录下找到对应的源码。

Qiankun实践——实现一个 JS 沙箱

不过,这些代码并不好懂。里面不仅写了大量的兜底逻辑,还有很异常情况的处理代码,很容易看着看着就被绕进去了。

那么今天就带大家一起来实现一个简化版的 Qiankun JS 沙箱吧。其中大部分逻辑会做精简处理,不会考虑一些异常情况。学有余力的同学可以直接 Clone Qiankun 源码 来查看。

这篇文章的所有代码都放在我的 这个仓库 mini-js-sandbox 中了,需要的自行提取。

沙箱原理

首先,我们要搞清楚沙箱是用来干嘛的,沙箱在我们生活中非常常见:

  • 军事沙盘
  • MineCraft 沙箱
  • GTA 模拟城市
  • ...

沙箱最大的作用就是做环境隔离,对于代码来说就是做代码之间的隔离。 应用 A 过来执行后,离开时要把环境恢复成原来的样子,不然当加载应用 B 时,就会用到应用 A 在全局留下的内容,从而造成环境污染。

Qiankun实践——实现一个 JS 沙箱

但我们有问题的 JS 代码是这样的:

window.jQuery = {}

window.jQuery.ajax();

这怎么能在 “沙箱” 中执行呢?很简单,我们把上面的代码存成字符串 codeStr,然后放入下面代码中:

// 要放入沙箱的代码
const codeStr = `
  window.jQuery = {}

  window.jQuery.ajax();
`;

// 最终执行的代码
const finalCode = `
function fn(window) {
  ${codeStr}
}

// 将沙箱作为入参传入
fn(window.proxy)
`;

// 执行上面 finalCode 代码片断
eval(finalCode)

这样一来,codeStr 中的 window 就不再是全局变量了,而是 window.proxy 了。下面我们来看 window.proxy 要怎么实现。

配置环境

为了更好地一步步实现沙箱,我们可以采用 TDD 的开发模式。

新建一个项目,然后执行:

npm i -D jest @types/jest jest-environment-jsdom
  • 安装 @types/jest 可以让 IDE 有更充分的提示。
  • Jest 从 28 版本开始已不再集成 jsdom 测试环境了,所以这里要单独安装 jest-environment。

没有 Jest 配置文件情况下,Jest 执行测试会有很多问题,所以推荐使用下面命令来自动生成 Jest 配置文件:

npx jest --init

生成 Jest 配置文件后,在 src/SnapshotSandbox.js 添加:

const add = (a, b) => {
  return a + b;
}

然后在 src/SnapshotSandbox.test.js 里写下我们第一个测试:

describe('SnapshotSandbox', () => {
  it('能运行', () => {
    expect(add(1, 1)).toEqual(2);
  })
});

最后 npm run test 会发现成功:

Qiankun实践——实现一个 JS 沙箱

快照沙箱

现在我们来实现最简单的沙箱——快照沙箱。

我们来思考:如果才能做到环境恢复呢?那当然是找个地方存起来呗。所以我们思路就很简单了:

Qiankun实践——实现一个 JS 沙箱

根据这个图,我们来简单写写用例,在 tests/SnapshotSandbox.test.js 添加:

const SnapshotSandbox = require('../src/SnapshotSandbox');

describe('SnapshotSandbox', () => {
  afterEach(() => {
    delete window.value;
  })

  it('激活时可以恢复微应用环境', () => {
    const sandbox = new SnapshotSandbox();

    // 假设以前有一个旧值 value: 1
    sandbox.modifiedMap = {
      value: 1 // 旧值
    }

    // 需要恢复 value: 1 -> window
    sandbox.active();

    expect(sandbox.proxy.value).toEqual(1);
  })

  it('失活时可以恢复以前 window 的环境', () => {
    const sandbox = new SnapshotSandbox();

    // 准备激活
    sandbox.proxy.value = 321;
    sandbox.active();
    expect(sandbox.windowSnapshot.value).toEqual(321);

    // 准备失活
    sandbox.proxy.value = 123;
    sandbox.inactive();
    expect(sandbox.modifiedMap.value).toEqual(123);
  })
});

这里,我们用 window.value 来测试是沙箱在激活以及失活时,是否能恢复 window.value 的值。

由于我们刚刚安装了 jest-environment-jsdom,并在 Jest 配置文件里应用上,所以这里直接设置 window.value 不会报错。

下面,我们就来实现 SnapshotSandbox,在 src/SnapshotSandbox.js 添加:

class SnapshotSandbox {
  windowSnapshot = {}
  modifiedMap = {}
  proxy = window;

  constructor() {
  }

  active() {
    // 记录 window 旧的 key-value
    Object.entries(window).forEach(([key, value]) => {
      this.windowSnapshot[key] = value;
    })

    // 恢复上一次的 key-value
    Object.keys(this.modifiedMap).forEach(key => {
      window[key] = this.modifiedMap[key];
    })
  }

  inactive() {
    this.modifiedMap = {};

    Object.keys(window).forEach(key => {
      // 如果有改动,则说明要恢复回来
      if (window[key] !== this.windowSnapshot[key]) {
        // 记录变更
        this.modifiedMap[key] = window[key];
        window[key] = this.windowSnapshot[key];
      }
    })
  }
}

module.exports = SnapshotSandbox;

从上面可以看到,我们用 windowSnapshot 来记录上一次 window 的环境,用 modifiedMap 来记录沙箱里的变更,以此作为恢复环境的依据。

恢复环境的操作也很简单,无非就是把整个 Map 或者 Object 遍历一遍,然后把 key-value 逐一做赋值操作即可。

单例沙箱

上面这个沙箱不好的地方在于:每次在 inactive 的时候都要对所有属性进行 Diff 操作。虽然在一般情况下,window 挂载的属性不会很多:

Qiankun实践——实现一个 JS 沙箱

但每次都遍历一遍也是有点挫的,我们为什么不能像 React 做 DOM Diff 那样:把每次变更记录下来,然后通过这些变更记录来反推环境记过呢? 例如:

  • 沙箱有新增,那么恢复 window 环境就是删除
  • 沙箱有修改,那么恢复 window 环境就要用原始值覆盖修改值

有了上面这个设想,我们就会想到:如果才能把每次变更都记录下来呢?很简单,利用 ES6 的 Proxy 里的 Setter,我们可以在里面去做当前 Key 的判断:新增 or 修改,然后把对应的 key-value 记录到不同的 Map 中。

根据这个设想,我们来实现一下,在 src/SingularProxySandbox.js 里添加:

class SingularProxySandbox {
  /** 沙箱期间新增的全局变量 */
  addedMap = new Map();
  /** 沙箱期间更新的全局变量 */
  originMap = new Map();
  /** 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot */
  updatedMap = new Map();

  setWindowKeyValues(key, value, shouldDelete) {
    if (value === undefined || shouldDelete) {
      // 删除 key-value
      delete window[key];
    } else {
      window[key] = value;
    }
  }

  constructor() {
    const fakeWindow = Object.create(null);
    const { addedMap, originMap, updatedMap } = this;

    this.proxy = new Proxy(fakeWindow, {
      set(_, key, value) {
        // 记录以前的值
        const originValue = window[key];

        if (!window.hasOwnProperty(key)) {
          // 如果不存在,那么加入 addedMap(添加)
          addedMap.set(key, value);
        } else if (!originMap.has(key)) {
          // 如果当前 window 对象存在该属性,且 originMap 中未记录过,则记录该属性初始值(修改)
          originMap.set(key, originValue);
        }

        // 记录修改后的值
        updatedMap.set(key, value);

        // 修改值
        window[key] = value;
      },
      get(_, key) {
        return window[key];
      }
    });
  }

  active() {
    // 激活时,把上次微应用做更新/新增的 key-value 覆盖到 window 上
    this.updatedMap.forEach((value, key) => this.setWindowKeyValues(key, value));
  }

  inactive() {
    // 删除新增的 key-value
    this.addedMap.forEach((_, key) => this.setWindowKeyValues(key, undefined, true));
    // 覆盖上次全局变量的 key-value
    this.originMap.forEach((value, key) => this.setWindowKeyValues(key, value));
  }
}

module.exports = SingularProxySandbox;

这里我们用了 3 个变量来记录变更,注意:这 3 变量都是针对沙箱运行时的:

  • addedMap 记录沙箱环境中新增的 key-value
  • originMap 记录沙箱修改时 Window 的旧 key-value
  • updatedMap 记录沙箱环境修改时新的 key-value

activeinactive 时就能通过这三来恢复环境了。下面我们把测试用例也补充一下,在 tests/SingularProxySandbox.test.js 添加:

const SingularProxySandbox = require('../src/SingularProxySandbox');

describe('SingularProxySandbox', () => {
  afterEach(() => {
    delete window.fixedValue;
    delete window.addedValue;
  })

  it('激活时可以正确记录对应的值', () => {
    window.fixedValue = '原始值';

    const sandbox = new SingularProxySandbox();

    sandbox.active();

    sandbox.proxy.fixedValue = '修改了';
    sandbox.proxy.addedValue = '新增的值';

    expect(sandbox.originMap.get('fixedValue')).toEqual('原始值');
    expect(sandbox.updatedMap.get('fixedValue')).toEqual('修改了');
    expect(sandbox.addedMap.get('addedValue')).toEqual('新增的值');

    sandbox.inactive();
  })

  it('激活时可以恢复微应用环境', () => {
    const sandbox = new SingularProxySandbox();

    // 模拟上一次微应用存的值
    sandbox.updatedMap.set('addedValue', 'added');
    sandbox.updatedMap.set('fixedValue', 'fixed');

    sandbox.active();

    // 检查 window 是否有对应的 key-value
    expect(window.addedValue).toEqual('added');
    expect(window.fixedValue).toEqual('fixed');

    // 检查 proxy 是否也有对应的 key-value
    expect(sandbox.proxy.addedValue).toEqual('added');
    expect(sandbox.proxy.fixedValue).toEqual('fixed');

    sandbox.inactive();
  })

  it('失活时会恢复原来的 window 环境', () => {
    // 原来 window 的环境
    window.fixedValue = '原始值';

    const sandbox = new SingularProxySandbox();

    sandbox.active();

    // 微应用环境下新增的 addedValue
    sandbox.proxy.addedValue = '新增的值';
    // 微应用修改了 fixedValue
    sandbox.proxy.fixedValue = '修改了';

    sandbox.inactive();

    expect(window.addedValue).toBeUndefined();
    expect(window.fixedValue).toEqual('原始值');
  })
});

这里的测试用例主要分 3 部分:

  • 在 window 环境以及沙箱环境中模拟 key-value,然后检查 addedMap, originMap 以及 updatedMap 是否符合预期
  • 检查激活时能否恢复环境
  • 检查失活时是否能恢复环境

多例沙箱

上面这两种沙箱其实都太过于关注 “恢复环境” 这件事上了。

我们不妨跳出这个概念:我们为什么要恢复环境?假如有 N 个微应用,那么就分配 N 个沙箱环境,然后通过修改 window.proxy 就能指定对应的环境,这样一来根本不需要 “恢复环境” 的操作了。

Qiankun实践——实现一个 JS 沙箱

那么问题来了:这里的 “分配环境” 的 “环境” 要怎么造出来呢?难道分配一个空对象?当然不是!毕竟沙箱中有可能要用到 window.location 或者 document 这些属性。

因此,我们可以把浏览器的原生属性都复制到一个空对象上,这样对于每个沙箱环境都会有一个相对干净的浏览器环境了。 不废话,直接上代码:

let activeSandboxCount = 0;

class MultipleProxySandbox {
  proxy = {};

  constructor(props) {
    const { fakeWindow, keysWithGetters } = this.createFakeWindow();

    this.proxy = new Proxy(fakeWindow, {
      set(target, key, value) {
        // 对于一些非原生的属性不做修改,因这些属性可能是用户自己改的
        // 这里只需要判断 target[key] 即可, 因为只有原生属性才会复制到 target 上
        if (!target[key] && window[key]) {
          return;
        }
        target[key] = value;
      },
      get(target, key) {
        // 判断从 window 取值还是从当前 fakeWindow 取值
        const actualTarget = keysWithGetters[key] ? window : key in target ? target : window;
        return actualTarget[key];
      }
    });
  }

  createFakeWindow() {
    const fakeWindow = {};
    const keysWithGetters = {};

    Object.getOwnPropertyNames(window)
      .filter((key) => {
        // 只要不可配置的的属性,这些不可配置属性也可以理解为原生属性(一般情况下)
        const descriptor = Object.getOwnPropertyDescriptor(window, key);
        return !descriptor?.configurable;
      })
      .forEach((key) => {
        // 复制 key-value
        fakeWindow[key] = window[key];
        // 同时记录这些 key
        keysWithGetters[key] = true;
      })

    return { fakeWindow, keysWithGetters };
  }

  active() {
    activeSandboxCount += 1;
  }

  inactive() {
    activeSandboxCount -= 1;
  }
}

module.exports = MultipleProxySandbox;

从上面可以看到 fakeWindow 则为我们要分配的环境对象。它通过 createFakeWindow 来生成。其中,createFakeWindow 会通过 !descriptor?.configurable 把非原生属性过滤掉,只把原生的 key-value 复制到 fakeWindow

除此之外,每次复制时把这些原生属性的 key 记录下来,当在访问这些属性时,会通过 key 来判断是否为原生属性,如果是原生属性,依然从 window 获取值,否则在 fakeWindow 上获取。

当然这里 !descriptor?.configurable 并不能直接和 “原生属性” 划等号,这里只是更方便去理解多例沙箱的特点。

由于多例沙箱会对每个环境分配一个 fakeWindow,所以也谈不上 “恢复环境” 了,因此 active 以及 inactive 几乎没有任何代码了。 这在 Qiankun 中也是如此,只不过 Qiankun 会在这里对白名单的属性做额外的处理。

对测试用例来说也不用测 “恢复环境” 了,毕竟通过这样的代码就能实现环境切换了:

window.proxy = sandbox1; // 切换到沙箱1

window.proxy = sandbox2; // 切换到沙箱2

那这里我们就简单测一下 “无法修改非原生 key-value” 的情况吧,在 tests/MultipleProxySandbox.test.js 中添加:

const MultipleProxySandbox = require('../src/MultipleProxySandbox');

describe('MultipleProxySandbox', () => {
  it('不会修改非原生属性', () => {
    window.a = 1;

    const sandbox = new MultipleProxySandbox();

    sandbox.active();
    sandbox.proxy.a = 2;

    expect(window.a).toEqual(1);

    sandbox.inactive();
  })
});

那么,这就是多例沙箱的原理了。实际上多例沙箱源码比这个要更复杂,因为里面有非常多对 gloalThis, self, document 这些属性和 key 名的处理:

if (p === Symbol.unscopables) return unscopables;

if (p === 'window' || p === 'self') {
  return proxy;
}

if (p === 'globalThis') {
  return proxy;
}

if (
  p === 'top' ||
  p === 'parent' ||
  (process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop'))
) {
  if (globalContext === globalContext.parent) {
    return proxy;
  }
  return (globalContext as any)[p];
}

if (p === 'hasOwnProperty') {
  return hasOwnProperty;
}

if (p === 'document') {
  return document;
}

if (p === 'eval') {
  return eval;
}

当然,对于学习者来说这些都可以忽略,不要钻牛角尖。

总结

最后来总结一下这次实践吧。

沙箱最重要的功能就是: 隔离多个 JS 环境,使得不同微应用在执行时不受到其它微应用的影响。 这里的执行时不仅是单独,也可以多个同时进行。

对于单独执行微应用的情况下,我们有 SnapshotSandbox 以及 SingularProxySandbox 两种沙箱。前者会在 inactive 时做所有属性的 Diff,而 SingularProxySandbox 则会通过 Proxy 来 Watch 每次更新,同时记录每次变更,通过这些变更反推原始环境,从而实现 “环境恢复”。

更高效的应该是多例沙箱 MultipleProxySandbox,这种沙箱会对每个微应用分配一个沙箱,在修改时,只修改 fakeWindow 内容,而不会污染 window 对象。这种沙箱也更符合直觉,但浏览器不一定都会支持 Proxy 语法,所以对于一些老旧浏览器,还是得用 SnapshotSandbox

这篇文章的代码都放在 这个仓库 mini-js-sandbox,需要的自行提取即可。如果你喜欢我的分享,可以来一波一键三连,点赞、在看就是我最大的动力,比心 ❤️