为什么要进行 window 隔离
防止不同应用在 window 上的处理(添加/删除 window 属性、注册 window 对象等)干扰其他应用。(确保微应用之间 全局变量/事件 不冲突、污染等。)
本次以 qiankun 的实现为例,分析一下内部的实现逻辑。
ProxySandbox 与 SnapshotSandbox
在 qiankun 中,实现了两种 js 沙箱隔离机制,分别为 Proxysandbox 以及 SnapshotSandbox。二者有啥区别呢。
-
实现上
- ProxySandbox:基于 Proxy,对 window 进行代理,隔离其属性的读写操作。
- SnapshotSandbox:通过保存对 window 进行属性的快照,记录对 window 的 diff,同时在切换微应用时,恢复对 window diff 前的快照。
-
依赖
- 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
构造函数
下面讲一下构造函数会做的操作。
初始化基础参数
- 设置 this.name
- 设置 this.globalContext, 默认为 window
- 设置 this.type = ndBoxType.Proxy
创建 fakeWindow
createFakeWindow 函数中,基于 window 创建了一个 fakeWindow,同时对于有 get 操作符的属性,保存在了一个 Map 中,即 propertiesWithGetter 。这一点看注释是为了性能上的考虑(array-indexof-vs-set-has)。
createFakeWindow 做了如下事情:
- 使用 Object.getOwnPropertyNames 获取 window 上的属性【返回一个数组,其包含给定对象中所有自有属性(包括不可枚举属性,但不包括使用 symbol 值作为名称的属性)。】
- 过滤出不可配置的属性 configurable:false
- 获取对应属性的属性描述符,同时判断该属性是否是一个 访问器属性(有 getter 函数)。对于 top\self\parent\window 改写 configurable 为 true。同时这些属性对于不同浏览器中的行为是不一样的。如注释所示的 top 属性 safari/fireFox 与 chrome 不一致。所以就根据是否有 getter 函数,做了不同处理,对于没有 getter 函数的(即是一个数据属性),改写其 writeable 为 true,有 getter 的,则进行标记在 propertiesWithGetter 中。
- 使用 Object.defineProperty 将不可配置属性定义在 fakeWindow 中,同时对对应的属性描述符进行 freeze,防止 zone.js 改写。
通过 Proxy 代理属性操作
get
- 注册运行中的微应用沙箱。 首先判断当前沙箱是否处于激活态,如果处于激活态,则会拿到当前激活的微应用与传入的微应用名称进行比较,如果当前的为空或则当前的微应用与传入的微应用不同,则会去更新当前的微应用。数据结构如下:
{
name: string;
window: proxy
}
- 对于访问 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 并未返回任何东西:
- 对于 window\self\globalThis 都是直接返回 proxy 实例。
- top\parent。在master应用处于顶层窗口,或者同源的 iframe 中时(window===window.parent),返回 proxy,其他情况会返回 window[top/parent]
- 当属性等于 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);
}
-
document 属性,返回 sandbox 中的 docment,这里的document 会在加载子应用 html 时进行 patch。(这里后面再写一篇介绍下对子应用的处理吧)
-
eval 属性,直接返回了 eval
-
对于 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。 -
对于有 getter 函数的属性,会直接从 gloablContext 上找,否者先判断是否在 fakeWindow 上,不在则使用 gloablContext。
-
对于 freeze 的属性,是直接返回其访问值。
-
对于不是 native 属性的,并且不在使用 native window 做绑定的属性中(排除dev、目前只有 fetch)。也是直接返回其访问值。native 属性如下: qiankun/src/sandbox/globals.ts at master · umijs/qiankun
目前看这里应该针对的是一些自定义属性。
- 对于必须要从 native window 读取的属性,如 fetch。会重新绑定 this 到 native window。其他的则会绑定 this 到 globalContext 上(默认是 window)。进行绑定操作只会针对下面几种 case 生效:
- callable 即为函数:
typeof fn === 'function' && fn instanceOf Function - 非 bounded 函数:
fn.name.indexOf('bound ') === 0 && !fn.hasOwnProperty('prototype'); - 非 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 后的函数赋值其原有的一些属性。
- 复制 fn 自定义属性到 bind 后的函数上: 便利 getOwnPropertyNames(fn) ,通过 defineProperty 赋值到 bind 后的函数上。
- 复制 fn 的 prototype (如果有)到 bind 后的函数上:同样通过 defineProperty 赋值到 bind 后的函数上。但有一点要注意:不能通过
boundValue.prototype = fn.prototype赋值。会触发原型链查找,赋值 readonly 属性报错。详见:ES 拾遗之赋值操作与原型链查找 · Issue #47 · kuitos/kuitos.github.io - 改写 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
- 判断沙箱是否运行,如果运行中,那么就注册 runningApp。进行接下来的属性判断。
- 对于在全局变量白名单列表中的属性,直接设置值在 globalContext 中。
- 否则,对于 globalContext 上已经存在当前 set 的属性时,并且 fakeWindow 上没有。那么就同步 globalContext 属性的 descriptor 到 fakeWindow 代理的对象上。(只有 writable 或者有 setter 的属性才会进行 defineProperty)
- globalContext 上没有当前设置的属性时,直接设置在 fakeWindow 的代理对象上。
- 最后将设置的属性添加到 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
- 对于在 fakeWindow 代理上有的属性 (hasOwnProperty),直接获取 代理上的属性描述符并返回。同时标记该属性在 代理的 fakeWindow 上
descriptorTargetMap.set(p, 'target') - 对于在 globalContext 上有的属性,获取 globalContext 上的属性描述符。同时设置该属性在 globalContext 上
descriptorTargetMap.set(p, 'globalContext')。 同时如果该属性是不可配置,会将其改为可配置 configurable = true。 - 其他情况则返回 undefined
ownKeys
- 通过 Reflect.ownKeys 分别获取 globalContext 以及 fakeWindow 代理对象的属性列表并返回。
defineProperty
- 设置属性的描述符,这里先获取缓存映射的属性来源,即 getOwnPropertyDescriptor handler 中设置的 descriptorTargetMap。按照来源(globalContext、fakeWindow)分别设置属性描述符
deleteProperty
- 注册运行的 app 为当前 proxy
- 如果 fakeWindow 代理对象有属性,则执行删除操作并且移除 updatedValueSet 中缓存的属性。
getPrototypeOf
- 直接返回 globalContext 的原型。
active 与 inactive
这个就更新标志位操作,设置 this.sandboxRunning = true (active) / false (inactive)
SnapshotSandbox
snapshotSandbox 是基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器。这个相对与 proxysandbox 来讲,会更容易理解一点。
构造函数
- 设置 this.name 等于传入的 name
- 设置 this.proxy 等于 window
- 设置 this.type = sandBoxType.Snapshot
active
- 记录当前 window 快照 this.windowSnapshot = {}
- 遍历 window 属性,同时赋值给 windowSnapshot
- 恢复之前的变更,将 this.modifyPropsMap 中的属性赋值给 window
- 删除之前删除的属性,遍历 this.deletePropsSet
- 置 this.sandboxRunning = true
inactive
- 清空变化属性 map 记录, this.modifyPropsMap = {}
- 清空删除属性集合,this.deletePropsSet.clear()
- 遍历 window 属性,当 window 当前属性不等于 windowSnapshot 的属性值时,记录变更点 this.modifyPropsMap[prop] = window[prop] 同时更新 window 的属性为 windowSnapshot 的属性值。window[prop] = this.windowSnapshot[prop]
- 遍历 this.windowSnapshot,如果发现 window 没有属性 key 时,说明新增了属性,需要删除,记录在 this.deletePropsSet 中,同时更新 window[prop] = this.windowSnapshot[prop]恢复环境。
总结
-
对于 ProxySandbox 来讲,源码中还会有很多细节的点,比如对于 proxy 操作来讲,不能违反哪些约束、对属性访问时,会考虑到属性冻结、原型链查找,特殊属性 top、parent、window 处理等。
-
对于 ProxySandbox 与 SnapshotSandbox 的实现基本就看到这里吧,然后其实还存在 LegacySandbox,这个是一个过渡的 sandbox 已经不常用了,对于后面如何应用在子应用中生效的,这部分应该是跟 import-html-entry 有关了,后面在单独写一篇文章去追溯下吧。