引言

最近在看微前端相关的代码和框架,其中JS沙箱是微前端的关键之一。
但JS沙箱并不是只有微前端才有,相反,在很久很久以前,很多场景都有用到。
比如一些OJ平台的javascript运行环境,可能(只是猜测)就是直接在浏览器的JS沙箱运行的。
再比如常用的菜鸟教程有一个在线编辑器,也是可以直接输入js语句进行运行的,也用到了JS沙箱。

但是在OJ平台或者菜鸟教程,可能粗暴地对api进行了限制和过滤,防止它们影响到宿主环境。
而微前端的JS沙箱,是需要有完整运行子应用的能力的,所以微前端实现的JS沙箱更加完善。


这里以qiankun的三种沙箱模式来讲解如何对window上做沙箱隔离的两种思想

快照思想

快照思想很好理解,就是好比趁妈妈不在家偷偷喝冰箱里的东西,拿之前记一下瓶子摆放在哪个位置,喝完之后,把空瓶子放回原处,免得妈妈发现。

首先每个子应用都有个存放临时快照的对象snapshot,也有一个存放当前子应用状态的对象modifyPropsMap

挂载子应用之前

  1. 遍历window存放到一个对象snapshot内
  2. 把子应用的状态modifyPropsMap拿出来,更新window

卸载子应用时

  1. 遍历window存放到子应用的状态modifyPropsMap内,以便下一次调用该子应用时可以还原当时的状态。
  2. 遍历window,和snapshot作比对,不一样就还原

SnapshotSandBox

具体代码:


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
    // 兼容IE的clearInterval
    if (obj.hasOwnProperty(prop) || prop === 'clearInterval') {
      callbackFn(prop);
    }
  }
}
/**
 * 基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器
 */
export default class SnapshotSandbox implements SandBox {
   private windowSnapshot!: Window;
   private modifyPropsMap: Record<any, any> = {};
   
   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];
      }
    });

    this.sandboxRunning = false;
  }
}

问题:

  1. 浅比对,导致二级以上的属性更改无法还原
  2. 在window属性很多,且JS沙箱切换频繁的极端情况下,diff比对效率较低

LegacySandBox

再来看看qiankun基于快照思想实现的改进版

首先它的核心思想还是快照思想,但是用了三个状态池,分别用于子应用卸载时还原主应用的状态和子应用加载时还原子应用的状态。
其中快照被拆成了两个表addedPropsMapInSandboxmodifiedPropsOriginalValueMapInSandbox,便于window还原,而currentUpdatedPropsValueMap对应的是当前子应用的状态

简单来说就是利用三个表的空间,解决了diff比对效率低的问题。

这段代码有点长,我们先看它的私有属性和钩子函数

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>();

  active() {
    if (!this.sandboxRunning) {
      this.currentUpdatedPropsValueMap.forEach((v, p) => this.setWindowProp(p, v));
    }

    this.sandboxRunning = true;
  }

  inactive() {
    // restore global props to initial snapshot
    this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => this.setWindowProp(p, v));
    this.addedPropsMapInSandbox.forEach((_, p) => this.setWindowProp(p, undefined, true));
  }

  // ......
}

很好懂~

在子应用激活的时候
把当前子应用的状态currentUpdatedPropsValueMap更新到window上

在子应用卸载的时候
按照之前的快照把window还原回去



再来看看它的构造函数

  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;

    const setTrap = (p: PropertyKey, value: any, originalValue: any, sync2Window = true) => {
      if (this.sandboxRunning) {
        if (!rawWindow.hasOwnProperty(p)) {
          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; // 本质上还是操作 window 对象
        }

        this.latestSetProp = p;

        return true;
      }
      return true;
    };

    const proxy = new Proxy(fakeWindow, {
      set: (_: Window, p: PropertyKey, value: any): boolean => {
        const originalValue = (rawWindow as any)[p];
        return setTrap(p, value, originalValue, true);
      },
      get(_: Window, p: PropertyKey): any {
        // 防止逃逸沙箱
        if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') {
          return proxy;
        }
        const value = (rawWindow as any)[p];
        return getTargetValue(rawWindow, value);
      },
      has(_: Window, p: string | number | symbol): boolean {...},
      getOwnPropertyDescriptor(_: Window, p: PropertyKey): PropertyDescriptor | undefined {...},
      defineProperty(_: Window, p: string | symbol, attributes: PropertyDescriptor): boolean {...});
    this.proxy = proxy;
  }
}

