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 的完整时序:

基于 VitePress 构建