微前端中的JS沙箱实现

1,608 阅读2分钟

参考了qiankun中的JS沙箱部分代码实现的代码,代码。

以及大量参考,copy了ppt的内容图片

背景

在微前端中为了防止项目的JS互相影响冲突,导致项目无法正常工作,于是实现了一个简单的沙箱的极致用于隔离各个项目之间的JS运行环境

原理结构

image.png

在沙箱的实现里,我们会有两个环境:

全局环境 Global Env: 指的是你框架应用所运行的全局环境,代表的是外部的部分。

内部环境Render Env: 而子应用加载的时候,实际上是应该跑在一个内部的沙箱环境里面,就是这张图上所表示的 。

简单来说 子应用 可以读取 全局环境,而不能对其进行修改,所有期修改保存在 内部环境 里,用于限制其影响范围。

沙箱类型

根据沙箱实现的功能逻辑,可以分成两种

1. 快照沙箱

image.png

其中最经典的实践思路其实是快照沙箱。

快照沙箱就是在应用沙箱挂载和卸载的时候记录快照,在应用切换的时候依据快照恢复环境,

举个例子:

应用A启动 =》 拍摄快照 

应用A内部 window.a1 = 10

应用A卸载 =》 恢复快照

windows.a1 = undefined

实现这个做法有两种方案

1. 跑两个for循环,来对比两次的差异,来拍摄快照和恢复快照

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

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;
  }

2.使用Proxy来记录之间的变化

image.png

用一句话来概括proxy 就是 他可以给Object设置一个钩子,当Object 被get\set的时候,会触发钩子

通过劫持 window ,我们可以劫持到子应用对全局环境的一些修改。当子应用往 window 上挂东西、修改东西和删除东西的时候,我们可以把这个操作记录下来。当我们恢复回外面的全局环境的时候,我们只需要反向执行之前的操作即可。比如我们在沙箱内部设了一个新的变量 window.a = 123 。那在离开的时候,我们只需要把 a 变量删了即可。

核心代码看这段就行了

const proxy = new Proxy(fakeWindow, {
      set: (_: Window, p: PropertyKey, value: any): boolean => {
        if (this.sandboxRunning) {
          if (!rawWindow.hasOwnProperty(p)) {
            addedPropsMapInSandbox.set(p, value);
          } else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
            // 如果当前 window 对象存在该属性,且 record map 中未记录过,则记录该属性初始值
            const originalValue = (rawWindow as any)[p];
            modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
          }

          currentUpdatedPropsValueMap.set(p, value);
          // 必须重新设置 window 对象保证下次 get 时能拿到已更新的数据
          // eslint-disable-next-line no-param-reassign
          (rawWindow as any)[p] = value;

          this.latestSetProp = p;

          return true;
        }

        if (process.env.NODE_ENV === 'development') {
          console.warn(`[qiankun] Set window.${p.toString()} while sandbox destroyed or inactive in ${name}!`);
        }

        // 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
        return true;
      },

      get(_: Window, p: PropertyKey): any {
        // avoid who using window.window or window.self to escape the sandbox environment to touch the really window
        // or use window.top to check if an iframe context
        // see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
        if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') {
          return proxy;
        }

        const value = (rawWindow as any)[p];
        return getTargetValue(rawWindow, value);
      },

      // trap in operator
      // see https://github.com/styled-components/styled-components/blob/master/packages/styled-components/src/constants.js#L12
      has(_: Window, p: string | number | symbol): boolean {
        return p in rawWindow;
      },

      getOwnPropertyDescriptor(_: Window, p: PropertyKey): PropertyDescriptor | undefined {
        const descriptor = Object.getOwnPropertyDescriptor(rawWindow, p);
        // A property cannot be reported as non-configurable, if it does not exists as an own property of the target object
        if (descriptor && !descriptor.configurable) {
          descriptor.configurable = true;
        }
        return descriptor;
      },
    });

但是缺点在于无法支持多个实例,也就是说我两个沙箱不可能同时激活,我不能同时挂载两个应用,不然的话这两个沙箱之间会打架,导致错误。

2. 代理沙箱

image.png

另一条思路,我们让子应用里面的环境和外面的环境完全隔离。就如图所示,我们的 A 应用就活在 A 应用的沙箱里面,B 应用就活在 B 应用的沙箱里面,两者之间要不发生干扰,这个沙箱的实现思路其实也是通过 ES6 的 proxy,也通过代理特性实现的。

