微前端icestark源码解读-沙箱sandbox的创建详解(五)

快速链接

icestark源码解读(一):控制微应用加载与卸载的核心原理

icestark源码解读(二):微应用加载详解

icestark源码解读(三):微应用卸载详解

icestark源码解读(四):微应用间数据通信详解

沙箱(sandbox)

首先我们先了解什么是沙箱,一个用于程序独立运行的虚拟环境,并且外界无法修改该环境的任何信息。

为什么需要沙箱

对于我们前端的应用而言,无非就是js和css。当我们遇到应用之间的css或者js相互影响、污染的时候,就该考虑启用沙箱去解决。故沙箱分为css沙箱和js沙箱。

例如:

  1. 主应用和子应用的css文件中都有class为.name的样式,这样两个应用之间的样式就会相互影响
  2. 主应用中有对window.name的操作,子应用中也有对window.name的操作,这样两个应用之间在访问与修改window.name的时候 就会相互影响

css 沙箱

  • 由于icestark从设计上是:页面运行时的同时只会存在一个子应用,故多个子应用之间是不存在样式污染的问题。

  • 但是主应用和子应用是会同时存在的。官方给出的解决方案很直接,使用CSS Modules来解决,。

  • 如果你的主应用和子应用都使用了类似于ant design这样的第三方UI库的话,那么可以给其中一个应用的组件库设置一下class的前缀进行解决。

  • 对于引用类似 normalize.css 这种全局重置样式的话,统一从主应用引入,子应用避免修改全局样式。

js 沙箱

对于js的隔离,官方采用的是Proxy代理实现js沙箱。我们跟随源码来具体看下其实现的过程。源码位于代码库中package/sandbox文件夹下面。

Sandbox是一个类,内部首先定义一些私有变量以供微前端运行时使用。看下面源码以及注释。

private sandbox: Window;

private multiMode = false; // 是否启用多实例模式

private eventListeners = {}; // 记录监听的事件

private timeoutIds: number[] = []; // 记录定时器的id

private intervalIds: number[] = []; // 记录定时器的id

private propertyAdded = {}; // 记录添加的原始window对象身上没有的属性

private originalValues = {}; // 原始window对象身上有的属性,记录下在更改其属性值之前的属性以及属性值

public sandboxDisabled: boolean; // 记录是否禁用沙箱

constructor 函数

在我们执行 new Sandbox的时候,首先执行的是constructor函数,其内部代码很简单。先是判断下浏览器是否支持Proxy,然后就是初始化一个this.sandbox用于储存一个Proxy沙箱。 对于multiMode参数,暂时不进行讲解,用到的地方不多。暂时可以忽略。

constructor(props: SandboxProps = {}) {
  const { multiMode } = props;
  if (!window.Proxy) {
    console.warn('proxy sandbox is not support by current browser');
    this.sandboxDisabled = true; // 浏览器不支持Proxy,则将sandboxDisabled置为true
  }
  // enable multiMode in case of create mulit sandbox in same time
  this.multiMode = multiMode; // 是否启用多实例沙箱
  this.sandbox = null; // 储存沙箱的代理
}

createProxySandbox 函数

该函数用于创建一个Proxy沙箱。

  1. 首先是通过 Object.create(null)去创建一个干净并且可高度定制的一个对象(其身上不会继承Object对象身上的任何方法),用于后续通过Proxy进行代理.

  2. 然后给新创建的对象设置addEventListenerremoveEventListenersetTimeoutsetInterval属性,去劫持原始window对象身上的这些方法,劫持的目的主要是将事件与定时器的id记录在Sandbox的实例中

  3. 最后通过Proxy方法去代理我们创建的对象,通过getset方法,在新对象设置属性以及访问新对象身上的属性的时候,我们可以做一些逻辑处理。

  4. set 方法中, 在设置代理对象身上的属性的时候,首先看设置的属性在原始window对象身上有没有,没有的话记录在propertyAdded之中,如果原始window对象身上有该属性的话,那么就将该属性以及该属性在原始window对象身上的值记录在originalValues中。 如果multiMode未启用的话,会将本次设置的属性以及属性值在设置给代理对象的同时,也会设置给原始window对象。

  5. get方法中,在访问代理对象身上的属性时候,首先判断该属性是不是Symbol.unscopables。 对于Symbol.unscopables的介绍如下图所示:MDN介绍

