深入讲解qiankun的JS沙箱隔离机制原理与实践
乾坤(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进行微前端开发时,更好地理解和运用沙箱隔离机制。