qiankun - 前端沙箱

251 阅读11分钟

背景
[qiankun]隔离沙箱有两种,

  • 一种是js变量隔离沙箱,LegacySandbox 单例沙箱、ProxySandbox 多例沙箱、SnapshotSandbox 低版本沙箱 ,
  • 一种是样式隔离沙箱,样式隔离沙箱通过配置start(options)中的参数决定

1.前端沙箱是什么?

沙箱,即sandbox,本质上就是一种为了限制程序的错误影响范围,而做出的一种安全隔离机制。默认情况下,程序是可以访问机器上的所有资源的(cpu、内存、文件、网络),但是如果没有限制可能会造成:破坏正在运行的资源,数据泄漏。。。 对于这种情况,我们需要给程序的操作权限加以限制,比较常见的是将这段程序认为是一个用户,给这个用户分配角色,这个角色可以有哪些操作的权限:只读、操作部分文件等等。
然而还有另一种限制方案:给这个程序运行的环境进行限制,即沙箱限制。

这两种方案的差别在于:
1.操作权限限制的安全策略是对每一个资源外部进行安全盒子封装限制,外部资源访问时候都需要经过自身安全盒子的限制检查,通过则可以访问,但这么做的成本在于我需要对系统中每一个资源都要进行遍历和安全封装,代价较大。
2.沙箱限制是对“危险”本身进行“盒子封装限制”,通过限制这个环境盒子的操作来实现安全限制。
即,一个封装自己防御别人,一个直接限制危险。 沙箱,即sandbox,本质上就是一种为了限制程序的错误影响范围,而做出的一种安全隔离机制。可以认为沙箱模式是一种黑盒模式,在黑盒中运行的js程序只会影响当前的作用域。 是给不可信的代码提供一定的运行环境(这部分运行环境和普通环境的差别在于),当这个环境内代码访问需要隔离外的资源时候需要进行限制。
总结:沙箱模式是一种安全隔离机制,在这个机制内专门用来运行不可信代码,限制影响范围和限制外部资源请求。

2.为什么要做前端沙箱?

前端什么时候需要沙箱?当我们需要解析和执行不可信的js的时候就需要沙箱隔离。
那么什么时候前端需要解析和运行不可行的js呢?

  1. 执行第三方js
  2. 解析执行服务端返回的jsonp函数
  3. 服务端渲染
  4. vue模板中表达式计算 在qiankun中,需要加载很多子应用,如果子应用中和主应用都对window定义了一个共同的变量,那么这个时候就会造成变量污染,如果都在window中监听了同一个事件,那么也会造成冲突。

3.qiankun中为什么要做沙箱?

主要原因: 是为了隔离子应用对window对象的修改,会影响到主应用中window对象的属性值,进而需要隔离主子应用之间的window。

当我们在子应用中在window中挂载一个变量的时候window.a = 1,然后切换回到主应用时候发现window.a仍然存在,但是主应用中并没有定义属性a,这就导致了子应用定义的全局变量会污染到主应用。所以应该讲设置一个js沙箱,让子应用的全局变量不影响到主应用。

4.qiankun中三种沙箱原理 :实现js隔离

背景:当我们在子应用中在window中挂载一个变量的时候window.a = 1,然后切换回到主应用时候发现window.a仍然存在,但是主应用中并没有定义属性a,这就导致了子应用定义的全局变量会污染到主应用。所以应该讲设置一个js沙箱,让子应用的全局变量不影响到主应用。
所以,qiankun为什么要做JS沙箱?针对的就是window对象,当切换多个子应用时候,如何保证每个子应用中使用的window只供自己使用,而不被其他子应用设置相同字段所污染,故设立了“沙箱机制”。

4.1 快照沙箱

快照沙箱(snapshot-sandbox),顾名思义就是给你一张相片来记录你当前的状态。
使用场景:1.适用于只有单个子应用; 2.不支持window.proxy的低版本浏览器
原理:基于diff实现,通过两个缓存对象来分别记录
将要激活沙箱:通过windowSnapshot来记录此时window的状态,记录好之后将上一次退出沙箱的状态modifyPropsMap赋值到window上。 将要推出沙箱:通过对比快照对象和当前window对象的差别属性,将差别属性记录到

