Skip to content

第4章 JS 沙箱机制深度剖析

"沙箱的本质不是隔离——是在不隔离的环境中制造隔离的幻觉。"

本章要点

  • 理解三代沙箱的演进脉络:从快照全量 diff 到 Proxy 单实例再到 Proxy 多实例
  • 深入 SnapshotSandbox 的实现原理:暴力遍历 window 属性的全量快照与恢复
  • 掌握 LegacySandbox 的 Proxy 拦截机制:用三个 Map 精准追踪变更
  • 剖析 ProxySandbox 的 fakeWindow 设计:createFakeWindow 如何构造隔离的全局对象
  • 认识沙箱的边界与逃逸:哪些东西是 JS 沙箱无论如何也隔离不了的
  • 手写实现三种沙箱核心逻辑,与乾坤源码逐行对照

在前面的章节中,我们了解了乾坤的整体架构和应用加载机制。但如果说应用加载是微前端的"骨架",那么 JS 沙箱就是它的"免疫系统"——没有沙箱,多个子应用运行在同一个页面中,就像多个陌生人共用一间没有隔板的办公室,全局变量的冲突、原型链的污染、事件监听器的残留……一切都会变成灾难。

这一章是全书技术密度最高的章节。我们将从乾坤源码出发,逐行拆解三代 JS 沙箱的设计与实现,理解每一代方案"为什么这样做",以及"代价是什么"。读完这一章,你不仅能看懂乾坤的沙箱源码,更能理解一个深刻的事实:在浏览器中实现完美的 JS 隔离,在理论上就是不可能的。 所有沙箱都是工程上的近似解——区别只在于近似到什么程度,以及付出了多少代价。

4.1 三代沙箱的演进

乾坤的 JS 沙箱经历了三代演进,每一代都是对上一代痛点的精准回应。在深入每一代的实现之前,我们先从宏观视角理解它们的关系。

4.1.1 为什么需要沙箱

当多个子应用运行在同一个页面中,它们共享同一个 window 对象。这意味着:

typescript
// 子应用 A 设置了一个全局变量
window.globalConfig = { theme: 'dark', language: 'zh-CN' };

// 子应用 B 也设置了同名的全局变量
window.globalConfig = { apiBaseUrl: 'https://api.example.com' };

// 子应用 A 再次读取时——灾难发生了
console.log(window.globalConfig.theme); // undefined!

更隐蔽的问题是原型链污染:

typescript
// 子应用 A 给 Array 原型加了一个方法
Array.prototype.last = function() {
  return this[this.length - 1];
};

// 子应用 B 遍历数组时
const arr = [1, 2, 3];
for (const key in arr) {
  console.log(key); // "0", "1", "2", "last" —— 多了一个不该有的属性
}

还有事件监听器的残留:

typescript
// 子应用 A 挂载时注册了 resize 监听
window.addEventListener('resize', handleResize);

// 子应用 A 卸载了,但 handleResize 还在!
// 当窗口大小变化时,handleResize 还会被调用
// 而此时子应用 A 的 DOM 已经被移除,handleResize 中的 DOM 操作会报错

沙箱的使命就是:让每个子应用以为自己独占了 window,但实际上它们的修改互不影响。

4.1.2 三代沙箱对比总览

特性SnapshotSandboxLegacySandboxProxySandbox
实现原理全量 diff windowProxy 拦截 + 记录变更Proxy + fakeWindow
多实例支持不支持不支持支持
性能差(遍历 window)好(精准拦截)
浏览器兼容IE 9+ES6 ProxyES6 Proxy
隔离粒度激活/失活时整体切换激活/失活时整体切换每个实例独立
对 window 的影响直接修改 window直接修改 window不修改 window
适用场景降级方案单实例过渡方案生产推荐方案

下图展示了三代沙箱的演进脉络与核心差异:

三代沙箱的演进轨迹非常清晰:

  1. SnapshotSandbox:不支持 Proxy 的环境下的降级方案,通过保存和恢复 window 快照实现隔离
  2. LegacySandbox:引入 Proxy,不再需要遍历 window,但仍然直接修改真实的 window 对象
  3. ProxySandbox:引入 fakeWindow,子应用的所有修改都写入 fakeWindow,真实 window 完全不受影响

每一步演进都在解决上一代的核心痛点:SnapshotSandbox 性能差 → LegacySandbox 用 Proxy 解决;LegacySandbox 不支持多实例 → ProxySandbox 用 fakeWindow 解决。