可以看到,在setter里触发了setTrap,对三个状态表进行更改,同时对window进行操作。
而getter则是做了一些防止逃逸沙箱的处理,然后交接给getTargetValue函数处理,getTargetValue函数主要处理window.console、window.atob这类API在微应用中调用时会抛出 Illegal invocation异常的问题

问题:
虽然解决了diff效率问题,但本质还是操作真实window,二级以上的属性更改和删除无法触发setter,依旧无法还原干净

快照思想总结

可以看到qiankun实现的时候,都是做了浅比对,大概是因为做深比对效率很低,不适合极端环境。
也可以看出来快照思想的局限性,拍一张照片,要以什么样的分辨率去记录,然后又要以什么样的还原度去还原,这都是需要做取舍的


既然快照思想有它原理性的局限性,那能不能换一个思路呢?
其实有的,那就是代理思想

代理思想

既然没有办法完全还原,那我就不还原,我只要能复制就好了,代理思想就是这样的,基于原来的window,代理出许多fakeWindow,一个子应用对应一个fakeWindow

不过代理思想虽然美好,但是实际实现JS沙箱的时候有很多限制:

  1. 一些window的API是不允许在fakeWindow上运行的,会抛出TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation的错误。比如fetch、console、atob等
  2. 有些js库挂载的全局变量是不想被沙箱限制的,比如一些浏览器插件(如React Developer Tools),或者一些poly fill库,如SystemJS,或者react webpack hot reload变量__REACT_ERROR_OVERLAY_GLOBAL_HOOK__

但qiankun还是针对这些情况做了兼容处理,实现了可以用于生产环境的基于proxy的JS沙箱
我们这里主要掌握它代理的思想就好了,所以对源码做了一些精简

首先看看如何基于原来的window代理出fakeWindow

function createFakeWindow(globalContext: Window) {
  const propertiesWithGetter = new Map<PropertyKey, boolean>();
  const fakeWindow = {} as FakeWindow;

// 从window对象拷贝不可配置的属性
// 举个例子:window、document、location这些都是挂在Window上的属性,他们都是不可配置的
// 拷贝出来到fakeWindow上,就间接避免了子应用直接操作全局对象上的这些属性方法
  Object.getOwnPropertyNames(globalContext)
    .filter((p) => {
      const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
      return !descriptor?.configurable;
    })
    .forEach((p) => {
      const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
      if (descriptor) {
        const hasGetter = Object.prototype.hasOwnProperty.call(descriptor, 'get');

        // 省略兼容性代码...
        // make top/self/window property configurable and writable, otherwise it will cause TypeError while get trap return.

        if (hasGetter) propertiesWithGetter.set(p, true);

        // freeze the descriptor to avoid being modified by zone.js
        // 拷贝属性到fakeWindow对象上
        rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
      }
    });

  return {
    fakeWindow,
    propertiesWithGetter,
  };
}

可以看到,通过createFakeWindow,我们获得了fakeWindow和propertiesWithGetter,这个fakeWindow的属性都是原来window的一些不可配置属性,这样我们就拿到了一个干净的fakeWindow,为什么说是干净的,因为这个fakeWindow只拥有了一些必要的属性。

而propertiesWithGetter有什么用,那我们就得继续看ProxySandBox的源码了