export class {
    constructor() {
        this.proxy= window; // 创建一个代理变量proxy,这个变量指向window
        this.active(); // 创建沙箱时候自动激活
    }
    // 激活沙箱
    active() {
        // 创建一个沙箱快照
        this.snapshot = new Map();
        // 遍历全局环境
        for(const key in window) {
            this.snapshot[key] = window[key];
        }
    }
    // 销毁沙箱
    inactive() {
        for (const key in window) {
            if (window[key] !== this.snapshot[key]) {
                // 还原操作
                window[key] = this.snapshot[key]
            }
        }
    }
}

快照沙箱的缺点:window中的属性有很多,通过迭代去一一遍历的话会影响性能。 当子应用的unmount生命周期执行时,会执行沙箱的inactive函数。
问题1: 在销毁沙箱的时候为什么是遍历window,这个时候window是当前子应用下要使用的window,如果有属性被删除,那么快照对象不是和window缺少了一个对比属性?是不是应该以snapshot对象遍历,然后不一样的时候赋值呢?

4.2 单例代理沙箱

基础知识
proxy代理,Proxy是构造函数,用来生成proxy实例,var proxy = new Proxy(target, handler) ,其中target表示想要针对哪个目标对象进行拦截。

set函数接收的四个参数:
1.trapTarget:用于接收属性(代理的目标)的对象
2.key:要写入的属性键(字符串或者symbol)
3.value:被写入的属性值
4.receiver:操作发生的对象(通常是代理)

let target = { name: "target" } 
let proxy = new Proxy(target, {
    // get函数有两个参数,目标对象和key,在get中执行取值操作,并返回:要有返回值   return target【key】
    get: function (target, key) { 
        console.log(`捕获到对象获取${key}属性的值操作`); 
        return target[key]; 
    },
    // set函数拿到三个参数:target,key值和要设置的value值,在set中执行赋值操作target【key】= value的操作
    set: function (target, key, val) { 
        console.log(`捕获到对象设置${key}属性的值操作,新值为${val}`); 
        target[key] = val; 
    }
 })

单例沙箱实现原理: 单例代理沙箱通过三个参数来记录全局变量,分别记录新增的全局变量,更新的全局变量,以及时刻记录全局变量变化的变量(新增和更新)

核心在于:如何去避免遍历window的属性,来达到提速的效果。 将更新的属性(modifyprops对象)和新增的属性(addprops对象)都记录下来
inactive销毁的时候,对于更新的属性值全部还原成原来的window上的初始值,对于新增的属性则从row_window中全部删除, 最终还原成初始时候的window。
active创建的时候,currentUpdatedProps对象需要对window对象初始化,遍历currentUpdatedProps这个map对象,将属性和值都赋值到window对象中。因为这个对象作用就是将所有的更新还是增加属性都记录下来,以便后期再次在沙箱运行的时候给window重新赋值。

// 这里重点区分两个操作动作:
// 1.新增动作:如果说要操作的当前属性在window中不存在,那么就一定是新增的属性(那么就要存在add对象中)
// 2.更新动作:如果这个对象存在,那么就应该是要修改。那么我们就要记录:(1)被修改之前window的初始值(2)然后为了下次激活可以给window赋值当前修改后的🈯️,因此还需要记录当前更新的值(3)最后再将更新的值赋给window 
// 这么看我们需要准备3个对象来记录,一个是用记录来原来window的初始值(更新前先记录originKey的originValue),另外两个是用来激活时候赋值(一个记录增加值,另一个记录本次所有的修改更新值)
// 这里比较容易忽略的就是修改之前,要保存之前的window的原始值(如果origin对象存在这个属性那么说明已经保存过了直接修改即可,如果没有则需要对origin对象新增属性,这个时候就需要先保存再修改),然后才能修改window,
class Legacy {
  constructor() {
    // 沙箱期间新增的全局变量
    this.addedPropsMapInSandbox = {};
    // 沙箱期间更新的全局变量
    this.modifiedPropsOriginalValueMapInSandbox = {};
    // 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot
    this.currentUpdatedPropsValueMap = {};
    const rawWindow = window;
    const fakeWindow = Object.create(null);
    this.sandboxRunning = true;
    const proxy = new Proxy(fakeWindow, {
      set: (target, prop, value) => {
        // 如果是激活状态
        if(this.sandboxRunning) {
          // 判断当前window上存不存在该属性
          if(!rawWindow.hasOwnProperty(prop)) {
            // 记录新增值
            this.addedPropsMapInSandbox[prop] = value;
          } else if(!this.modifiedPropsOriginalValueMapInSandbox[prop]) {
            // 记录更新值的初始值
            const originValue = rawWindow[prop]
            this.modifiedPropsOriginalValueMapInSandbox[prop] = originValue;
          }
          // 纪录此次修改的属性
          this.currentUpdatedPropsValueMap[prop] = value;
          // 将设置的属性和值赋给了当前window,还是污染了全局window变量
          rawWindow[prop] = value;
          return true;
        }
        return true;
      },
      get: (target, prop) => {
        return rawWindow[prop];
      }
    })
    this.proxy = proxy;
  }
  active() {
    if (!this.sandboxRunning) {
      // 还原上次修改的值
      for(const key in this.currentUpdatedPropsValueMap) {
        window[key] = this.currentUpdatedPropsValueMap[key];
      }
    }

    this.sandboxRunning = true;
  }
  inactive() {
    // 将更新值的初始值还原给window
    for(const key in this.modifiedPropsOriginalValueMapInSandbox) {
      window[key] = this.modifiedPropsOriginalValueMapInSandbox[key];
    }
    // 将新增的值删掉
    for(const key in this.addedPropsMapInSandbox) {
      delete window[key];
    }

    this.sandboxRunning = false;
  }
}