4.1.3 沙箱的生命周期

无论哪一代沙箱,都遵循相同的生命周期模型:

typescript
interface SandboxLifecycle {
  // 激活沙箱:子应用挂载前调用
  active(): void;

  // 失活沙箱:子应用卸载时调用
  inactive(): void;
}

// 在乾坤中的调用时机
async function mountApp(app: MicroApp) {
  // 1. 激活沙箱
  app.sandbox.active();

  // 2. 执行子应用的 JS 代码(在沙箱环境中)
  evalSubAppScripts(app.scripts, app.sandbox.proxy);

  // 3. 调用子应用的 mount 生命周期
  await app.mount(props);
}

async function unmountApp(app: MicroApp) {
  // 1. 调用子应用的 unmount 生命周期
  await app.unmount(props);

  // 2. 失活沙箱
  app.sandbox.inactive();
}

理解了这个生命周期模型,我们就有了分析每一代沙箱的基本框架。接下来让我们逐一深入。

4.2 快照沙箱:暴力但可靠的全量 diff

SnapshotSandbox 是乾坤最早期的沙箱实现,也是最容易理解的一种。它的思想极其朴素:在子应用激活前,把 window 的所有属性拍一张快照;在子应用失活时,把 window 恢复到快照状态。

4.2.1 核心思想

想象你和室友合租一间房间,但你们不能同时在房间里。你的使用时段是白天,室友是晚上。为了避免冲突,你们约定:

  1. 你进入房间前,拍一张照片记录房间的初始状态
  2. 你在房间里随意使用——挪桌子、换窗帘、贴海报
  3. 你离开时,对比当前状态和初始照片,把所有改动记录下来,然后恢复原样
  4. 下次你再进来时,根据之前的记录重新应用你的改动

这就是 SnapshotSandbox 的全部逻辑。

4.2.2 乾坤源码剖析

让我们看乾坤源码中 SnapshotSandbox 的实现:

typescript
// 来自 qiankun/src/sandbox/snapshotSandbox.ts(简化后)

type WindowSnapshot = Record<string, any>;

class SnapshotSandbox implements SandBox {
  name: string;
  type = SandBoxType.Snapshot;
  sandboxRunning = false;

  // 激活前的 window 快照
  private windowSnapshot!: WindowSnapshot;
  // 子应用运行期间对 window 做的修改
  private modifyPropsMap: Record<string, any> = {};
  proxy: WindowProxy;

  constructor(name: string) {
    this.name = name;
    this.proxy = window;
    // 注意:proxy 就是 window 本身!
    // 这意味着子应用直接操作的就是真实的 window
  }

  active() {
    // 1. 拍摄当前 window 的快照
    this.windowSnapshot = {} as WindowSnapshot;
    for (const prop in window) {
      if (window.hasOwnProperty(prop)) {
        this.windowSnapshot[prop] = (window as any)[prop];
      }
    }

    // 2. 如果之前有改动记录,恢复这些改动
    Object.keys(this.modifyPropsMap).forEach((prop) => {
      (window as any)[prop] = this.modifyPropsMap[prop];
    });

    this.sandboxRunning = true;
  }

  inactive() {
    // 1. 记录子应用对 window 做的所有修改
    this.modifyPropsMap = {};
    for (const prop in window) {
      if (window.hasOwnProperty(prop)) {
        if ((window as any)[prop] !== this.windowSnapshot[prop]) {
          // 记录变更
          this.modifyPropsMap[prop] = (window as any)[prop];
          // 恢复原值
          (window as any)[prop] = this.windowSnapshot[prop];
        }
      }
    }

    this.sandboxRunning = false;
  }
}

4.2.3 执行流程详解

让我们通过一个具体的时序来理解这段代码的工作方式:

typescript
// 假设初始 window 状态
// window.existingVar = 'original'

const sandbox = new SnapshotSandbox('app-A');

// ===== 第一次激活 =====
sandbox.active();
// windowSnapshot = { existingVar: 'original', ... }
// modifyPropsMap 为空,所以没有需要恢复的改动

// 子应用 A 运行期间
window.existingVar = 'modified by A';  // 修改已有属性
window.newVar = 'created by A';         // 新增属性

// ===== 第一次失活 =====
sandbox.inactive();
// 遍历 window,发现两处变化:
// modifyPropsMap = { existingVar: 'modified by A', newVar: 'created by A' }
// 恢复 window:
// window.existingVar = 'original'  (恢复)
// window.newVar = 'original'?      —— 注意!这里有个问题

