qiankun JS 沙箱隔离机制 —— Proxy 篇

1 阅读7分钟

一、整体架构

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

类型类名模式隔离强度说明
SandBoxType.ProxyProxySandboxstrict(默认)强隔离每个子应用一个独立的 fakeWindow,互不干扰
SandBoxType.LegacyProxyLegacySandboxloose(宽松)弱隔离直接操作真实 window,通过记录/恢复实现隔离

沙箱的选择逻辑(src/sandbox/index.ts):

if (window.Proxy) {
  sandbox = useLooseSandbox
    ? new LegacySandbox(appName, globalContext)
    : new ProxySandbox(appName, globalContext, elementGetter);
} else {
  sandbox = new SnapshotSandbox(appName);  // 降级方案,非 Proxy
}

二、ProxySandbox(严格模式,默认)

2.1 核心思想

每个子应用拥有一个独立的 fakeWindow 对象作为 Proxy 的 target,所有全局变量的读写都代理到这个 fakeWindow 上,从而实现多实例共存、互不污染。

2.2 fakeWindow 的创建

function createFakeWindow(globalContext: Window) {
  const propertiesWithGetter = new Map<PropertyKey, boolean>();
  const fakeWindow = {} as FakeWindow;

  // 将 globalContext 上所有 不可配置(non-configurable) 的属性复制到 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) {
        // 特殊处理: top/parent/self/window 改为 configurable
        // 这样 get trap 才能返回 proxy 自身而不违反 Proxy 不变量约束
        if (p === 'top' || p === 'parent' || p === 'self' || p === 'window') {
          descriptor.configurable = true;
          if (!hasGetter) descriptor.writable = true;
        }
        rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
      }
    });

  return { fakeWindow, propertiesWithGetter };
}

为什么要复制不可配置的属性?

根据 ECMAScript 规范,Proxy 的 getOwnPropertyDescriptor trap 不能将一个不存在于 target 上的属性报告为 non-configurable。因此必须把 window 上 non-configurable 的属性(如 windowtopNaNundefined 等)同步到 fakeWindow

2.3 Proxy Handler 详解

set trap — 写入拦截

proxy.xxx = value

执行逻辑:

沙箱运行中?
├── 是 →  目标属性在 globalContext 上存在但 fakeWindow 没有?
│         ├── 是 → 检查是否 writable/有 setter → 用 defineProperty 写入 fakeWindow
│         └── 否 → 直接写入 fakeWindow: target[p] = value
│
│         属性在白名单中?(System, __cjsWrapper 等)
│         ├── 是 → 同时写入真实 globalContext(并记录原始值以便恢复)
│         └── 否 → 不影响真实 window
│
│         记录到 updatedValueSet,更新 latestSetProp
│
└── 否 → 忽略写入,返回 true(避免 strict mode 抛 TypeError

关键点:写操作默认只影响 fakeWindow,不污染真实 window,这是多实例隔离的核心。

get trap — 读取拦截

proxy.xxx

执行逻辑:

p === Symbol.unscopables → 返回 unscopables(配合 with 语句)
p === 'window' / 'self' / 'globalThis' → 返回 proxy 自身(防逃逸)
p === 'top' / 'parent' → 非 iframe 环境返回 proxy,iframe 中返回真实值
p === 'hasOwnProperty' → 返回自定义实现(同时检查 fakeWindow 和 globalContext)
p === 'document' → 返回真实 document
p === 'eval' → 返回真实 eval

其他属性:
  propertiesWithGetter 中有 → 从 globalContext 读取
  fakeWindow 上有 → 从 fakeWindow 读取
  都没有 → 从 globalContext 读取

  对函数类型的值调用 getTargetValue 做绑定处理

防逃逸设计window.windowwindow.selfwindow.globalThis 都返回 proxy 本身,防止子应用通过这些属性拿到真实的 window 对象。

image.png

has trap — in 操作符拦截

has(target, p) {
  return p in unscopables || p in target || p in globalContext;
}

配合 with(proxy) { ... } 语句使用。unscopables 包含 ES2015 全局变量(ArrayObjectPromise 等),让这些变量能通过 in 检查,从而在 with 块中正确被访问。

getOwnPropertyDescriptor trap

属性在 fakeWindow 上 → 返回 fakeWindow 的描述符
属性在 globalContext 上 → 返回 globalContext 的描述符(强制 configurable: true)
都没有 → 返回 undefined

同时记录描述符来源到 descriptorTargetMap,供 defineProperty trap 使用。

defineProperty trap

defineProperty(target, p, attributes) {
  const from = descriptorTargetMap.get(p);
  switch (from) {
    case 'globalContext':
      return Reflect.defineProperty(globalContext, p, attributes);
    default:
      return Reflect.defineProperty(target, p, attributes);
  }
}

如果属性描述符来源于 globalContext(通过 getOwnPropertyDescriptor 查询过),则 defineProperty 也写回 globalContext,否则写到 fakeWindow

deleteProperty trap

只能删除 fakeWindow 上的属性,不影响真实 window

ownKeys trap

ownKeys(target) {
  return uniq(Reflect.ownKeys(globalContext).concat(Reflect.ownKeys(target)));
}

合并 globalContextfakeWindow 的 keys 并去重。

getPrototypeOf trap

getPrototypeOf() {
  return Reflect.getPrototypeOf(globalContext);
}

确保 proxy instanceof Window 返回 true

2.4 函数绑定处理 — getTargetValue

当从沙箱中获取到函数值时,需要特殊处理:

function getTargetValue(target: any, value: any): any {
  // 仅绑定: 可调用 && 非已绑定函数 && 非构造函数
  // 如 window.console、window.atob、window.fetch 这类原生方法
  if (isCallable(value) && !isBoundedFunction(value) && !isConstructable(value)) {
    const boundValue = Function.prototype.bind.call(value, target);
    // 复制枚举属性(如 moment 函数上的静态方法)
    // 复制 prototype
    // 修正 toString 输出
    return boundValue;
  }
  return value;
}

为什么需要 bind? 很多浏览器原生 API(如 fetchconsole.log)内部有 this 检查,如果 this 不是 window 会抛出 Illegal invocation。bind 到 target(根据情况是 globalContextnativeGlobal)保证调用正常。

2.5 unscopables 与 with 语句

qiankun 使用 import-html-entry 执行子应用脚本时,会将代码包裹在:

(function(window, self, globalThis) {
  with(window) {
    // 子应用代码
  }
}).call(proxy, proxy, proxy, proxy)

Symbol.unscopables 返回的对象告诉 with 语句哪些属性不应该从 proxy 上查找,而是直接走作用域链。这里把 ES 内置全局变量(ArrayObjectMath 等)放入 unscopables,避免在 proxy 上查找这些不可变的全局对象带来的性能开销。

2.6 白名单机制

const globalVariableWhiteList = ['System', '__cjsWrapper', ...variableWhiteListInDev];

白名单中的变量在 set 时同时写入真实 window,并在 inactive() 时恢复原始值。这是为了兼容 System.js 等工具的特殊需求。

2.7 currentRunningApp 注册机制

private registerRunningApp(name, proxy, elementGetter) {
  if (this.sandboxRunning) {
    setCurrentRunningApp({ name, window: proxy, elementGetter });
    nextTask(() => setCurrentRunningApp(null));  // 下一个微任务清除
  }
}

每次 get/set/delete 时注册当前运行的子应用,下一个微任务清除。这让 document.createElement 等被劫持的 DOM API 能知道当前操作来自哪个子应用,从而将动态创建的 script/style 归属到正确的沙箱。


三、LegacySandbox(宽松模式)

3.1 核心思想

直接在真实 window 上读写,但通过三个 Map 记录所有变更,在 inactive() 时恢复原始状态,active() 时重新应用变更。

3.2 三个核心 Map

// 沙箱期间新增的全局变量
private addedPropsMapInSandbox = new Map<PropertyKey, any>();

// 沙箱期间修改的全局变量的原始值
private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>();

// 持续记录所有更新(新增+修改)的全局变量的当前值
private currentUpdatedPropsValueMap = new Map<PropertyKey, any>();

3.3 set trap

const setTrap = (p, value, originalValue, sync2Window = true) => {
  if (this.sandboxRunning) {
    if (!rawWindow.hasOwnProperty(p)) {
      // 新增属性 → 记录到 addedPropsMapInSandbox
      addedPropsMapInSandbox.set(p, value);
    } else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
      // 修改已有属性(首次修改)→ 记录原始值
      modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
    }
    // 记录当前值
    currentUpdatedPropsValueMap.set(p, value);
    // 同步写入真实 window
    if (sync2Window) rawWindow[p] = value;
    return true;
  }
  return true;
};