4.3 多例代理沙箱

前面两种沙箱中:
第一种的缺点有两个:1.需要遍历window属性,然后赋值和恢复; 2.只能代理单个子应用
第二种通过proxy代理,解决第一个沙箱遍历耗时的问题,但是仍然没有解决只能代理单个子应用的问题。

那么什么场景下需要使用多例沙箱呢? 所以诞生了多例沙箱.一般情况下,我们的中后台系统在同一时间只会去加载一个子应用去运行,但是如果我们需要在当前页面下维护两个子应用呢?上面的两种方法就会有一个问题,子应用1操作的window和子应用2操作的window都是指向全局window,这个时候就存在同时操作window属性冲突的问题!所以,多实例是通过每一个沙箱的操作都是通过沙箱的fake-window进行操作,获取的话也是先从fake-window中获取,没有再从上面的window获取值。这样的proxySandBox 不会直接操作 window 对象。并且为了避免子应用操作或者修改主应用上诸如 window、document、location 这些重要的属性,会遍历这些属性到子应用 window 副本(fakeWindow)上。

原理:获取的时候从fakeWindow中获取,设置属性的时候也只对facewindow进行设置。不会影响到外面的window值。这样从根本上避开变量污染。

image.png 原理图(来源juejin.cn/post/692011…

  class ProxySandbox {
    active() {
      this.sandboxRunning = true;
    }
    inactive() {
      this.sandboxRunning = false;
    }
    constructor() {
      const rawWindow = window;
      const fakeWindow = {};
      const proxy = new Proxy(fakeWindow, {
        set: (target, prop, value) => {
          if(this.sandboxRunning) {
            target[prop] = value;
            return true;
          }
        },
        get: (target, prop) => {
          // 如果fakeWindow里面有,就从fakeWindow里面取,否则,就从外部的window里面取
          let value = prop in target ? target[prop] : rawWindow[prop];
          return value
        }
      })
      this.proxy = proxy;
    }
  }

总结
qiankun中设置js沙箱的目的是为了防止这种场景的发生:子应用中设置或者修改了window对象属性,影响到了主应用或者其他子应用。而为了解决这种问题,乾坤提供了三种解决方案
1.快照沙箱
原理:进入子应用之前遍历所有的属性记录,出来后把这张照片还原
2.单例沙箱
原理:通过代理来拦截所有对window的操作,然后通过window.isOwnProperty()的有无来判断是操作的类型:新增还是修改,分别放在对应的存储中。然后在离开的时候执行对应的删除和还原操作
3.多例沙箱
原理:通过代理来拦截所有对window的操作,然后通过window.isOwnProperty()的有无来判断是操作的类型:新增还是修改,分别放在对应的存储中。这里修改不再直接在window上修改,而是设置一个新的对象来承接修改,这样下次查看的时候,如果修改对象有的key就拿修改对象的值,如果没有就是未修改过,则通过window[key]获取。
也就是说,多例沙箱中全局的主应用的window只能够查询,不能做修改或者添加操作,这是和单例沙箱(两种方式)最大的区别

测试demo: image.png 结果为: image.png 可以看出,在沙箱里面更新的属性在沙箱里面是有更新效果的,但是在沙箱外面的全局window中,属性并没有改变。 juejin.cn/post/686564…
juejin.cn/post/692011…
juejin.cn/post/689664…