// ===== 第二次激活 =====
sandbox.active();
// 重新拍快照
// 从 modifyPropsMap 恢复子应用 A 的改动:
// window.existingVar = 'modified by A'
// window.newVar = 'created by A'

下图展示了快照沙箱 activate/deactivate 的完整时序:

4.2.4 性能问题分析

SnapshotSandbox 的致命问题在于性能。window 对象上有多少属性?

typescript
// 在 Chrome 中测试
let count = 0;
for (const prop in window) {
  if (window.hasOwnProperty(prop)) {
    count++;
  }
}
console.log(count); // 通常 200-400+,取决于页面加载的脚本

// 每次 active() 和 inactive() 都需要遍历所有属性
// 如果子应用频繁切换(比如用户快速在多个 Tab 之间切换),
// 这个开销会变得非常明显

// 更糟糕的是:for...in 遍历的性能本身就不好
// 它需要遍历整个原型链,而 window 的原型链很深:
// window → Window.prototype → WindowProperties → EventTarget.prototype → Object.prototype

让我们量化这个开销:

typescript
// 性能测试
function benchmarkSnapshotSandbox() {
  const sandbox = new SnapshotSandbox('bench');

  // 模拟子应用添加一些属性
  sandbox.active();
  for (let i = 0; i < 100; i++) {
    (window as any)[`__test_prop_${i}`] = i;
  }

  const start = performance.now();
  for (let i = 0; i < 100; i++) {
    sandbox.inactive();
    sandbox.active();
  }
  const end = performance.now();

  console.log(`100 次切换耗时: ${end - start}ms`);
  // 典型结果:50-200ms(取决于 window 上的属性数量)
  // 对比:ProxySandbox 的切换几乎是 0ms
}

4.2.5 SnapshotSandbox 的局限性

除了性能问题,SnapshotSandbox 还有以下局限:

typescript
// 局限 1:不支持多实例
// 因为它直接修改 window,同一时刻只能有一个沙箱处于激活状态
const sandboxA = new SnapshotSandbox('A');
const sandboxB = new SnapshotSandbox('B');

sandboxA.active();
sandboxB.active(); // 如果 A 还没 inactive,B 的快照会包含 A 的修改!

// 局限 2:无法拦截属性的读取
// 快照沙箱只能在 active/inactive 时做 diff
// 在子应用运行期间,它无法知道子应用读取了哪些属性

// 局限 3:for...in 遍历遗漏不可枚举属性
// window 上有些属性是不可枚举的(如 window.NaN, window.undefined)
// for...in 无法遍历到它们
// 如果子应用修改了这些属性,SnapshotSandbox 无法检测到

// 局限 4:新增属性在恢复时不会被删除
// 假设子应用新增了 window.newProp
// inactive() 时虽然记录了 modifyPropsMap.newProp
// 但恢复逻辑是 window[prop] = snapshot[prop]
// 如果 snapshot 中没有 newProp,window.newProp 会被设置为 undefined
// 而不是被 delete 掉——这有微妙的区别:
console.log('newProp' in window); // true —— 属性还在!只是值是 undefined

深度洞察:SnapshotSandbox 存在的意义

看完这些局限性,你可能会想:既然 SnapshotSandbox 这么多问题,为什么乾坤还要保留它?答案是一个字:兼容。Proxy 是 ES6 的特性,不支持 polyfill——如果用户的浏览器不支持 Proxy(主要是 IE),SnapshotSandbox 是唯一的选择。这体现了一个重要的工程哲学:先保证能用,再追求好用。 降级方案的价值不在于它有多优雅,而在于它在极端条件下仍然能工作。

4.3 单实例代理沙箱:Proxy 的性能优化

下图展示了 LegacySandbox 中三个 Map 的分工协作机制,以及 Proxy 拦截写入时的分类决策逻辑:

LegacySandbox 是乾坤的第二代沙箱。它用 ES6 Proxy 替代了全量 diff,实现了精准的属性变更追踪。

4.3.1 设计动机

SnapshotSandbox 的根本问题在于:它用事后 diff 来检测变更。每次 active/inactive 都要遍历整个 window。而 LegacySandbox 换了一个思路:用 Proxy 实时拦截每一次写入,在写入的瞬间就记录变更。 这样,active/inactive 时只需要处理那些确实被修改过的属性,而不需要遍历整个 window。

4.3.2 三个关键的 Map

LegacySandbox 的精妙之处在于它用三个 Map 来追踪不同类型的变更:

