qiankun JS 沙箱隔离机制:Proxy 篇
有一个线上问题烦了我一阵子:A 子应用打开过一次以后,B 子应用里某个全局配置莫名其妙变了。刷新页面又好了,按菜单顺序点一遍又复现。最后一路查到 window 上,才发现两个老应用都在往全局挂同名字段——这才把 qiankun 沙箱的实现翻了个底朝天。
微前端最麻烦的地方就在这里。子应用通常默认自己运行在完整浏览器环境里,会读写 window、注册全局事件、创建定时器、动态插入 script 和 style。如果这些副作用直接落到真实 window 上,应用之间就会互相污染,而且污染路径经常很隐蔽。
qiankun 的 JS 沙箱解决的不是“禁止子应用碰全局对象”,那基本不现实;它做的是加一层代理,让子应用“以为自己在操作 window”,实际读写被沙箱接管。下面主要看 Proxy 相关的两种沙箱:ProxySandbox 和 LegacySandbox。
整体架构
qiankun 提供了三类 JS 沙箱,其中 Proxy 相关的有两种:
| 类型 | 类名 | 模式 | 隔离强度 | 说明 |
|---|---|---|---|---|
SandBoxType.Proxy | ProxySandbox | strict,默认 | 强隔离 | 每个子应用一个独立的 fakeWindow,支持多实例共存 |
SandBoxType.LegacyProxy | LegacySandbox | loose,宽松 | 弱隔离 | 直接操作真实 window,通过记录和恢复实现隔离 |
| 非 Proxy 降级 | SnapshotSandbox | snapshot | 较弱 | 不支持 Proxy 时使用快照恢复 |
沙箱选择逻辑可以简化成:
1if (window.Proxy) { 2 sandbox = useLooseSandbox 3 ? new LegacySandbox(appName, globalContext) 4 : new ProxySandbox(appName, globalContext, elementGetter) 5} else { 6 sandbox = new SnapshotSandbox(appName) 7}
默认推荐的是 ProxySandbox。它隔离更彻底,也是 qiankun 支持多子应用同时运行的基础。
ProxySandbox 的核心思想
ProxySandbox 做的事是:为每个子应用创建一个独立的 fakeWindow,再用 Proxy 包一层。
子应用代码访问的是 proxy:
1window.foo = 'bar' 2console.log(window.foo)
但写入默认不会直接污染真实 window,而是写到当前子应用自己的 fakeWindow 上。
这样每个子应用都有一份独立的全局变量存储空间。A 应用写了 window.appName = 'A',不会影响 B 应用。
fakeWindow 为什么存在
很多人第一次看 ProxySandbox 时会问:既然有 Proxy,为什么还要造一个 fakeWindow?
原因之一是 Proxy 有规范约束。对于不可配置的属性,Proxy 的 trap 不能随便撒谎。比如真实 window 上某些属性是 non-configurable,如果 proxy 的 target 上没有对应属性,却在 getOwnPropertyDescriptor 里把它报告成 non-configurable,就会违反 Proxy 不变量。
所以 qiankun 会把真实 window 上不可配置的属性复制到 fakeWindow:
1function createFakeWindow(globalContext: Window) { 2 const propertiesWithGetter = new Map<PropertyKey, boolean>() 3 const fakeWindow = {} as FakeWindow 4 5 Object.getOwnPropertyNames(globalContext) 6 .filter((p) => { 7 const descriptor = Object.getOwnPropertyDescriptor(globalContext, p) 8 return !descriptor?.configurable 9 }) 10 .forEach((p) => { 11 const descriptor = Object.getOwnPropertyDescriptor(globalContext, p) 12 13 if (descriptor) { 14 if (p === 'top' || p === 'parent' || p === 'self' || p === 'window') { 15 descriptor.configurable = true 16 descriptor.writable = true 17 } 18 19 Object.defineProperty(fakeWindow, p, Object.freeze(descriptor)) 20 } 21 }) 22 23 return { fakeWindow, propertiesWithGetter } 24}
这里还会特殊处理 top、parent、self、window。这些属性如果不改成 configurable,后面 get trap 返回 proxy 自身时,可能违反 Proxy 对不可配置属性的约束。
fakeWindow 既是独立存储空间,也是满足规范约束的 target。
set trap:写入默认只落到 fakeWindow
子应用执行:
1window.foo = 'bar'
会进入 set trap。
简化逻辑如下:
1沙箱正在运行? 2 是: 3 属性存在于真实 window,但 fakeWindow 没有? 4 是:检查 descriptor,必要时 defineProperty 到 fakeWindow 5 否:直接写入 fakeWindow 6 7 属性在白名单里? 8 是:同步写入真实 window,并记录原始值,卸载时恢复 9 否:不影响真实 window 10 11 记录 updatedValueSet 和 latestSetProp 12 13 否: 14 忽略写入,返回 true,避免 strict mode 抛错
关键点是:普通写操作默认只影响 fakeWindow。
这就是 ProxySandbox 支持多实例隔离的核心。如果每个子应用都有自己的 fakeWindow,它们写入的全局变量就不会互相覆盖。
get trap:读取时兼顾隔离和兼容
读取全局属性时,逻辑比写入更复杂。
常见特殊分支包括:
1Symbol.unscopables 2 -> 返回 unscopables,配合 with 使用 3 4window / self / globalThis 5 -> 返回 proxy 自身,防止逃逸到真实 window 6 7top / parent 8 -> 非 iframe 环境返回 proxy,iframe 中返回真实值 9 10hasOwnProperty 11 -> 返回自定义实现,同时检查 fakeWindow 和 globalContext 12 13document 14 -> 返回真实 document 15 16eval 17 -> 返回真实 eval
普通属性读取:
1propertiesWithGetter 中存在 2 -> 从 globalContext 读取 3 4fakeWindow 上存在 5 -> 从 fakeWindow 读取 6 7否则 8 -> 从 globalContext 读取 9 10如果结果是函数 11 -> 经过 getTargetValue 绑定 this
这里最重要的设计是防逃逸。
子应用如果执行:
1window.window 2window.self 3window.globalThis
拿到的仍然是 proxy,而不是真实 window。否则沙箱很容易被绕过。
has trap:服务于 with 语句
qiankun 执行子应用脚本时,会配合 with(window):
1with (window) { 2 // 子应用代码 3}
因此变量查找会触发 proxy 的 has trap。
简化实现:
1has(target, p) { 2 return p in unscopables || p in target || p in globalContext 3}
它会同时检查:
unscopablesfakeWindow- 真实
window
这样子应用里的未声明变量访问,也能被导向沙箱代理。
getOwnPropertyDescriptor 和 defineProperty
getOwnPropertyDescriptor 需要同时考虑 fakeWindow 和真实 window:
1属性在 fakeWindow 上 2 -> 返回 fakeWindow 的描述符 3 4属性在 globalContext 上 5 -> 返回 globalContext 的描述符,但强制 configurable: true 6 7都没有 8 -> 返回 undefined
qiankun 还会记录 descriptor 来源,供 defineProperty 使用。
1defineProperty(target, p, attributes) { 2 const from = descriptorTargetMap.get(p) 3 4 switch (from) { 5 case 'globalContext': 6 return Reflect.defineProperty(globalContext, p, attributes) 7 default: 8 return Reflect.defineProperty(target, p, attributes) 9 } 10}
如果某个属性描述符来自真实 window,后续 defineProperty 也写回真实 window。否则写入 fakeWindow。
这部分逻辑主要是为了兼容复杂的全局属性操作,同时尽量不破坏 Proxy 规范。
deleteProperty、ownKeys 和 getPrototypeOf
删除属性时,ProxySandbox 只允许删除 fakeWindow 上的属性,不影响真实 window:
1delete window.foo 2 -> 只删除 fakeWindow.foo
ownKeys 会合并真实 window 和 fakeWindow 的 key:
1ownKeys(target) { 2 return uniq( 3 Reflect.ownKeys(globalContext).concat(Reflect.ownKeys(target)) 4 ) 5}
getPrototypeOf 返回真实 window 的原型:
1getPrototypeOf() { 2 return Reflect.getPrototypeOf(globalContext) 3}
这样可以尽量保证:
1proxy instanceof Window
这类判断符合子应用预期。
函数绑定:避免 Illegal invocation
从 window 上读取函数时,qiankun 会做绑定处理。
原因是很多浏览器原生 API 对 this 有要求。如果直接把函数拿出来调用,可能报:
1Illegal invocation
例如:
1const rawFetch = window.fetch 2rawFetch('/api')
某些原生方法必须以正确的宿主对象作为 this。
qiankun 的 getTargetValue 会处理这类函数:
1function getTargetValue(target: any, value: any): any { 2 if ( 3 isCallable(value) && 4 !isBoundedFunction(value) && 5 !isConstructable(value) 6 ) { 7 const boundValue = Function.prototype.bind.call(value, target) 8 9 // 复制枚举属性 10 // 复制 prototype 11 // 修正 toString 12 return boundValue 13 } 14 15 return value 16}
它只绑定可调用、非已绑定、非构造函数的值。这样既保证原生 API 调用正常,也尽量避免破坏构造函数行为。
with 和 Symbol.unscopables
qiankun 通过 import-html-entry 执行子应用脚本时,大致会包成这样:
1(function(window, self, globalThis) { 2 with (window) { 3 // 子应用代码 4 } 5}).call(proxy, proxy, proxy, proxy)
这使得子应用中的全局变量访问都优先进入 proxy 的 has/get/set 流程。
但像 Array、Object、Promise、Math 这类 ES 内置全局对象,没有必要每次都绕到 proxy 上查一遍。qiankun 会通过 Symbol.unscopables 标记一批对象,让 with 查找时跳过它们,直接走外层作用域。
这既减少性能开销,也避免对一些稳定内置对象做无意义代理。
白名单变量
严格隔离并不代表所有变量都只能写到 fakeWindow。
有些工具确实需要写到真实 window,比如:
1const globalVariableWhiteList = [ 2 'System', 3 '__cjsWrapper', 4 // dev 环境下的额外变量 5]
这些白名单变量在 set 时会同步到真实 window,并记录原始值。在沙箱 inactive() 时,再恢复原状。
这是隔离性和兼容性之间的折中。
currentRunningApp:让动态资源归属正确
qiankun 还会在沙箱访问时注册当前运行的子应用:
1private registerRunningApp(name, proxy, elementGetter) { 2 if (this.sandboxRunning) { 3 setCurrentRunningApp({ name, window: proxy, elementGetter }) 4 nextTask(() => setCurrentRunningApp(null)) 5 } 6}
这个标记会在下一个任务中清除。
它的作用是配合 DOM API 劫持。比如子应用动态创建:
1document.createElement('script') 2document.createElement('style')
qiankun 需要知道这个操作来自哪个子应用,才能把动态资源归属到正确沙箱和容器中。
LegacySandbox:宽松模式
LegacySandbox 的思路完全不同。
它不使用独立 fakeWindow 存储普通全局变量,而是直接读写真实 window,同时记录变更。在卸载时恢复原状态。
它维护三个核心 Map:
1// 沙箱期间新增的全局变量 2private addedPropsMapInSandbox = new Map<PropertyKey, any>() 3 4// 沙箱期间修改的全局变量原始值 5private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>() 6 7// 持续记录新增和修改后的当前值 8private currentUpdatedPropsValueMap = new Map<PropertyKey, any>()
LegacySandbox 的 set trap
写入时,LegacySandbox 会直接影响真实 window:
1const setTrap = (p, value, originalValue, sync2Window = true) => { 2 if (this.sandboxRunning) { 3 if (!rawWindow.hasOwnProperty(p)) { 4 addedPropsMapInSandbox.set(p, value) 5 } else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) { 6 modifiedPropsOriginalValueMapInSandbox.set(p, originalValue) 7 } 8 9 currentUpdatedPropsValueMap.set(p, value) 10 11 if (sync2Window) { 12 rawWindow[p] = value 13 } 14 15 return true 16 } 17 18 return true 19}
它会区分:
- 新增属性
- 修改已有属性
- 当前最新值
这些记录用于后续卸载和重新激活。
LegacySandbox 的 get trap
读取逻辑比较直接:
1get(_, p) { 2 if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') { 3 return proxy 4 } 5 6 const value = rawWindow[p] 7 return getTargetValue(rawWindow, value) 8}
它仍然会处理 window/self/top/parent,避免子应用轻易逃逸到真实 window。
但普通属性读取来自真实 window,隔离强度明显弱于 ProxySandbox。
LegacySandbox 的生命周期
激活时,把之前记录的当前值重新应用到真实 window:
1active() { 2 if (!this.sandboxRunning) { 3 this.currentUpdatedPropsValueMap.forEach((value, prop) => { 4 this.setWindowProp(prop, value) 5 }) 6 } 7 8 this.sandboxRunning = true 9}
卸载时,恢复修改过的属性,删除新增属性:
1inactive() { 2 this.modifiedPropsOriginalValueMapInSandbox.forEach((value, prop) => { 3 this.setWindowProp(prop, value) 4 }) 5 6 this.addedPropsMapInSandbox.forEach((_, prop) => { 7 this.setWindowProp(prop, undefined, true) 8 }) 9 10 this.sandboxRunning = false 11}
这种模式兼容性更强,但不适合多实例同时运行。因为多个子应用共享同一个真实 window,只能通过激活和卸载时的恢复来维持相对隔离。
ProxySandbox 和 LegacySandbox 对比
| 维度 | ProxySandbox strict | LegacySandbox loose |
|---|---|---|
| 全局变量存储 | 独立 fakeWindow | 真实 window |
| 隔离方式 | 读写代理到 fakeWindow | 写入真实 window,记录后恢复 |
| 多实例支持 | 支持 | 不支持,适合单实例 |
| 激活/卸载成本 | 低,主要切换 running 标志 | 高,需要遍历 Map 恢复和重放 |
| 兼容性 | 较好 | 更强 |
| 推荐场景 | 默认推荐,多子应用并存 | 老代码兼容、单实例场景 |
如果子应用质量可控,并且需要多实例共存,ProxySandbox 更合适。
如果老应用对真实 window 依赖很重,严格沙箱跑不起来,LegacySandbox 可以作为兼容方案。
沙箱和生命周期补丁
沙箱不是独立工作的,它还要和 qiankun 的生命周期补丁配合。
创建流程大致是:
1loadApp() 2 -> createSandboxContainer(appName, ...) 3 -> new ProxySandbox / LegacySandbox 4 -> patchAtBootstrapping() 5 -> 返回 { instance, mount, unmount } 6 -> global = sandboxContainer.instance.proxy 7 -> execScripts(global, strictGlobal)
挂载时:
1mount() 2 -> sandbox.active() 3 -> rebuild bootstrapping side effects 4 -> patchAtMounting() 5 -> patchInterval() 6 -> patchWindowListener() 7 -> patchHistoryListener() 8 -> 动态 append 补丁 9 -> rebuild mounting side effects
卸载时:
1unmount() 2 -> 收集 freers 的 rebuilders 3 -> sandbox.inactive() 4 -> ProxySandbox: 恢复白名单变量,标记 sandboxRunning = false 5 -> LegacySandbox: 恢复修改,删除新增,标记 sandboxRunning = false
也就是说,JS 沙箱解决的是全局变量隔离;定时器、事件监听、history、动态样式脚本这些副作用,还需要额外 patch 和清理。
脚本执行如何进入沙箱
子应用脚本会通过 execScripts 执行,并把 proxy 作为 window/self/globalThis 传进去:
1(function(window, self, globalThis) { 2 with (window) { 3 // 子应用代码 4 } 5}).call(proxy, proxy, proxy, proxy)
这段设计很关键:
window.xxx访问进入 proxywindow.xxx = value写入进入 proxy- 未声明变量在
with中也会走 proxy 的has/get window/self/globalThis都指向 proxy,减少逃逸路径
沙箱隔离并不是靠子应用自觉,而是通过执行环境把全局访问接管。
关键设计取舍
fakeWindow 是 ProxySandbox 的基础。它既提供独立存储空间,也满足 Proxy 对不可配置属性的规范要求。
防逃逸设计让 window/self/globalThis/top/parent 尽量返回 proxy 自身,避免子应用直接拿到真实 window。
with + Symbol.unscopables 让子应用未声明变量也进入代理,同时让 ES 内置全局对象跳过不必要的 proxy 查找。
函数绑定解决了原生 API 的 Illegal invocation 问题,这是浏览器宿主对象里很常见的坑。
白名单变量是隔离和兼容之间的折中。少数工具需要写真实 window,qiankun 允许它们同步,但会在卸载时恢复。
currentRunningApp 让 DOM API 劫持能知道当前操作来自哪个子应用,从而正确处理动态创建的资源。
后来再排微前端问题,我不会一上来就怀疑 qiankun 配置错了,而是先问一句:这个副作用到底落在哪里了?
如果是普通全局变量污染,ProxySandbox 大概率能兜住;如果是动态插入的样式、脚本、定时器、事件监听,就要继续看生命周期补丁有没有接住;如果是老应用强依赖真实 window,那就可能落到 LegacySandbox 或白名单这类兼容取舍上。
qiankun 的 Proxy 沙箱不是单纯包一层 new Proxy(window)。严格模式下,它用 fakeWindow 承接子应用写入,用 proxy 接管读写,用 with 改变脚本执行时的全局查找路径,再配合函数绑定、白名单、生命周期补丁和动态资源归属,才凑成一套能落地的隔离体系。理解这些细节,排查时才知道问题是在全局变量、动态资源、事件副作用,还是老应用自己的全局假设上。