章
目
录
乾坤(qiankun)作为一款热门的微前端框架,其中,沙箱隔离机制是保障微应用独立运行的关键,尤其是JS沙箱,在防止微应用之间的代码冲突方面发挥着重要作用。接下来,我们就深入探究一下qiankun中JS沙箱的原理与实现。
一、为什么需要JS沙箱
在实际开发中,我们经常会遇到一些棘手的问题。比如,我之前参与一个JSP项目时,就发现JSON.stringify({name: '张三'})
的结果是'"{\\"name\\":\\"张三\\"}"'
,而当使用JSON.parse()
解析时,得到的竟然还是字符串。经过仔细审查,才发现原来是JSON
方法被重写了。
这种情况并非个例,有些插件为了实现特定功能,会重写很多方法。像Vue2为了实现数据监听,重写了Array
的一系列方法;single - spa
为了监听路由,重写了pushState
、replaceState
等方法(不过这些重写一般不会影响原API的正常使用)。但像我遇到的JSON
方法被重写的情况,就直接影响了原API的执行结果。
qiankun中默认开启了js、window
沙箱,这是非常有必要的。它主要通过三种沙箱来实现隔离:SnapshotSandbox
、ProxySandbox
以及下文会提到的LegacySandbox
(原文未详细提及,简单介绍下)。接下来,我们分别深入了解一下这几种沙箱的实现原理。
二、SnapshotSandbox沙箱
(一)SnapshotSandbox沙箱的原理
SnapshotSandbox是针对不支持proxy api
的低版本浏览器设计的,它采用快照的形式来实现沙箱隔离。简单来说,就是在微应用加载时,记录下当前window
对象的状态(也就是拍个“快照”),当微应用离开时,对比当前window
对象和快照的差异,把更改的部分恢复,并记录这些变更;再次进入微应用时,恢复之前记录的变更。
(二)SnapshotSandbox沙箱的代码实现
下面我们结合代码来深入理解一下:
import type { SandBox } from '../interfaces';
import { SandBoxType } from '../interfaces';
// 定义一个函数,用于循环遍历window对象的属性,并对每个属性执行回调函数
function iter(obj: typeof window, callbackFn: (prop: any) => void) {
// 使用for...in循环遍历对象的属性
// eslint-disable-next-line guard-for-in, no-restricted-syntax
for (const prop in obj) {
// 处理兼容性问题,对于clearInterval属性特殊处理
if (obj.hasOwnProperty(prop) || prop === 'clearInterval') {
callbackFn(prop);
}
}
}
/**
* 基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器
*/
export default class SnapshotSandbox implements SandBox {
proxy: WindowProxy;
name: string;
type: SandBoxType;
sandboxRunning = true;
private windowSnapshot!: Window;
private modifyPropsMap: Record<any, any> = {};
constructor(name: string) {
this.name = name;
this.proxy = window;
this.type = SandBoxType.Snapshot;
}
// 当微应用激活(初次进入该微应用)时执行
active() {
// 记录当前window的快照
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;
}
}
在这段代码中:
active
方法在微应用首次加载时被调用,它先创建一个空的windowSnapshot
对象,然后通过iter
函数遍历window
对象,将每个属性的值复制到windowSnapshot
中,完成当前window
状态的记录。接着,恢复之前离开微应用时记录的变更。inactive
方法在离开微应用时执行,它先清空modifyPropsMap
,然后再次遍历window
对象,对比当前属性值和windowSnapshot
中的值。如果有差异,就将当前值记录到modifyPropsMap
中,并把window
对象的属性值恢复为快照中的值。
需要注意的是,快照沙箱只是进行了一层浅拷贝对比。例如,对console
对象内部方法的更改可能不会被记录下来,这种情况可能会影响其他子应用。如果有更复杂的需求,开发者可能需要自行记录原本的变更。
三、ProxySandbox沙箱
(一)ProxySandbox沙箱的原理
ProxySandbox是针对支持proxy
的浏览器实现的沙箱机制。它的核心原理是利用JavaScript的Proxy
对象,创建一个代理对象来拦截对window
对象的访问,从而实现对微应用环境的隔离。
(二)ProxySandbox沙箱的简易实现示例
为了更好地理解,我们先来看一个简易版本的实现示例:
const proxy = {}
(function(window) {
// code 部分
console.log(window,window.console, console) // {}, undefined, console {debug: ƒ, error: ƒ, info: ƒ, log: ƒ, warn: ƒ, …}
}(proxy))
在这个示例中,我们创建了一个空对象proxy
,并将其作为参数传递给一个立即执行函数。在函数内部,打印window
、window.console
和console
。可以看到,window
是一个空对象,window.console
是undefined
,而console
是正常的console
对象。这表明,通过这种方式,我们可以在一定程度上模拟一个独立的window
环境。
(三)解决兼容性问题与实现真正的隔离
然而,上述示例还存在一些问题,比如如何兼容console
的API呢?这就用到了with
语句。with
语句可以扩展一个语句的作用域链,允许在代码块中直接使用对象的属性和方法,而无需重复引用对象。但需要注意的是,with
语句在严格模式下是被禁止使用的,因为它可能会使代码难以理解和维护。
在实际的ProxySandbox
实现中,我们可以这样使用with
语句:
const proxy = new Proxy(window, {})
(function(window) {
with (proxy) {
// code 部分
console.log(window,window.console, console) // {}, undefined, console {debug: ƒ, error: ƒ, info: ƒ, log: ƒ, warn: ƒ, …}
}
}(proxy))
这样,就解决了使用window
全局API并且走代理的问题。但这种简单的代理方式也会污染原本的window
对象(被浅拷贝了)。例如:
a = {name: 1, age: 2} {name:1, age:2}
b = new Proxy(a, {}) < Proxy (0bject) {name: 1, age: 2}
b.six =3 <3
a <{name:1, age: 2, six: 3}
可以看到,对代理对象b
添加属性,会影响到原对象a
。
(四)qiankun中ProxySandbox的具体实现
那么,乾坤中的ProxySandbox
是如何实现的呢?我们来看一下
createFakeWindow
API的代码:
const rawObjectDefineProperty = Object.defineProperty;
function createFakeWindow(globalContext: Window) {
const propertiesWithGetter = new Map<PropertyKey, boolean>();
const fakeWindow = {} as FakeWindow;
Object.getOwnPropertyNames(globalContext)
.filter((p) => {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
return!descriptor?.configurable;
})
.forEach((p) => {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
if (descriptor) {
const hasGetter = Object.prototype.hasOwnProperty.call(descriptor, 'get');
if (
p === 'top' ||
p === 'parent' ||
p ==='self' ||
p === 'window' ||
(process.env.NODE_ENV === 'test' && (p ==='mockTop' || p ==='mockSafariTop'))
) {
descriptor.configurable = true;
if (!hasGetter) {
descriptor.writable = true;
}
}
if (hasGetter) propertiesWithGetter.set(p, true);
rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
}
});
return {
fakeWindow,
propertiesWithGetter,
};
}
在这段代码中:
- 首先,通过
Object.getOwnPropertyNames
获取globalContext
(也就是window
对象)的所有属性名,并过滤掉那些可配置的属性。 - 然后,对剩下的属性获取其描述符,并根据属性名进行一些特殊处理,比如将
top
、parent
、self
、window
等属性设置为可配置的。 - 最后,使用
Object.defineProperty
将这些属性定义到fakeWindow
对象中,并冻结它们,防止意外修改。
通过这种方式,创建了一个相对独立的fakeWindow
对象,实现了一定程度的环境隔离。在实际应用中,如果在乾坤的一个子应用环境下更改了
window
原本全局的属性,在子应用中这个属性也会被更改。这是因为在
proxy
的get
操作中,如果fakeWindow
没有该API,还是会从全局的window
上获取。至于为什么没有对window
上的API进行深拷贝做绝对隔离,大概率是出于性能方面的考虑。
四、关于qiankun沙箱的常见问题解答
(一)import-html-entry中的子应用信息缓存会造成内存泄漏吗?
const styleCache = {};
const scriptCache = {};
const embedHTMLCache = {};
正常情况下,这种缓存不会造成内存泄漏。因为它是随着加载过的子应用数量增长的,并非持续无限制地增长。除非在特殊场景下,比如子应用数量成百上千,并且都在同一个tab页面执行过,才可能出现问题。在一般情况下,开发者可以放心使用。
(二)如果研发团队代码都很标准,是否可以不开启沙箱?
如果团队代码编写非常规范,且不在window
对象上放置全局方法,同时注意body
、html
、:root
等相关元素的使用,那么是可以不开启沙箱的。实际上,在正常项目中,也很少会在全局window
上放置东西,所以在满足上述条件时,不开启沙箱也是可行的。
通过对qiankun中JS沙箱的深入分析,我们了解了其原理、实现方式以及在实际应用中的一些注意事项。希望这些内容能帮助大家在使用qiankun进行微前端开发时,更好地理解和运用沙箱隔离机制。