qiankun JS 沙箱隔离机制:Proxy 篇

有一个线上问题烦了我一阵子:A 子应用打开过一次以后,B 子应用里某个全局配置莫名其妙变了。刷新页面又好了,按菜单顺序点一遍又复现。最后一路查到 window 上,才发现两个老应用都在往全局挂同名字段——这才把 qiankun 沙箱的实现翻了个底朝天。

微前端最麻烦的地方就在这里。子应用通常默认自己运行在完整浏览器环境里,会读写 window、注册全局事件、创建定时器、动态插入 scriptstyle。如果这些副作用直接落到真实 window 上,应用之间就会互相污染,而且污染路径经常很隐蔽。

qiankun 的 JS 沙箱解决的不是“禁止子应用碰全局对象”,那基本不现实;它做的是加一层代理,让子应用“以为自己在操作 window”,实际读写被沙箱接管。下面主要看 Proxy 相关的两种沙箱:ProxySandboxLegacySandbox

整体架构

qiankun 提供了三类 JS 沙箱,其中 Proxy 相关的有两种:

类型类名模式隔离强度说明
SandBoxType.ProxyProxySandboxstrict,默认强隔离每个子应用一个独立的 fakeWindow,支持多实例共存
SandBoxType.LegacyProxyLegacySandboxloose,宽松弱隔离直接操作真实 window,通过记录和恢复实现隔离
非 Proxy 降级SnapshotSandboxsnapshot较弱不支持 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}

这里还会特殊处理 topparentselfwindow。这些属性如果不改成 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}

它会同时检查:

  • unscopables
  • fakeWindow
  • 真实 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 会合并真实 windowfakeWindow 的 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 流程。

但像 ArrayObjectPromiseMath 这类 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 strictLegacySandbox 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 访问进入 proxy
  • window.xxx = value 写入进入 proxy
  • 未声明变量在 with 中也会走 proxy 的 has/get
  • window/self/globalThis 都指向 proxy,减少逃逸路径

沙箱隔离并不是靠子应用自觉,而是通过执行环境把全局访问接管。

关键设计取舍

fakeWindowProxySandbox 的基础。它既提供独立存储空间,也满足 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 改变脚本执行时的全局查找路径,再配合函数绑定、白名单、生命周期补丁和动态资源归属,才凑成一套能落地的隔离体系。理解这些细节,排查时才知道问题是在全局变量、动态资源、事件副作用,还是老应用自己的全局假设上。