typescript
// 来自 qiankun/src/sandbox/legacy/sandbox.ts(简化后)

class LegacySandbox implements SandBox {
  name: string;
  type = SandBoxType.LegacyProxy;
  sandboxRunning = false;
  proxy: WindowProxy;

  // Map 1:子应用新增的属性
  // 这些属性在子应用激活前不存在于 window 上
  private addedPropsMapInSandbox = new Map<PropertyKey, any>();

  // Map 2:子应用修改的属性的原始值
  // key 是属性名,value 是修改前的原始值
  private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>();

  // Map 3:子应用运行期间设置过的所有属性的当前值
  // 这是一个"全记录",包含新增和修改
  private currentUpdatedPropsValueMap = new Map<PropertyKey, any>();

  constructor(name: string) {
    this.name = name;

    const { addedPropsMapInSandbox, modifiedPropsOriginalValueMapInSandbox, currentUpdatedPropsValueMap } = this;
    const rawWindow = window;
    const fakeWindow = Object.create(null);

    this.proxy = new Proxy(fakeWindow, {
      set(_: Window, prop: PropertyKey, value: any) {
        if (!rawWindow.hasOwnProperty(prop)) {
          // 新增属性
          addedPropsMapInSandbox.set(prop, value);
        } else if (!modifiedPropsOriginalValueMapInSandbox.has(prop)) {
          // 修改已有属性(且是第一次修改)
          // 记录原始值,后续恢复时使用
          const originalValue = (rawWindow as any)[prop];
          modifiedPropsOriginalValueMapInSandbox.set(prop, originalValue);
        }

        // 无论新增还是修改,都记录当前值
        currentUpdatedPropsValueMap.set(prop, value);

        // 关键:仍然直接修改真实的 window!
        (rawWindow as any)[prop] = value;

        return true;
      },

      get(_: Window, prop: PropertyKey) {
        return (rawWindow as any)[prop];
      },
    });
  }

  active() {
    // 恢复子应用之前的修改
    this.currentUpdatedPropsValueMap.forEach((value, prop) => {
      (window as any)[prop] = value;
    });
    this.sandboxRunning = true;
  }

  inactive() {
    // 恢复修改过的属性的原始值
    this.modifiedPropsOriginalValueMapInSandbox.forEach((value, prop) => {
      (window as any)[prop] = value;
    });

    // 删除新增的属性
    this.addedPropsMapInSandbox.forEach((_value, prop) => {
      delete (window as any)[prop];
    });

    this.sandboxRunning = false;
  }
}

4.3.3 三个 Map 的分工

为什么需要三个 Map?让我们用一个例子理解它们各自的职责:

typescript
// 初始状态:window.existingVar = 'original'

const sandbox = new LegacySandbox('app-A');

// ===== 激活 =====
sandbox.active();

// 操作 1:修改已有属性
sandbox.proxy.existingVar = 'modified';
// addedPropsMapInSandbox: {} (不是新增)
// modifiedPropsOriginalValueMapInSandbox: { existingVar: 'original' }
// currentUpdatedPropsValueMap: { existingVar: 'modified' }
// window.existingVar = 'modified'  ← 真实 window 被修改了

// 操作 2:再次修改同一属性
sandbox.proxy.existingVar = 'modified again';
// addedPropsMapInSandbox: {} (不变)
// modifiedPropsOriginalValueMapInSandbox: { existingVar: 'original' } (不变!只记录第一次的原始值)
// currentUpdatedPropsValueMap: { existingVar: 'modified again' }
// window.existingVar = 'modified again'

// 操作 3:新增属性
sandbox.proxy.newVar = 'created';
// addedPropsMapInSandbox: { newVar: 'created' }
// modifiedPropsOriginalValueMapInSandbox: { existingVar: 'original' } (不变)
// currentUpdatedPropsValueMap: { existingVar: 'modified again', newVar: 'created' }
// window.newVar = 'created'

// ===== 失活 =====
sandbox.inactive();
// 1. 遍历 modifiedPropsOriginalValueMapInSandbox,恢复原始值:
//    window.existingVar = 'original'
// 2. 遍历 addedPropsMapInSandbox,删除新增属性:
//    delete window.newVar

// 此时 window 恢复到了初始状态!

// ===== 再次激活 =====
sandbox.active();
// 遍历 currentUpdatedPropsValueMap,恢复子应用的修改:
// window.existingVar = 'modified again'
// window.newVar = 'created'

基于 VitePress 构建