3.4 get trap

get(_, p) {
  if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') {
    return proxy;  // 防逃逸
  }
  const value = rawWindow[p];
  return getTargetValue(rawWindow, value);  // 直接从真实 window 读取
}

3.5 生命周期

active(激活)

active() {
  if (!this.sandboxRunning) {
    // 重新将 currentUpdatedPropsValueMap 的值设置到 window
    this.currentUpdatedPropsValueMap.forEach((v, p) => this.setWindowProp(p, v));
  }
  this.sandboxRunning = true;
}

inactive(卸载)

inactive() {
  // 1. 恢复被修改的属性为原始值
  this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => this.setWindowProp(p, v));
  // 2. 删除新增的属性
  this.addedPropsMapInSandbox.forEach((_, p) => this.setWindowProp(p, undefined, true));
  this.sandboxRunning = false;
}

四、ProxySandbox vs LegacySandbox 对比

维度ProxySandbox (strict)LegacySandbox (loose)
全局变量存储独立的 fakeWindow真实 window
隔离方式读写都代理到 fakeWindow写入 window 并记录变更
多实例支持支持(每个实例独立 fakeWindow)不支持(共享 window,需要互斥)
激活/卸载成本低(只需切换 running 标志)高(需要遍历 Map 恢复/应用变更)
对子应用兼容性较好更好(直接操作 window,兼容性更强)
适用场景默认推荐,支持多子应用并存单实例场景,兼容老代码

五、沙箱生命周期与集成

5.1 创建流程

loadApp()
  → createSandboxContainer(appName, ...)
    → new ProxySandbox / LegacySandbox
    → patchAtBootstrapping()       // 启动期补丁(动态 script/style 拦截等)
    → 返回 { instance, mount, unmount }
  → global = sandboxContainer.instance.proxy  // 用 proxy 替代 windowexecScripts(global, strictGlobal)         // 在沙箱环境中执行子应用脚本

5.2 挂载流程

mount()
  → sandbox.active()                          // 激活沙箱
  → rebuild bootstrapping side effects        // 重建启动期副作用patchAtMounting()                         // 挂载期补丁patchInterval()                         // 劫持 setInterval/clearIntervalpatchWindowListener()                   // 劫持 addEventListener/removeEventListenerpatchHistoryListener()                  // 劫持 history.listen
    → 动态 append 补丁                         // 劫持 DOM 操作
  → rebuild mounting side effects             // 重建挂载期副作用

5.3 卸载流程

unmount()
  → 收集所有 freers 的 rebuilders            // 保存副作用重建函数供下次 mount 使用
  → sandbox.inactive()                        // 关闭沙箱
    → ProxySandbox: 恢复白名单变量,标记 sandboxRunning = false
    → LegacySandbox: 恢复所有修改,删除所有新增,标记 sandboxRunning = false

5.4 脚本执行

子应用的脚本通过 import-html-entryexecScripts 执行,核心原理是:

// strictGlobal 模式(ProxySandbox)
(function(window, self, globalThis) {
  with(window) {
    // 子应用代码
    // 所有未声明的变量访问都通过 with → proxy 的 has/get trap
    // 所有 window.xxx 的访问都通过 proxy 的 get trap
    // 所有 window.xxx = yyy 都通过 proxy 的 set trap
  }
}).call(proxy, proxy, proxy, proxy)

这样子应用代码中对全局变量的所有操作都被 Proxy 拦截,实现了完整的 JS 隔离。


六、关键设计决策总结

  1. fakeWindow 作为 Proxy target:满足 ES 规范对 Proxy 不变量的要求,同时提供独立的存储空间。

  2. 防逃逸window/self/globalThis/top/parent 都返回 proxy 自身,堵死子应用获取真实 window 的路径。

  3. unscopables + with:利用 Symbol.unscopables 让 ES 内置全局变量跳过 proxy 查找,提升性能;其余变量通过 with 语句走 proxy 的 has/get trap。

  4. 函数 bind:原生 API 必须绑定正确的 this,否则会抛 Illegal invocation

  5. 白名单同步:少量特殊变量(System__cjsWrapper)需要同步到真实 window,并在卸载时恢复。

  6. currentRunningApp 注册:通过微任务机制标记当前运行的子应用,让 DOM API 劫持能正确归属动态资源。