image.png
由于后面会采用with函数执行script,故这里要排除Symbol.unscopables属性。

  • get方法中,如果访问的key是'top', 'window', 'self', 'globalThis'这四个中任何一个,则直接返回代理对象本身。

  • 如果访问的key是hasOwnProperty, 则首先去看代理对象身上,代理对象身上没有的话则返回原始的window对象hasOwnProperty方法的结果

  • 如果访问的key在代理对象身上可以找到对应的value,则直接返回。否则去createProxySandbox函数的参数身上去找有没有对应的value,有则返回,没有的话,紧接着会去原始window对象身上找。

  • 对于原始window对象身上寻找key对应的value时,若访问的key是eval的话,直接返回eval函数。对于访问的key是window对象身上除eval函数以外的函数的话,首先使用bind函数重置下this的指向,确保指向的是原始window对象。有的函数可能会增加新的属性(例如:Axios, Moment), 故需要遍历一遍函数,将函数身上的属性,全部复制一遍到重置this之后的函数身上。

  • 对于访问的key在window对象对应的value不是函数的话,则直接返回。

  1. Proxy中的has方法很简单,key在代理对象中是否存在,不存在的话则返回key在原始window对象身上是否存在的结果。

  2. 最后一步是将new Proxy返回的代理对象,赋值到this.sandbox身上。

补充知识点:

判断某个属性是否属于某个对象,有两个方式,hasOwnProperty方法和in关键字。

  • in关键字用来判断某个属性属于某个对象,可以是对象的自有属性,也可以是通过prototype继承的属性
  • hasOwnProperty方法用来判断某个属性属于某个对象,只会检查对象的自有属性,通过prototype继承的属性不会检测
/**
 * 创建Proxy沙箱
 * @param injection
 */
createProxySandbox(injection?: object) {
  const { propertyAdded, originalValues, multiMode } = this;
  const proxyWindow = Object.create(null) as Window; // 创建一个干净且高度可定制的对象
  const originalWindow = window; // 缓存原始window对象
  const originalAddEventListener = window.addEventListener; // 缓存原始addEventListener事件绑定函数
  const originalRemoveEventListener = window.removeEventListener;// 缓存原始removeEventListener事件移除函数
  const originalSetInterval = window.setInterval; // 缓存原始定时器setInterval函数
  const originalSetTimeout = window.setTimeout; // 缓存原始定时器setTimeout函数

  // 劫持 addEventListener,将绑定的事件名以及事件的回调函数全部储存在this.eventListeners中
  proxyWindow.addEventListener = (eventName, fn, ...rest) => {
    this.eventListeners[eventName] = (this.eventListeners[eventName] || []);
    this.eventListeners[eventName].push(fn);

    return originalAddEventListener.apply(originalWindow, [eventName, fn, ...rest]);
  };
  // 劫持 removeEventListener, 将解绑的事件名以及事件的回调函数从this.eventListeners中移除掉
  proxyWindow.removeEventListener = (eventName, fn, ...rest) => {
    const listeners = this.eventListeners[eventName] || [];
    if (listeners.includes(fn)) {
      listeners.splice(listeners.indexOf(fn), 1);
    }
    return originalRemoveEventListener.apply(originalWindow, [eventName, fn, ...rest]);
  };
  // 劫持 setTimeout,将每一个定时器的id储存在this.timeoutIds
  proxyWindow.setTimeout = (...args) => {
    const timerId = originalSetTimeout(...args);
    this.timeoutIds.push(timerId); // 存储timerId
    return timerId;
  };
  // 劫持 setInterval,将每一个定时器的id储存在this.intervalIds
  proxyWindow.setInterval = (...args) => {
    const intervalId = originalSetInterval(...args);
    this.intervalIds.push(intervalId); // 存储intervalId
    return intervalId;
  };

  // 创建Proxy,代理proxyWindow
  const sandbox = new Proxy(proxyWindow, {
    /**
     * 设置属性以及属性值
     * @param target 代理的对象 proxyWindow
     * @param p 属性名
     * @param value 属性值
     */
    set(target: Window, p: PropertyKey, value: any): boolean {
      // eslint-disable-next-line no-prototype-builtins
      if (!originalWindow.hasOwnProperty(p)) { // 说明原始window对象身上没有该属性
        // record value added in sandbox
        propertyAdded[p] = value; // 将该属性以及属性值记录在propertyAdded变量中
      // eslint-disable-next-line no-prototype-builtins
      } else if (!originalValues.hasOwnProperty(p)) { // 说明原始window对象身上有该属性, 需要在originalValues中记录下本次设置的属性以及属性值
        // if it is already been setted in original window, record it's original value
        originalValues[p] = originalWindow[p];
      }
      // set new value to original window in case of jsonp, js bundle which will be execute outof sandbox
      if (!multiMode) {
        originalWindow[p] = value; // 将window对象身上没有的属性设置到window对象身上
      }
      // eslint-disable-next-line no-param-reassign
      target[p] = value; // 设置属性以及属性值到代理的对象身上
      return true;
    },
    /**
     * 获取代理对象身上的属性值
     * @param target 代理的对象 proxyWindow
     * @param p 属性名
     */
    get(target: Window, p: PropertyKey): any {
      // Symbol.unscopables 介绍 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Symbol/unscopables
      if (p === Symbol.unscopables) {
        return undefined;
      }
      if (['top', 'window', 'self', 'globalThis'].includes(p as string)) {
        return sandbox;
      }
      // proxy hasOwnProperty, in case of proxy.hasOwnProperty value represented as originalWindow.hasOwnProperty
      if (p === 'hasOwnProperty') {
        // eslint-disable-next-line no-prototype-builtins
        return (key: PropertyKey) => !!target[key] || originalWindow.hasOwnProperty(key);
      }

      const targetValue = target[p];
      /**
       * Falsy value like 0/ ''/ false should be trapped by proxy window.
       */
      if (targetValue !== undefined) {
        // case of addEventListener, removeEventListener, setTimeout, setInterval setted in sandbox
        return targetValue;
      }

      // search from injection
      const injectionValue = injection && injection[p];
      if (injectionValue) {
        return injectionValue;
      }

      const value = originalWindow[p];

      /**
      * use `eval` indirectly if you bind it. And if eval code is not being evaluated by a direct call,
      * then initialise the execution context as if it was a global execution context.
      * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval
      * https://262.ecma-international.org/5.1/#sec-10.4.2
      */
      if (p === 'eval') {
        return value;
      }

      if (isWindowFunction(value)) { // 判断是不是window对象身上的函数
        // When run into some window's functions, such as `console.table`,
        // an illegal invocation exception is thrown.
        const boundValue = value.bind(originalWindow); // 更改this指向为原始window对象

        // Axios, Moment, and other callable functions may have additional properties.
        // Simply copy them into boundValue.
        for (const key in value) {
          boundValue[key] = value[key];
        }

        return boundValue;
      } else {
        // case of window.clientWidth、new window.Object()
        return value;
      }
    },
    /**
     * 用于判断代理对象身上是否有指定的属性
     * @param target 代理对象
     * @param p 属性的key
     */
    has(target: Window, p: PropertyKey): boolean {
      return p in target || p in originalWindow;
    },
  });
  this.sandbox = sandbox;
}

