微前端学习 - window 隔离篇

329 阅读9分钟

为什么要进行 window 隔离

防止不同应用在 window 上的处理(添加/删除 window 属性、注册 window 对象等)干扰其他应用。(确保微应用之间 全局变量/事件 不冲突、污染等。)

本次以 qiankun 的实现为例,分析一下内部的实现逻辑。

ProxySandbox 与 SnapshotSandbox

在 qiankun 中,实现了两种 js 沙箱隔离机制,分别为 Proxysandbox 以及 SnapshotSandbox。二者有啥区别呢。

  1. 实现上

    • ProxySandbox:基于 Proxy,对 window 进行代理,隔离其属性的读写操作。
    • SnapshotSandbox:通过保存对 window 进行属性的快照,记录对 window 的 diff,同时在切换微应用时,恢复对 window diff 前的快照。
  2. 依赖

    • ProxySandbox:依赖 Proxy,对于不支持 Proxy 的浏览器降级为 SnapshotSandbox。
    • SnapshotSandbox:无 api 依赖。

沙箱结构

export type SandBox = {
  /** 沙箱的名字 */
  name: string;
  /** 沙箱的类型 */
  type: SandBoxType;
  /** 沙箱导出的代理实体 */
  proxy: WindowProxy;
  /** 沙箱是否在运行中 */
  sandboxRunning: boolean;
  /** latest set property */
  latestSetProp?: PropertyKey | null;
  patchDocument: (doc: Document) => void;
  /** 启动沙箱 */
  active: () => void;
  /** 关闭沙箱 */
  inactive: () => void;
};

ProxySandbox

构造函数

下面讲一下构造函数会做的操作。

初始化基础参数

  1. 设置 this.name
  2. 设置 this.globalContext, 默认为 window
  3. 设置 this.type = ndBoxType.Proxy

创建 fakeWindow

createFakeWindow 函数中,基于 window 创建了一个 fakeWindow,同时对于有 get 操作符的属性,保存在了一个 Map 中,即 propertiesWithGetter 。这一点看注释是为了性能上的考虑(array-indexof-vs-set-has)。

createFakeWindow 做了如下事情:

  1. 使用 Object.getOwnPropertyNames 获取 window 上的属性【返回一个数组,其包含给定对象中所有自有属性(包括不可枚举属性,但不包括使用 symbol 值作为名称的属性)。】
  2. 过滤出不可配置的属性 configurable:false

non-configable.png

  1. 获取对应属性的属性描述符,同时判断该属性是否是一个 访问器属性(有 getter 函数)。对于 top\self\parent\window 改写 configurable 为 true。同时这些属性对于不同浏览器中的行为是不一样的。如注释所示的 top 属性 safari/fireFox 与 chrome 不一致。所以就根据是否有 getter 函数,做了不同处理,对于没有 getter 函数的(即是一个数据属性),改写其 writeable 为 true,有 getter 的,则进行标记在 propertiesWithGetter 中。
  2. 使用 Object.defineProperty 将不可配置属性定义在 fakeWindow 中,同时对对应的属性描述符进行 freeze,防止 zone.js 改写。

通过 Proxy 代理属性操作

get
  1. 注册运行中的微应用沙箱。 首先判断当前沙箱是否处于激活态,如果处于激活态,则会拿到当前激活的微应用与传入的微应用名称进行比较,如果当前的为空或则当前的微应用与传入的微应用不同,则会去更新当前的微应用。数据结构如下:
{
    name: string;
    window: proxy
}
  1. 对于访问 Symbol.unscopables 属性,会返回内置的 unscopables,其值为 globalsInES2015 + requestAnimationFrame。会对其进行 array2TruthyObject 操作。其中 globalsInES2015 如下所示:
// generated from https://github.com/sindresorhus/globals/blob/main/globals.json es2015 part
// only init its values while Proxy is supported
export const globalsInES2015 = window.Proxy
  ? [
      'Array',
      'ArrayBuffer',
      'Boolean',
      'constructor',
      'DataView',
      'Date',
      'decodeURI',
      'decodeURIComponent',
      'encodeURI',
      'encodeURIComponent',
      'Error',
      'escape',
      'eval',
      'EvalError',
      'Float32Array',
      'Float64Array',
      'Function',
      'hasOwnProperty',
      'Infinity',
      'Int16Array',
      'Int32Array',
      'Int8Array',
      'isFinite',
      'isNaN',
      'isPrototypeOf',
      'JSON',
      'Map',
      'Math',
      'NaN',
      'Number',
      'Object',
      'parseFloat',
      'parseInt',
      'Promise',
      'propertyIsEnumerable',
      'Proxy',
      'RangeError',
      'ReferenceError',
      'Reflect',
      'RegExp',
      'Set',
      'String',
      'Symbol',
      'SyntaxError',
      'toLocaleString',
      'toString',
      'TypeError',
      'Uint16Array',
      'Uint32Array',
      'Uint8Array',
      'Uint8ClampedArray',
      'undefined',
      'unescape',
      'URIError',
      'valueOf',
      'WeakMap',
      'WeakSet',
    ].filter((p) => /* just keep the available properties in current window context */ p in window)
  : [];