简单来说沙箱内部存储了一个对象,用于记录沙箱内部的修改,使得其不会影响到全局环境,读取的时候优先读记录的,没有读window

主要代码如下

set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
        if (this.sandboxRunning) {
          // We must kept its description while the property existed in rawWindow before
          if (!target.hasOwnProperty(p) && rawWindow.hasOwnProperty(p)) {
            const descriptor = Object.getOwnPropertyDescriptor(rawWindow, 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
            rawWindow[p] = value;
          }

          updatedValueSet.add(p);

          this.latestSetProp = p;

          return true;
        }

        if (process.env.NODE_ENV === 'development') {
          console.warn(`[qiankun] Set window.${p.toString()} while sandbox destroyed or inactive in ${name}!`);
        }

        // 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
        return true;
      },

      get(target: FakeWindow, p: PropertyKey): any {
        if (p === Symbol.unscopables) return unscopables;

        // avoid who using window.window or window.self to escape the sandbox environment to touch the really window
        // see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
        if (p === 'window' || p === 'self') {
          return proxy;
        }

        // hijack global accessing with globalThis keyword
        if (p === 'globalThis') {
          return proxy;
        }

        if (
          p === 'top' ||
          p === 'parent' ||
          (process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop'))
        ) {
          // if your master app in an iframe context, allow these props escape the sandbox
          if (rawWindow === rawWindow.parent) {
            return proxy;
          }
          return (rawWindow as any)[p];
        }

        // proxy.hasOwnProperty would invoke getter firstly, then its value represented as rawWindow.hasOwnProperty
        if (p === 'hasOwnProperty') {
          return hasOwnProperty;
        }

        // mark the symbol to document while accessing as document.createElement could know is invoked by which sandbox for dynamic append patcher
        if (p === 'document' || p === 'eval') {
          setCurrentRunningSandboxProxy(proxy);
          // FIXME if you have any other good ideas
          // remove the mark in next tick, thus we can identify whether it in micro app or not
          // this approach is just a workaround, it could not cover all complex cases, such as the micro app runs in the same task context with master in some case
          nextTick(() => setCurrentRunningSandboxProxy(null));
          switch (p) {
            case 'document':
              return document;
            case 'eval':
              // eslint-disable-next-line no-eval
              return eval;
            // no default
          }
        }

        // eslint-disable-next-line no-nested-ternary
        const value = propertiesWithGetter.has(p)
          ? (rawWindow as any)[p]
          : p in target
          ? (target as any)[p]
          : (rawWindow as any)[p];
        return getTargetValue(rawWindow, value);
      },

如何代理window

我们回过头来看这个问题,如何代理window对象,来设想一下几个简单的的情况

  1. 直接指定window的情况
function foo(){
    window.a1 = 10
}
foo()

PS: 我怎么记得之前window不能被重新定义会报错,现在好像不报错了,尴尬

image.png

我们不能直接通过重定义window对象为别的东西来代理window

但是我们可以用个自执行函数,然后给他个window的变量,如下

fakeWindow = {}
!function(window){
  function foo(){
      window.a1 = 10
  }
  foo()
}(fakeWindow)
console.log(fakeWindow)

{a1: 10}

那如果它不写window,直接写变量名呢?

fakeWindow = {a1 : 5}
!function(window){
  function foo(){
      a1 = 10
  }
  foo()
}(fakeWindow)

可以这么写

fakeWindow = {a1 : 5}
!function(window){
with(window){
  function foo(){
      a1 = 10
  }
  foo()
}
}(fakeWindow)

// fakeWindow => (a1 : 10)

这里有一个 with语句 扩展一个语句的作用域链

image.png

简单来说它可以拓展当前的查找范围

例如:

fakeWindow = {a : 10}
with(fakeWindow){
    a=20; // fakeWindow = {a : 20}
}

但是with仅仅作用于查找

并不能影响新变量的写入 并不能影响新变量的写入 并不能影响新变量的写入

例如这样就是不行的!

fakeWindow = {a : 10}
with(fakeWindow){
    b=20; // fakeWindow = {a : 10}
}
// fakeWindow = {a : 10}

等同var,他们不会被沙箱代理到,但是他们会被原生隔离,限定在其作用域范围内,不会影响到其他项目

该功能由import-html-entry 实现

核心代码如下:

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){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy);`
		: `;(function(window, self){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy);`;

逃逸

  1. 非严格模式下的 0,eval("window") 2;非严格模式下的 局部函数

(如果上面有不对的地方可以告诉我,可能我理解的不太对)