execScriptInSandbox 函数

从命名上我们可以看出,该函数的作用是执行沙箱里面的js代码。其核心是通过with + new Function 去创建沙箱运行环境,从而执行js。

  • 在js的执行中,访问变量是通过作用域链来进行查找,在with块级作用域下,访问变量会先从with指定的对象身上查找,指定的对象身上找不到的话,则正常按照作用域链去向上找。MDN: with语句描述
  • 使用new Function 关键字去创建函数,通过bind函数,将this绑定到代理对象this.sandbox上面,将代理对象this.sandbox作为参数传入进去。MDN: new Function 描述

通过上面两个步骤可以将沙箱内js对window对象的访问与修改,转向去对代理对象this.sandbox的访问与修改。

/**
 * 执行沙箱里面的js代码
 * @param script
 */
execScriptInSandbox(script: string): void {
  if (!this.sandboxDisabled) {
    // create sandbox before exec script
    if (!this.sandbox) {
      this.createProxySandbox();
    }
    try {
      // with 语句中  执行的js,在访问变量的时候都会先从sandbox对象身上找
      const execScript = `with (sandbox) {;${script}\n}`; // 要执行的js代码
      // eslint-disable-next-line no-new-func
      // 创建一个sandbox作为参数的函数
      const code = new Function('sandbox', execScript).bind(this.sandbox);
      // 将this.sandbox作为参数传入函数内部
      code(this.sandbox);
    } catch (error) {
      console.error(`error occurs when execute script in sandbox: ${error}`);
      throw error;
    }
  }
}

clear 函数

顾名思义其作用就是用来清空沙箱,当子应用卸载的时候,肯定要把为子应用创建的沙箱清空掉。其本质是将微应用沙箱执行js后产生的影响全部消除。

  1. this.eventListeners中储存的所有的事件全部解除绑定
  2. 清除所有的定时器(setInterval、setTimeout)
  3. this.originalValues中储存的原始window对象身上key对应的value,进行恢复
  4. 根据this.propertyAdded,将原始window对象身上没有的属性全部移除