这一点比较奇怪的是,在真实的 window 中去访问 Symbol.unscopables 并未返回任何东西:

BCTE$1OCJ@XNGX1D.png

  1. 对于 window\self\globalThis 都是直接返回 proxy 实例。
  2. top\parent。在master应用处于顶层窗口,或者同源的 iframe 中时(window===window.parent),返回 proxy,其他情况会返回 window[top/parent]
  3. 当属性等于 hasOwnProperty 时,会返回内置的hasOwnProperty 函数,其实现为,当传入的 obj 是一个普通对象时(即使用 call 改变 this 调用时),(不为 proxy 本身),会直接调用 Object.prototype.hasOwnProperty,否则就在 fakeWindow 或者 globalContext(window)上调用 hasOwnProperty 查找。
    function hasOwnProperty(this: any, key: PropertyKey): boolean {
      // calling from hasOwnProperty.call(obj, key)
      if (this !== proxy && this !== null && typeof this === 'object') {
        return Object.prototype.hasOwnProperty.call(this, key);
      }

      return fakeWindow.hasOwnProperty(key) || globalContext.hasOwnProperty(key);
    }
  1. document 属性,返回 sandbox 中的 docment,这里的document 会在加载子应用 html 时进行 patch。(这里后面再写一篇介绍下对子应用的处理吧)

  2. eval 属性,直接返回了 eval

  3. 对于 string 类型的属性,源码貌似有点错误qiankun/src/sandbox/proxySandbox.ts at master · umijs/qiankun,直接判断了 p==='string',应该少了 typeof,提个 pr 小改下,因为在 set handler 中也有类似的逻辑(且 set 中会持续添加白名单属性 globalVariableWhiteList)。同时不在白名单中(排除 dev 字段,就只有 System、__cjsWrapper 两个属性)。具体原因跟 System.js 有关了。目的是为了保持 System.js 运行正常吧,然后就把这两个赋值在 globalContext(window) 上了,脱离了 沙箱 window。

  4. 对于有 getter 函数的属性,会直接从 gloablContext 上找,否者先判断是否在 fakeWindow 上,不在则使用 gloablContext。

  5. 对于 freeze 的属性,是直接返回其访问值。

  6. 对于不是 native 属性的,并且不在使用 native window 做绑定的属性中(排除dev、目前只有 fetch)。也是直接返回其访问值。native 属性如下: qiankun/src/sandbox/globals.ts at master · umijs/qiankun

目前看这里应该针对的是一些自定义属性。

  1. 对于必须要从 native window 读取的属性,如 fetch。会重新绑定 this 到 native window。其他的则会绑定 this 到 globalContext 上(默认是 window)。进行绑定操作只会针对下面几种 case 生效:
  2. callable 即为函数: typeof fn === 'function' && fn instanceOf Function
  3. 非 bounded 函数:fn.name.indexOf('bound ') === 0 && !fn.hasOwnProperty('prototype');
  4. 非 Constructable 函数: 两轮判断,第一轮fn.prototype && fn.prototype.constructor === fn && Object.getOwnPropertyNames(fn.prototype).length > 1; 。为什么是 length>1呢,因为 prototype 上默认会有 constructor 属性,如果大于 1,则说明有 原型链上有其他方法。第二轮判断则是通过正则去判断。规则如下:
    1. 有 prototype 并且 prototype 上有定义一系列非 constructor 属性
    2. 函数名大写开头
    3. class 函数
    满足其一则可认定为构造函数

通过判断后,还会对绑定 this 后的函数赋值其原有的一些属性。

  1. 复制 fn 自定义属性到 bind 后的函数上: 便利 getOwnPropertyNames(fn) ,通过 defineProperty 赋值到 bind 后的函数上。
  2. 复制 fn 的 prototype (如果有)到 bind 后的函数上:同样通过 defineProperty 赋值到 bind 后的函数上。但有一点要注意:不能通过 boundValue.prototype = fn.prototype 赋值。会触发原型链查找,赋值 readonly 属性报错。详见:ES 拾遗之赋值操作与原型链查找 · Issue #47 · kuitos/kuitos.github.io
  3. 改写 bind 后的函数的 toString 返回值: 为了保持 toString 的一致性:
function call() {
  return "toString return";
}
const o = {};
const bounded = Function.prototype.bind.call(call, o);
console.log(call.toString());
//function call() {
//  return "toString return";
//}

console.log(bounded.toString());
// function () { [native code] }
set
  1. 判断沙箱是否运行,如果运行中,那么就注册 runningApp。进行接下来的属性判断。
  2. 对于在全局变量白名单列表中的属性,直接设置值在 globalContext 中。
  3. 否则,对于 globalContext 上已经存在当前 set 的属性时,并且 fakeWindow 上没有。那么就同步 globalContext 属性的 descriptor 到 fakeWindow 代理的对象上。(只有 writable 或者有 setter 的属性才会进行 defineProperty)
  4. globalContext 上没有当前设置的属性时,直接设置在 fakeWindow 的代理对象上。
  5. 最后将设置的属性添加到 updatedValueSet(记录 window 值变更记录) 中,同时标记 latestSetProp 为当前设置属性。
has

拦截 in 操作符

has handler 会先判断 in cachedGlobalObjects 再判断 in fakeWindow 代理的对象上,最后判断 in globalContext 上。

注意这里的 cachedGlobalObjects:property in cachedGlobalObjects must return true to avoid escape from get trap。因为对于不可配置数据来讲,如果 has 拦截 in 操作符返回 false,则会出现报错异常。详见:handler.has() - JavaScript | MDN

可以查看下面代码示例的 console 信息。

getOwnPropertyDescriptor
  1. 对于在 fakeWindow 代理上有的属性 (hasOwnProperty),直接获取 代理上的属性描述符并返回。同时标记该属性在 代理的 fakeWindow 上descriptorTargetMap.set(p, 'target')
  2. 对于在 globalContext 上有的属性,获取 globalContext 上的属性描述符。同时设置该属性在 globalContext 上 descriptorTargetMap.set(p, 'globalContext') 。 同时如果该属性是不可配置,会将其改为可配置 configurable = true。
  3. 其他情况则返回 undefined
ownKeys
  1. 通过 Reflect.ownKeys 分别获取 globalContext 以及 fakeWindow 代理对象的属性列表并返回。
defineProperty
  1. 设置属性的描述符,这里先获取缓存映射的属性来源,即 getOwnPropertyDescriptor handler 中设置的 descriptorTargetMap。按照来源(globalContext、fakeWindow)分别设置属性描述符
deleteProperty
  1. 注册运行的 app 为当前 proxy
  2. 如果 fakeWindow 代理对象有属性,则执行删除操作并且移除 updatedValueSet 中缓存的属性。
getPrototypeOf
  1. 直接返回 globalContext 的原型。

active 与 inactive

这个就更新标志位操作,设置 this.sandboxRunning = true (active) / false (inactive)

SnapshotSandbox

snapshotSandbox 是基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器。这个相对与 proxysandbox 来讲,会更容易理解一点。

构造函数

  1. 设置 this.name 等于传入的 name
  2. 设置 this.proxy 等于 window
  3. 设置 this.type = sandBoxType.Snapshot

active

  1. 记录当前 window 快照 this.windowSnapshot = {}
  2. 遍历 window 属性,同时赋值给 windowSnapshot
  3. 恢复之前的变更,将 this.modifyPropsMap 中的属性赋值给 window
  4. 删除之前删除的属性,遍历 this.deletePropsSet
  5. 置 this.sandboxRunning = true

inactive

  1. 清空变化属性 map 记录, this.modifyPropsMap = {}
  2. 清空删除属性集合,this.deletePropsSet.clear()
  3. 遍历 window 属性,当 window 当前属性不等于 windowSnapshot 的属性值时,记录变更点 this.modifyPropsMap[prop] = window[prop] 同时更新 window 的属性为 windowSnapshot 的属性值。window[prop] = this.windowSnapshot[prop]
  4. 遍历 this.windowSnapshot,如果发现 window 没有属性 key 时,说明新增了属性,需要删除,记录在 this.deletePropsSet 中,同时更新 window[prop] = this.windowSnapshot[prop]恢复环境。

总结

  • 对于 ProxySandbox 来讲,源码中还会有很多细节的点,比如对于 proxy 操作来讲,不能违反哪些约束、对属性访问时,会考虑到属性冻结、原型链查找,特殊属性 top、parent、window 处理等。

  • 对于 ProxySandbox 与 SnapshotSandbox 的实现基本就看到这里吧,然后其实还存在 LegacySandbox,这个是一个过渡的 sandbox 已经不常用了,对于后面如何应用在子应用中生效的,这部分应该是跟 import-html-entry 有关了,后面在单独写一篇文章去追溯下吧。

参考