因为代理思想不需要还原全局window,也不会操作全局window,所以钩子函数并没有做太多事情,这里我们直接看构造函数

  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 => {
        if (this.sandboxRunning) {
          this.registerRunningApp(name, proxy);    // 注册当前正在操作的微前端应用
          if (!target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) {
            // 如果fakeWindow没有该prop,但是真实window有,则按真实window的descriptor来进行赋值操作
            const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
            const { writable, configurable, enumerable } = descriptor!;
            if (writable) {
              Object.defineProperty(target, p, {
                configurable,
                enumerable,
                writable,
                value,
              });
            }
          } else {
            target[p] = value;
          }

          if (variableWhiteList.indexOf(p) !== -1) {
            // 直接改写真实window的值
            globalContext[p] = value;
          }
          // 维护更新列表
          updatedValueSet.add(p);

          this.latestSetProp = p;

          return true;
        }
        return true;
      },

      get: (target: FakeWindow, p: PropertyKey): any => {
        this.registerRunningApp(name, proxy);
        
        // 省略一些代码处理

        const value = propertiesWithGetter.has(p)
          ? (globalContext as any)[p]
          : p in target
          ? (target as any)[p]
          : (globalContext as any)[p];
        /* Some dom api must be bound to native window, otherwise it would cause exception like 'TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation'
           See this code:
             const proxy = new Proxy(window, {});
             const proxyFetch = fetch.bind(proxy);
             proxyFetch('https://qiankun.com');
        */
        const boundTarget = useNativeWindowForBindingsProps.get(p) ? nativeGlobal : globalContext;
        return getTargetValue(boundTarget, value);
      }
      // 省略一些其他方法如has、defineProperty、getOwnPropertyDescriptor的处理
    });

    this.proxy = proxy;

    activeSandboxCount++;
  }

可以看到,我们在构造函数里执行了const { fakeWindow, propertiesWithGetter } = createFakeWindow(globalContext);,正如前面说的每一个子应用实例对应一个fakeWindow
然后我们通过const proxy = new Proxy(fakeWindow, handler)重写fakeWindow的setter、getter

其中setter的逻辑是:
如果fakeWindow没有该prop,但是真实window有,则按真实window的descriptor来进行赋值操作;
否则直接赋值给fakeWindow。
可以注意到,这里操作的是fakeWindow,对真实的window没有进行操作

setter也有对一些特殊情况进行处理
比如上面提到的情况(有些js库挂载的全局变量是不想被沙箱限制的)

if (variableWhiteList.indexOf(p) !== -1) {
  // 直接改写真实window的值
  globalContext[p] = value;
}

这里variableWhiteList = ['System', '__cjsWrapper', ...variableWhiteListInDev];

而getter的逻辑是:
对一些边界条件做处理
比如document、eval、window、top、self、globalThis等属性,或增强,或逃逸沙箱,或防止逃逸沙箱,或者直接挂在真实window上运行(解决window某些API挂载在fakeWindow导致TypeError的问题)。

并且判断propertiesWithGetter是否有这个属性,优先返回真实window上的属性(有点类似原型链的做法)

做完这些处理后交给getTargetValue函数处理。

相比快照思想:
解决了变量污染的问题(不修改真实window,对delete操作符也进行了相应监听)

总结

可以看到相比快照思想,代理思想才更符合微前端的实际应用场景,也是解决多个子应用实例共存的方法。
但这里的源码实现更多是实现了JS逻辑与执行环境没有太多关系的的沙箱隔离。

对于DOM的操作涉及到document的,拦截全局监听事件的,还需要更多的实现去隔离。
比如qiankun源码里的./src/sandbox/patchers

如何运行?

现在我们制造了容器,但是内容却不知道怎么放进去,更不用说怎么让它跑起来。

首先JS沙箱跑的自然是JS代码,我们处理JS代码时,都是当作字符串处理的,不管是从用户编辑器里输入的JS代码字符串,还是异步请求到的JS代码字符串。

那么如何让JS代码字符串跑起来,一般有两种方法,eval或动态创建script标签

qiankun使用的是eval,而另一个微前端框架icestark使用的是动态创建script标签的方法。