/**
 * 清空沙箱
 */
clear() {
  if (!this.sandboxDisabled) {
    // remove event listeners
    Object.keys(this.eventListeners).forEach((eventName) => {
      (this.eventListeners[eventName] || []).forEach((listener) => {
        window.removeEventListener(eventName, listener);
      });
    });
    // clear timeout
    this.timeoutIds.forEach((id) => window.clearTimeout(id));
    this.intervalIds.forEach((id) => window.clearInterval(id));
    // recover original values
    Object.keys(this.originalValues).forEach((key) => {
      window[key] = this.originalValues[key];
    });
    Object.keys(this.propertyAdded).forEach((key) => {
      delete window[key];
    });
  }
}

沙箱的创建时机

上面我们介绍了沙箱的实现原理,这部分我们从源码的角度上看下,是在什么时候去创建的沙箱。

从官方文档上面对开启沙箱的描述来看,只需要在appConfig中配置sandboxtrue即可开启子应用沙箱.

源码中,在监听路由变化,控制子应用加载与卸载的reroute函数,创建子应用createMicroApp函数中,会根据appConfigsandbox配置,去执行createSandbox函数创建沙箱。

createSandbox函数

该函数内部很简单,主要就是实例化Sandbox类,得到一个沙箱的实例并返回。有了沙箱实例之后,我们就可以调用实例身上的createProxySandbox方法,去创建window的代理对象。

export function createSandbox(sandbox?: boolean | SandboxProps | SandboxConstructor) {
  // Create appSandbox if sandbox is active
  let appSandbox = null;
  if (sandbox) {
    if (typeof sandbox === 'function') {
      // eslint-disable-next-line new-cap
      appSandbox = new sandbox();
    } else {
      const sandboxProps = typeof sandbox === 'boolean' ? {} : (sandbox as SandboxProps);
      // 实例一个沙箱
      appSandbox = new Sandbox(sandboxProps);
    }
  }
  return appSandbox;
}

在通过fetch的方式去加载子应用的jsloadScriptByFetch函数中,去执行getGobalWindow函数,获取子应用的window对象。

getGobalWindow 函数

export function getGobalWindow(sandbox?: Sandbox) {
  if (sandbox?.getSandbox) {
    // 开启了sandbox的话,则去创建沙箱的代理对象并返回
    sandbox.createProxySandbox();
    return sandbox.getSandbox();
  }
  // FIXME: If run in Node environment
  return window; // 未开启sandbox直接返回原始window对象,作为子应用的全局window对象
}

在fetch获取到子应用的js字符串之后,执行js的函数executeScripts中,通过沙箱实例身上的execScriptInSandbox方法,去执行子应用的js,从而将子应用中对变量的访问与操作,全部转向从沙箱中的代理对象上访问与操作,以此隔绝了子应用对全局原始window对象的修改。

executeScripts 函数

function executeScripts(scripts: string[], sandbox?: Sandbox, globalwindow: Window = window) {
  let libraryExport = null;

  for (let idx = 0; idx < scripts.length; ++idx) {
    const lastScript = idx === scripts.length - 1;
    if (lastScript) {
      noteGlobalProps(globalwindow);
    }

    if (sandbox?.execScriptInSandbox) {
      sandbox.execScriptInSandbox(scripts[idx]); // 子应用开启了沙箱,则在沙箱中执行js
    } else {
      // https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/eval
      // eslint-disable-next-line no-eval
      (0, eval)(scripts[idx]); // 未开启沙箱的子应用,则通过eval 执行子应用的js
    }

    if (lastScript) {
      libraryExport = getGlobalProp(globalwindow);
    }
  }

  return libraryExport;
}

总结

从上面的介绍来看,icestark沙箱是基于Proxy进行创建的,并且一次只能运行一个微应用。对于其创建的过程,理解上并不是很难。

接下来我们简单总结下其整个过程:

  1. 创建子应用的时候,开始创建沙箱
  2. 创建一个代理window对象,通过Proxy进行代理
  3. fetch获取到子应用的js,通过with + new Function的方式去执行子应用js,将子应用中对于window身上变量的访问与修改,改为从代理window对象身上去访问与修改。如果子应用访问代理对象身上的变量没找到的话,则会去从原始window对象身上访问。
  4. 这样下来,主应用是在操作原始windo对象,而子应用是在操作代理window对象,两者就不会相互影响了。
© 版权声明
THE END
喜欢就支持一下吧
点赞9 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容