先讲讲qiankun是怎么使用eval把JS代码字符串丢进JS沙箱里运行的吧
qiankun内置了一个import-html-entry的模块,它的主要作用就是通过fetchAPI,把html文件分成template, css, scripts, entry,并且暴露出execScripts, getExternalScripts, getExternalStyleSheets方法。

重点是execScripts,里面有个核心方法geval

const geval = (scriptSrc, inlineScript) => {
    const rawCode = beforeExec(inlineScript, scriptSrc) || inlineScript;
    const code = getExecutableScript(scriptSrc, rawCode, proxy, strictGlobal);
    (0, eval)(code);
    afterExec(inlineScript, scriptSrc);
};

重点是
const code = getExecutableScript(scriptSrc, rawCode, proxy, strictGlobal);
(0, eval)(code);
这两句代码

我们把getExecutableScript展开来看

function getExecutableScript(scriptSrc, scriptText, proxy, strictGlobal) {
    const sourceUrl = isInlineCode(scriptSrc) ? '' : `//# sourceURL=${scriptSrc}\n`;

    // 通过这种方式获取全局 window,因为 script 也是在全局作用域下运行的,所以我们通过 window.proxy 绑定时也必须确保绑定到全局 window 上
    // 否则在嵌套场景下, window.proxy 设置的是内层应用的 window,而代码其实是在全局作用域运行的,会导致闭包里的 window.proxy 取的是最外层的微应用的 proxy
    const globalWindow = (0, eval)('window');
    globalWindow.proxy = proxy;
    // TODO 通过 strictGlobal 方式切换 with 闭包,待 with 方式坑趟平后再合并
    return strictGlobal
        ? `;(function(window, self, globalThis){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`
        : `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`;
}

同时结合qiankun怎么使用的代码看看

const scriptExports = await execScripts(global, sandbox && !useLooseSandbox);

可以看到,当qiankun使用沙箱模式的时候,它传入getExecutableScriptstrictGlobaltrue,也就是说在这段代码字符串外面会包上with(window),来延长这段代码的作用域(可参考Javascript高级程序设计-第三版-4.2.1节延长作用域链)

(0, eval)(code)是什么意思呢?
首先这里(0, eval)的逗号操作符作用是从左到右执行各个变量,然后返回值是最后一个变量。
举个例子:

const obj = {
  method() { console.log(this); return this; }
}
obj.method() === window  // { method: f() }; false
(0, obj.method)() === window // Window; true

这里可以看到obj.method里的this仿佛被改变了
其实可以这么理解(0, obj.method)()等效于const method = obj.method; method();

我们取红宝书这么一段话来促进我们的理解:当解析器发现代码中调用 eval()方法时,它会将传入的参数当作实际的 ECMAScript 语句来解析,然后把执行结果插入到原位置。通过 eval()执行的代码被认为是包含该次调用的执行环境的一部分,因此被执行的代码具有与该执行环境相同的作用域链。

ok,我们可以再参考 https://stackoverflow.com/questions/14119988/return-this-0-evalthis/14120023 看看,直接eval调用,就符合红宝书说的被执行的代码具有与该执行环境相同的作用域链,但是间接调用eval,比如(any value, eval)(code),将会把eval的执行作用域改为全局作用域

我们可以用一些例子证明一下

const emptyObj = {};

(() => {
  eval("var a = 5")
  console.log(a)  // 5
})()
console.log(a)  // ReferenceError: a is not defined

(() => {
  eval.bind(emptyObj)("var b = 5")
})()
console.log(b)  // 5

(() => {
  eval.bind(window)("var c = 5")
})()
console.log(c)  // 5

(() => {
  (0, eval)("var d = 5")
})()
console.log(d)  // 5

(() => {
  (100, eval)("var e = 5")
})()
console.log(e)  // 5

所以可以看到,我们通过(0, eval)(code)把code的执行环境提升到了全局环境,以至于代码的作用域不会被拘束在可怜的geval函数作用域内,
又用with(fakeWindow)让子应用的JS代码执行在JS沙箱内,让它安分守己。