微前端源码精讲
第4章 JS 沙箱机制深度剖析
第4章 JS 沙箱机制深度剖析
“沙箱的本质不是隔离——是在不隔离的环境中制造隔离的幻觉。”
本章要点
- 理解三代沙箱的演进脉络:从快照全量 diff 到 Proxy 单实例再到 Proxy 多实例
- 深入 SnapshotSandbox 的实现原理:暴力遍历 window 属性的全量快照与恢复
- 掌握 LegacySandbox 的 Proxy 拦截机制:用三个 Map 精准追踪变更
- 剖析 ProxySandbox 的 fakeWindow 设计:createFakeWindow 如何构造隔离的全局对象
- 认识沙箱的边界与逃逸:哪些东西是 JS 沙箱无论如何也隔离不了的
- 手写实现三种沙箱核心逻辑,与乾坤源码逐行对照
在前面的章节中,我们了解了乾坤的整体架构和应用加载机制。但如果说应用加载是微前端的”骨架”,那么 JS 沙箱就是它的”免疫系统”——没有沙箱,多个子应用运行在同一个页面中,就像多个陌生人共用一间没有隔板的办公室,全局变量的冲突、原型链的污染、事件监听器的残留……一切都会变成灾难。
这一章是全书技术密度最高的章节。我们将从乾坤源码出发,逐行拆解三代 JS 沙箱的设计与实现,理解每一代方案”为什么这样做”,以及”代价是什么”。读完这一章,你不仅能看懂乾坤的沙箱源码,更能理解一个深刻的事实:在浏览器中实现完美的 JS 隔离,在理论上就是不可能的。 所有沙箱都是工程上的近似解——区别只在于近似到什么程度,以及付出了多少代价。
4.1 三代沙箱的演进
乾坤的 JS 沙箱经历了三代演进,每一代都是对上一代痛点的精准回应。在深入每一代的实现之前,我们先从宏观视角理解它们的关系。
在软件工程领域、“沙箱”这个词的历史几乎和计算机本身一样古老——从 1970 年代 UNIX 的 chroot 到 2000 年代的 Java Applet 沙箱、从浏览器的 Same-Origin Policy 到 Docker 的 namespace 隔离、从 WebAssembly 的 capability 模型到 iOS 的 app sandbox——每一代沙箱技术都是在回答同一个永恒的问题:如何让”不完全信任的代码”在”受控的环境里”运行、既不伤害宿主、又能完成正事?JavaScript 沙箱是这个漫长故事中一个相对年轻的章节——它的独特性在于、JavaScript 作为一种最初为”浏览器内嵌脚本”设计的语言、天生就没有内置的隔离机制。所有 JavaScript 沙箱方案、本质上都是在”没有隔离原语”的土壤里、用语法特性、运行时拦截、执行上下文操纵这些”间接手段”、硬生生造出隔离能力。这也是为什么 JavaScript 沙箱比其他语言的沙箱更复杂、更容易出 bug——因为它是在和语言本身的设计意图对抗。
本章我们要讲的三代沙箱、其实可以看作是”在不同约束下的三次突围尝试”——SnapshotSandbox 的思路最朴素:“既然没有隔离原语、那我就在子应用开始前保存 window 的完整状态、结束后恢复”——这是蛮力法、可靠但笨重;LegacySandbox 的思路更聪明:“我用 Proxy 实时记录子应用修改了什么、结束时只回滚这些修改”——这是精准法、性能提升但无法支持多例;ProxySandbox 的思路最巧妙:“我给每个子应用一个伪造的 window、让它们以为在修改真 window、实际修改的是伪造的、读取时再智能地从真 window 回退”——这是虚拟化法、性能最优且支持多例。三代演进、刚好是”蛮力 → 精准 → 虚拟化”这条经典的软件优化路径——在数据库索引、操作系统分页、CDN 缓存等无数领域、我们都见过同样的演化脉络。
4.1.1 为什么需要沙箱
在深入技术之前、让我们先理解”为什么沙箱是一个永恒的工程问题”。在有多个独立程序运行的任何系统里、都会遇到三种污染问题:(1) 变量命名污染——两段代码意外使用同名变量、后者覆盖前者;(2) 资源泄漏污染——某段代码注册的定时器、监听器、连接、即使代码退出、这些资源仍在活跃;(3) 原型/全局类型污染——某段代码修改了基础类型的行为(如 Array.prototype)、所有后续代码都会被波及。这三种污染、在单体程序里因为”单一作者、统一约束”而很少出现、但在多方协作的微前端里、几乎每周都会遇到真实案例。所以沙箱的存在、本质上是为”多方协作 + 共享运行时”这种特殊场景、提供一层”假装独立”的幻象——让每个子应用都感觉自己在独占浏览器、实际上它们被框架透明地隔离着。
当多个子应用运行在同一个页面中,它们共享同一个 window 对象。这意味着:
// 子应用 A 设置了一个全局变量
window.globalConfig = { theme: 'dark', language: 'zh-CN' };
// 子应用 B 也设置了同名的全局变量
window.globalConfig = { apiBaseUrl: 'https://api.example.com' };
// 子应用 A 再次读取时——灾难发生了
console.log(window.globalConfig.theme); // undefined!
更隐蔽的问题是原型链污染:
// 子应用 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" —— 多了一个不该有的属性
}
还有事件监听器的残留:
// 子应用 A 挂载时注册了 resize 监听
window.addEventListener('resize', handleResize);
// 子应用 A 卸载了,但 handleResize 还在!
// 当窗口大小变化时,handleResize 还会被调用
// 而此时子应用 A 的 DOM 已经被移除,handleResize 中的 DOM 操作会报错
沙箱的使命就是:让每个子应用以为自己独占了 window,但实际上它们的修改互不影响。
4.1.2 三代沙箱对比总览
| 特性 | SnapshotSandbox | LegacySandbox | ProxySandbox |
|---|---|---|---|
| 实现原理 | 全量 diff window | Proxy 拦截 + 记录变更 | Proxy + fakeWindow |
| 多实例支持 | 不支持 | 不支持 | 支持 |
| 性能 | 差(遍历 window) | 好(精准拦截) | 好 |
| 浏览器兼容 | IE 9+ | ES6 Proxy | ES6 Proxy |
| 隔离粒度 | 激活/失活时整体切换 | 激活/失活时整体切换 | 每个实例独立 |
| 对 window 的影响 | 直接修改 window | 直接修改 window | 不修改 window |
| 适用场景 | 降级方案 | 单实例过渡方案 | 生产推荐方案 |
下图展示了三代沙箱的演进脉络与核心差异:
flowchart LR
subgraph Gen1["第一代: SnapshotSandbox"]
S1["全量遍历window"] --> S2["事后diff检测变更"]
S2 --> S3["直接修改window"]
end
subgraph Gen2["第二代: LegacySandbox"]
L1["Proxy拦截写入"] --> L2["3个Map实时追踪"]
L2 --> L3["仍直接修改window"]
end
subgraph Gen3["第三代: ProxySandbox"]
P1["Proxy拦截读写"] --> P2["fakeWindow隔离"]
P2 --> P3["真实window不受影响"]
end
Gen1 -->|"性能差O(N)"| Gen2
Gen2 -->|"不支持多实例"| Gen3
style Gen1 fill:#ffebee,stroke:#c62828
style Gen2 fill:#fff3e0,stroke:#e65100
style Gen3 fill:#e8f5e9,stroke:#2e7d32
三代沙箱的演进轨迹非常清晰:
- SnapshotSandbox:不支持 Proxy 的环境下的降级方案,通过保存和恢复 window 快照实现隔离
- LegacySandbox:引入 Proxy,不再需要遍历 window,但仍然直接修改真实的 window 对象
- ProxySandbox:引入 fakeWindow,子应用的所有修改都写入 fakeWindow,真实 window 完全不受影响
每一步演进都在解决上一代的核心痛点:SnapshotSandbox 性能差 → LegacySandbox 用 Proxy 解决;LegacySandbox 不支持多实例 → ProxySandbox 用 fakeWindow 解决。
4.1.3 沙箱的生命周期
无论哪一代沙箱,都遵循相同的生命周期模型:
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 恢复到快照状态。
“快照”这个概念、在计算机科学里应用极广——文件系统有 copy-on-write 快照、数据库有 point-in-time recovery 快照、虚拟机有 VM snapshot、Git 有 commit 快照、React 有 state snapshot、Redux 有 time-travel debugging。它的核心价值在于提供一个”可回滚的参照点”——当你对一个系统做出不确定是否正确的改动时、快照让你有”反悔”的能力。SnapshotSandbox 就是把”快照”这个通用概念、应用到了 JavaScript 的 window 对象上——子应用每次激活就是”基于快照开始修改”、每次失活就是”回滚到快照并记录修改”。这种思路的优点是”概念简单、不会漏网”——只要你能正确地保存和对比 window 的全部状态、那任何修改都不可能逃出沙箱的感知。缺点也显而易见——保存和对比全部状态的成本、随 window 属性数量线性增长、在现代页面里(动辄数百个全局属性)这个成本不可忽视。
4.2.1 核心思想
想象你和室友合租一间房间,但你们不能同时在房间里。你的使用时段是白天,室友是晚上。为了避免冲突,你们约定:
- 你进入房间前,拍一张照片记录房间的初始状态
- 你在房间里随意使用——挪桌子、换窗帘、贴海报
- 你离开时,对比当前状态和初始照片,把所有改动记录下来,然后恢复原样
- 下次你再进来时,根据之前的记录重新应用你的改动
这就是 SnapshotSandbox 的全部逻辑。
4.2.2 乾坤源码剖析
让我们看乾坤源码中 SnapshotSandbox 的实现:
// 来自 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 执行流程详解
让我们通过一个具体的时序来理解这段代码的工作方式:
// 假设初始 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 的完整时序:
sequenceDiagram
participant App as 子应用A
participant SB as SnapshotSandbox
participant Win as window对象
Note over SB: === 第一次激活 ===
SB->>Win: 遍历所有属性,拍摄快照
SB->>SB: windowSnapshot = {...window}
SB->>Win: 恢复modifyPropsMap中的修改(首次为空)
SB->>SB: sandboxRunning = true
App->>Win: window.config = 'A'
App->>Win: window.newVar = 'hello'
Note over SB: === 第一次失活 ===
SB->>Win: 遍历所有属性,与快照对比
SB->>SB: 记录差异到modifyPropsMap
SB->>Win: 恢复每个属性为快照值
SB->>SB: sandboxRunning = false
Note over SB: === 第二次激活 ===
SB->>Win: 重新拍摄快照
SB->>Win: 从modifyPropsMap恢复子应用修改
Note over Win: window.config = 'A' 恢复
4.2.4 性能问题分析
SnapshotSandbox 的致命问题在于性能。window 对象上有多少属性?
// 在 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
让我们量化这个开销:
// 性能测试
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 的局限性不是”它是个失败的设计”、而是”它在某些具体场景下不再是最优解”——我们接下来要逐一分析这些局限、目的不是否定这个方案、而是帮助你判断”在什么条件下它不再适用”。
除了性能问题,SnapshotSandbox 还有以下局限:
// 局限 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 的性能优化
ES6 的 Proxy 是 JavaScript 语言有史以来引入的最强大的元编程特性之一——它让开发者能在语言层面做之前只有 C++ 或虚拟机层面才能做的”对象行为拦截”。Proxy 出现之前、JavaScript 的对象拦截能力非常有限:Object.defineProperty 只能对已有属性定义 getter/setter、对”新增属性”是完全感知不到的;try/catch 只能捕获运行时异常、对属性访问无能为力。Proxy 彻底改变了这一切——它让一个对象的 任何访问(get、set、has、delete、keys、…)都能被 JavaScript 代码拦截和改写。这个能力看似普通、实际上打开了一扇全新的大门——它让 Vue 3 的响应式系统、Mobx 的观察者模式、乾坤的 JS 沙箱、Immer 的不可变数据结构、Prisma 的类型安全查询构造器、都成为可能。可以说、没有 Proxy 就没有现代前端的很多基础设施。
LegacySandbox 就是乾坤对”Proxy 拦截式追踪”这个新范式的第一次实践。它的核心思路——只记录真正发生修改的属性、在子应用失活时只回滚这些修改——是对 SnapshotSandbox 的”全量对比”模式的一次精准打击。性能从 O(N) 降到 O(M) 的本质、不是”算法更聪明”、而是”信息获取方式更精准”——SnapshotSandbox 需要”事后对比”才能知道发生了什么修改、LegacySandbox 则在修改发生的瞬间就能”实时捕获”。这种从”轮询式感知”到”事件式感知”的转变、是计算机系统设计里最经典的性能优化手段之一——它在 Linux 的 inotify、Redis 的 pub/sub、React 的 fiber diff、Git 的 packfiles 里都有类似体现。
下图展示了 LegacySandbox 中三个 Map 的分工协作机制,以及 Proxy 拦截写入时的分类决策逻辑:
flowchart TB
Write["Proxy set 拦截\n子应用写入 window.xxx = value"] --> Check{"window.hasOwnProperty(prop)?"}
Check -->|"否: 新增属性"| Added["addedPropsMapInSandbox\n记录新增属性"]
Check -->|"是: 修改已有属性"| FirstModify{"首次修改该属性?"}
FirstModify -->|"是"| Modified["modifiedPropsOriginalValueMapInSandbox\n记录原始值(仅首次)"]
FirstModify -->|"否"| Skip["已有原始值记录,跳过"]
Added --> Current["currentUpdatedPropsValueMap\n记录当前值(所有变更)"]
Modified --> Current
Skip --> Current
Current --> RealWindow["直接写入真实 window\n(LegacySandbox 仍修改 window!)"]
subgraph Restore["inactive() 恢复"]
R1["modified Map\n恢复每个属性的原始值"]
R2["added Map\ndelete 新增的属性"]
end
style Write fill:#e3f2fd,stroke:#1565c0
style Added fill:#e8f5e9,stroke:#2e7d32
style Modified fill:#fff3e0,stroke:#e65100
style RealWindow fill:#ffebee,stroke:#c62828
LegacySandbox 是乾坤的第二代沙箱。它用 ES6 Proxy 替代了全量 diff,实现了精准的属性变更追踪。
4.3.1 设计动机
SnapshotSandbox 的根本问题在于:它用事后 diff 来检测变更。每次 active/inactive 都要遍历整个 window。而 LegacySandbox 换了一个思路:用 Proxy 实时拦截每一次写入,在写入的瞬间就记录变更。 这样,active/inactive 时只需要处理那些确实被修改过的属性,而不需要遍历整个 window。
4.3.2 三个关键的 Map
LegacySandbox 的精妙之处在于它用三个 Map 来追踪不同类型的变更:
// 来自 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?让我们用一个例子理解它们各自的职责:
// 初始状态: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'
关键设计点:
modifiedPropsOriginalValueMapInSandbox只记录第一次修改前的原始值,所以恢复时能精准还原addedPropsMapInSandbox记录新增属性,恢复时用delete而不是设为undefinedcurrentUpdatedPropsValueMap是”全记录”,用于再次激活时恢复子应用的所有变更
4.3.4 性能对比
LegacySandbox 相比 SnapshotSandbox 的性能优势是巨大的:
// SnapshotSandbox 的 active/inactive 复杂度
// 时间复杂度:O(N),N = window 上的属性数量(200-400+)
// 每次切换都遍历整个 window
// LegacySandbox 的 active/inactive 复杂度
// 时间复杂度:O(M),M = 子应用实际修改的属性数量(通常 10-50)
// 只处理确实被修改过的属性
// 量化对比
function benchmark() {
// 假设 window 上有 300 个属性
// 子应用修改了 20 个属性
// SnapshotSandbox: 每次切换遍历 300 个属性
// LegacySandbox: 每次切换只处理 20 个属性
// 性能提升:15 倍
// 当 window 属性更多时(大型应用中可能有 1000+),优势更明显
}
4.3.5 LegacySandbox 的致命局限
尽管性能大幅提升,LegacySandbox 仍然有一个根本性的问题:它直接修改了真实的 window 对象。
// 在 set trap 中:
(rawWindow as any)[prop] = value;
// 这一行直接修改了 window!
// 这意味着:同一时刻只能有一个沙箱处于激活状态
// 如果两个子应用同时运行:
const sandboxA = new LegacySandbox('A');
const sandboxB = new LegacySandbox('B');
sandboxA.active();
sandboxB.active();
sandboxA.proxy.config = 'A'; // window.config = 'A'
sandboxB.proxy.config = 'B'; // window.config = 'B'
// sandboxA 读取时会得到 'B' —— 隔离失败!
console.log(sandboxA.proxy.config); // 'B' !!!
这就是为什么它叫 “Legacy”——它是一个过渡方案。真正解决多实例问题的是 ProxySandbox。
深度洞察:为什么 LegacySandbox 选择修改真实 window
你可能会问:既然已经用了 Proxy,为什么不把值存在 fakeWindow 里,而要修改真实 window?原因在于兼容性。很多第三方库(如 jQuery、Lodash)会直接通过
window.xxx读取全局变量,而不经过 Proxy。如果子应用的变量只存在 fakeWindow 中,这些库就读不到。LegacySandbox 选择修改真实 window,正是为了保证这些”不走 Proxy”的读取方式也能正常工作。这是一个兼容性优先的设计决策——用多实例能力换兼容性。
4.4 多实例代理沙箱:fakeWindow 的精妙设计
ProxySandbox 是乾坤目前生产环境中推荐使用的沙箱方案。它解决了前两代沙箱最根本的问题:多实例支持。通过 fakeWindow 机制,每个子应用拥有自己独立的”虚拟 window”,彼此之间完全隔离,真实 window 不受任何影响。
“fakeWindow”这个名字朴素、但它代表的思想是”虚拟化”在前端领域的一次精彩应用。“虚拟化”在计算机历史上有着辉煌的谱系——1960 年代 IBM System/370 的虚拟机、让多个操作系统共享同一台物理机;1990 年代 VMware 的 x86 虚拟化、让云计算成为可能;2010 年代的 Docker 容器、让虚拟化从完整机器下沉到进程级别;2020 年代的 WebAssembly 和 eBPF、让虚拟化渗透到内核和语言运行时的每一层。ProxySandbox 的 fakeWindow、本质上是把”虚拟化”这个思想、带到了 JavaScript 运行时的最上层——浏览器的 window 对象。每个子应用都运行在自己的”虚拟 window”里、就像每台 VM 运行在自己的”虚拟 CPU”里一样——物理资源(真 window)是共享的、但每个实例都觉得自己独占了资源。这种”多个虚拟实例共享一个物理资源”的模式、是软件工程里最通用、最有生命力的一种模式——从 V8 的 Isolate 到 Go 的 goroutine、从 Erlang 的 Process 到 Rust 的 async task、都是这种思想的不同外化。
理解 ProxySandbox 最巧妙的一点、是它的”读时降级”策略。写入操作被 Proxy 拦截、重定向到 fakeWindow(实现隔离);读取操作则”先查 fakeWindow、查不到再查真 window”(实现对真实环境的兼容)。这种”写时隔离、读时融入”的非对称设计、解决了”严格隔离”和”浏览器 API 可用”的矛盾——子应用既能修改自己的”虚拟世界”而不影响别人、又能像正常 JS 代码一样访问 document、setTimeout 这些全局 API。这种”根据操作方向做非对称处理”的设计模式、在数据库的 CDC(Change Data Capture)、区块链的轻节点、分布式缓存的读写分离里都能看到类似影子——好的架构师会根据”操作的性质”而不是”统一规则”来设计行为、这样能兼顾效率和灵活性。
4.4.1 fakeWindow:虚拟的全局对象
ProxySandbox 的核心创新在于 createFakeWindow 函数。它创建了一个”假 window”,作为子应用全局变量的容器:
// 来自 qiankun/src/sandbox/proxySandbox.ts(简化后)
function createFakeWindow(globalContext: Window): {
fakeWindow: Window;
propertiesWithGetter: Map<PropertyKey, boolean>;
} {
const propertiesWithGetter = new Map<PropertyKey, boolean>();
const fakeWindow = {} as Window;
// 核心逻辑:从真实 window 上复制不可配置的属性到 fakeWindow
// 为什么只复制不可配置的属性?
// 因为 Proxy 的 invariant 约束:
// 如果目标对象上某个属性是不可配置的,
// Proxy 的 get trap 必须返回与目标对象一致的值
// 否则会抛出 TypeError
Object.getOwnPropertyNames(globalContext)
.filter((prop) => {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, prop);
return !descriptor?.configurable;
})
.forEach((prop) => {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, prop)!;
if (descriptor.get) {
// 记录有 getter 的属性,后续特殊处理
propertiesWithGetter.set(prop, true);
}
// 将不可配置的属性定义到 fakeWindow 上
// 保持与原始 window 完全一致的属性描述符
Object.defineProperty(fakeWindow, prop, {
...descriptor,
// 关键:将 configurable 改为 true
// 这样 fakeWindow 上的这些属性后续还能被修改
configurable: descriptor.configurable,
});
});
return { fakeWindow, propertiesWithGetter };
}
4.4.2 Proxy 的 invariant 约束
上面代码中提到了一个关键概念:Proxy 的 invariant 约束。这是理解 fakeWindow 设计的钥匙:
// ES 规范中的 Proxy invariant 示例
const target = {};
Object.defineProperty(target, 'name', {
value: 'fixed',
writable: false,
configurable: false,
});
const proxy = new Proxy(target, {
get(target, prop) {
if (prop === 'name') {
return 'fake'; // 试图返回不同的值
}
return Reflect.get(target, prop);
},
});
// TypeError: 'get' on proxy: property 'name' is a read-only and
// non-configurable data property on the proxy target but the proxy
// did not return its actual value
console.log(proxy.name);
// 这就是为什么 createFakeWindow 必须把不可配置的属性
// 复制到 fakeWindow(也就是 Proxy 的 target)上
// 否则 Proxy 的 get trap 无法返回正确的值
4.4.3 ProxySandbox 完整实现
// 来自 qiankun/src/sandbox/proxySandbox.ts(简化后的核心逻辑)
class ProxySandbox implements SandBox {
name: string;
type = SandBoxType.Proxy;
sandboxRunning = false;
proxy: WindowProxy;
// 属性变更记录
private updatedValueSet = new Set<PropertyKey>();
constructor(name: string) {
this.name = name;
const { fakeWindow, propertiesWithGetter } = createFakeWindow(window);
const descriptorTargetMap = new Map<PropertyKey, 'target' | 'globalContext'>();
const rawWindow = window;
this.proxy = new Proxy(fakeWindow, {
set: (target: Window, prop: PropertyKey, value: any): boolean => {
if (this.sandboxRunning) {
// 直接写入 fakeWindow,不修改真实 window!
(target as any)[prop] = value;
this.updatedValueSet.add(prop);
// 特殊处理:如果修改的是 window 上必须同步的属性
// 比如 document.title 等
return true;
}
// 沙箱未激活时的写入,静默忽略
if (process.env.NODE_ENV === 'development') {
console.warn(`[qiankun] Set window.${String(prop)} while sandbox destroyed or inactive in ${name}!`);
}
return true;
},
get: (target: Window, prop: PropertyKey): any => {
// 特殊属性处理
if (prop === Symbol.unscopables) return undefined;
if (prop === 'window' || prop === 'self' || prop === 'globalThis') {
return this.proxy;
}
// 避免 window.window.window... 无限递归
if (prop === 'top' || prop === 'parent') {
// 如果不在 iframe 中,top 和 parent 指向自身
if (rawWindow === rawWindow.parent) {
return this.proxy;
}
return (rawWindow as any)[prop];
}
// 如果 fakeWindow 上有这个属性,优先从 fakeWindow 读取
if (target.hasOwnProperty(prop)) {
// 有 getter 的属性需要特殊处理
// 比如 window.location 有 getter
// 我们需要确保 this 指向正确
const getterValue = propertiesWithGetter.get(prop);
if (getterValue) {
return (rawWindow as any)[prop];
}
return (target as any)[prop];
}
// fakeWindow 上没有,从真实 window 读取
const rawValue = (rawWindow as any)[prop];
// 如果是函数,需要绑定正确的 this
if (typeof rawValue === 'function') {
// 确保函数的 this 指向真实 window
// 否则 window.addEventListener 等方法会报错
const boundValue = rawValue.bind(rawWindow);
// 但也要保留函数上的属性
// 比如 window.addEventListener.toString()
for (const key of Object.keys(rawValue)) {
boundValue[key] = rawValue[key];
}
return boundValue;
}
return rawValue;
},
has: (target: Window, prop: PropertyKey): boolean => {
// with 语句中的属性查找会触发 has trap
// 子应用的代码会被包裹在 with(sandbox.proxy) { ... } 中
return prop in target || prop in rawWindow;
},
getOwnPropertyDescriptor: (target: Window, prop: PropertyKey) => {
// 优先返回 fakeWindow 上的描述符
if (target.hasOwnProperty(prop)) {
const descriptor = Object.getOwnPropertyDescriptor(target, prop);
// 确保 configurable 为 true
// 否则后续的 defineProperty 可能失败
if (descriptor) {
descriptor.configurable = true;
}
return descriptor;
}
// 否则返回真实 window 上的描述符
const descriptor = Object.getOwnPropertyDescriptor(rawWindow, prop);
if (descriptor) {
descriptor.configurable = true;
}
return descriptor;
},
defineProperty: (target: Window, prop: PropertyKey, descriptor: PropertyDescriptor): boolean => {
// 定义属性时,写入 fakeWindow
Object.defineProperty(target, prop, descriptor);
return true;
},
deleteProperty: (target: Window, prop: PropertyKey): boolean => {
if (target.hasOwnProperty(prop)) {
delete (target as any)[prop];
this.updatedValueSet.delete(prop);
return true;
}
return true;
},
});
}
active() {
this.sandboxRunning = true;
}
inactive() {
this.sandboxRunning = false;
}
}
4.4.4 为什么 active/inactive 如此简单
注意到了吗?ProxySandbox 的 active() 和 inactive() 方法极其简单——只是翻转一个 sandboxRunning 标志。
这正是 ProxySandbox 最优雅的地方:因为所有变更都写入 fakeWindow 而不是真实 window,切换沙箱时不需要做任何恢复操作。 每个沙箱实例拥有自己的 fakeWindow,互不干扰。
// 对比三代沙箱的切换成本
// SnapshotSandbox:
// active() → 遍历 window 拍快照 + 恢复之前的修改 O(N)
// inactive() → 遍历 window 做 diff + 恢复原始值 O(N)
// LegacySandbox:
// active() → 遍历 currentUpdatedPropsValueMap 恢复 O(M)
// inactive() → 遍历两个 Map 恢复 + 删除 O(M)
// ProxySandbox:
// active() → this.sandboxRunning = true O(1)
// inactive() → this.sandboxRunning = false O(1)
// 这就是架构设计的力量:
// 从 O(N) → O(M) → O(1)
// 不是通过优化算法,而是通过改变数据结构
4.4.5 多实例的工作方式
// 创建两个独立的沙箱
const sandboxA = new ProxySandbox('app-A');
const sandboxB = new ProxySandbox('app-B');
// 同时激活——这在前两代沙箱中是不可能的!
sandboxA.active();
sandboxB.active();
// 子应用 A 设置全局变量
sandboxA.proxy.config = { theme: 'dark' };
// 写入 sandboxA 的 fakeWindow,window.config 不受影响
// 子应用 B 设置同名全局变量
sandboxB.proxy.config = { theme: 'light' };
// 写入 sandboxB 的 fakeWindow
// 各自读取——完美隔离
console.log(sandboxA.proxy.config); // { theme: 'dark' }
console.log(sandboxB.proxy.config); // { theme: 'light' }
console.log(window.config); // undefined —— 真实 window 完全不受影响!
4.4.6 get trap 中的函数绑定问题
ProxySandbox 的 get trap 中有一个容易被忽视的细节:从真实 window 上读取的函数需要重新绑定 this。
// 为什么需要绑定 this?
// 如果不绑定:
const proxyWindow = sandbox.proxy;
const addEventListener = proxyWindow.addEventListener;
addEventListener('click', handler);
// 报错!因为 addEventListener 的 this 指向了 proxy
// 而浏览器的原生方法期望 this 是真实的 window
// 绑定后:
const boundAddEventListener = rawWindow.addEventListener.bind(rawWindow);
boundAddEventListener('click', handler);
// 正常工作
// 但这里有个更微妙的问题:
// window.setTimeout 也是一个函数
// 但 setTimeout 不需要绑定 this(它是全局函数)
// 而 window.addEventListener 需要绑定 this(它是方法)
// 乾坤的做法是统一绑定——多绑定不会有副作用
// 还有一个陷阱:
// Function.prototype.bind 会创建一个新函数
// 新函数没有原函数上的自定义属性
// 所以需要手动复制属性
深度洞察:Proxy 沙箱的”读写分离”策略
ProxySandbox 的设计可以概括为一个极其精炼的策略:写入操作写 fakeWindow,读取操作先查 fakeWindow 再查 window。 这种”读写分离”在数据库领域是老生常谈,但在 JS 沙箱中却是一个巧妙的创新。它的优雅之处在于:子应用无需做任何改造就能正常工作——写入时自动隔离到 fakeWindow,读取时如果 fakeWindow 上没有就自动降级到真实 window(读取全局 API 如
console、setTimeout等)。这种”写时隔离、读时降级”的策略,在隔离性和兼容性之间找到了最佳平衡点。
4.5 沙箱的边界与逃逸:那些隔离不了的东西
到目前为止,三代沙箱看起来已经相当强大了。但真相是:JS 沙箱有大量隔离不了的东西。 这不是实现的问题,而是浏览器架构决定的根本性限制。
根因藏在浏览器的执行模型里:一个 Tab 对应一个 Renderer Process,其中运行着一个 V8 Isolate——Isolate 代表独立的 JS 堆和执行栈,同一 Isolate 内的代码共享同一套原型(Array.prototype、Object.prototype 等)、共享同一个全局定时器队列和事件循环。微前端里所有子应用都跑在同一个 Isolate 里,Proxy 只能拦截”对象属性访问”这一层,对 Isolate 内部的共享状态(原型链、DOM 事件表、定时器队列、storage)完全无能为力。真正意义上的完美隔离只能通过 iframe 或 Web Worker 获得新的 Isolate,代价是失去共享执行上下文的能力。
4.5.1 原型链污染
// 沙箱能拦截 window.xxx = value 这样的赋值
// 但无法拦截原型链上的修改
// 在子应用 A 中(通过沙箱执行)
Array.prototype.customMethod = function() { return 'polluted'; };
// 在子应用 B 中
const arr = [1, 2, 3];
console.log(arr.customMethod()); // 'polluted' —— 被污染了!
// 为什么拦截不了?
// 因为 Array.prototype 不是 window 的直接属性
// 子应用通过 proxy.Array 获取到的 Array 是真实的 Array
// 修改 Array.prototype 不会触发 proxy 的 set trap
// 类似的还有:
Object.prototype.injected = true;
String.prototype.customTrim = function() { /* ... */ };
Promise.prototype.customThen = function() { /* ... */ };
// 所有内建对象的原型链修改都无法被拦截
4.5.2 DOM 事件监听器
// 沙箱拦截不了直接的 DOM API 调用
// 子应用 A
document.addEventListener('click', handlerA);
document.body.addEventListener('scroll', scrollHandlerA);
// 即使子应用 A 被卸载,这些监听器依然存在
// 因为 document 和 document.body 是共享的
// Proxy 只能拦截 window 上的属性访问
// document.addEventListener 不走 Proxy 的 set trap
// 乾坤的解决方案:在沙箱层面 patch addEventListener
// 记录子应用注册的所有事件监听器
// 卸载时自动移除
function patchDocumentEvents(sandbox: SandBox) {
const bindEvents: Array<[string, EventListener]> = [];
const originalAddEventListener = document.addEventListener;
document.addEventListener = function(
type: string,
listener: EventListener,
options?: boolean | AddEventListenerOptions
) {
bindEvents.push([type, listener]);
originalAddEventListener.call(document, type, listener, options);
};
return function unpatch() {
bindEvents.forEach(([type, listener]) => {
document.removeEventListener(type, listener);
});
document.addEventListener = originalAddEventListener;
};
}
4.5.3 全局定时器
// setTimeout/setInterval 返回的 ID 是全局的
// 子应用设置了定时器但卸载时没有清除,定时器还会继续触发
// 子应用 A
const timerId = setInterval(() => {
// 这个回调会持续执行,即使子应用 A 已经卸载
updateDashboard(); // 可能操作已经不存在的 DOM
}, 1000);
// 乾坤的解决方案:patch setTimeout/setInterval
function patchTimer(sandbox: SandBox) {
const timerIds: number[] = [];
const originalSetInterval = window.setInterval;
(sandbox.proxy as any).setInterval = function(
callback: Function,
delay: number,
...args: any[]
) {
const id = originalSetInterval(callback, delay, ...args);
timerIds.push(id);
return id;
};
return function unpatch() {
timerIds.forEach((id) => clearInterval(id));
};
}
4.5.4 localStorage 和 sessionStorage
// localStorage 是共享的,沙箱无法拦截
// 子应用 A
localStorage.setItem('token', 'app-a-token');
// 子应用 B
localStorage.setItem('token', 'app-b-token');
// 子应用 A 再读取
localStorage.getItem('token'); // 'app-b-token' —— 被覆盖了!
// 潜在的解决方案:给 key 加命名空间前缀
function createScopedStorage(appName: string): Storage {
return new Proxy(localStorage, {
get(target, prop: keyof Storage) {
if (prop === 'getItem') {
return (key: string) => target.getItem(`${appName}:${key}`);
}
if (prop === 'setItem') {
return (key: string, value: string) => target.setItem(`${appName}:${key}`, value);
}
if (prop === 'removeItem') {
return (key: string) => target.removeItem(`${appName}:${key}`);
}
return Reflect.get(target, prop);
},
});
}
// 但乾坤默认并没有做 localStorage 隔离
// 因为很多场景下子应用之间需要共享 localStorage(如登录 token)
4.5.5 History 和 Location
// history 和 location 是特殊的全局对象
// 它们代表的是浏览器的真实状态,不可能被沙箱替换
// 子应用 A
history.pushState(null, '', '/order/list');
// 这个操作直接影响了浏览器地址栏
// 无法被沙箱隔离——因为浏览器只有一个地址栏
// 类似的还有:
// - navigator(设备信息是共享的)
// - screen(屏幕信息是共享的)
// - performance(性能测量是共享的)
// - fetch/XMLHttpRequest(网络请求是共享的)
4.5.6 CSS 副作用
// JS 沙箱完全无法隔离 CSS
// 子应用 A 通过 JS 动态创建样式
const style = document.createElement('style');
style.textContent = `
body { background: red; }
.btn { color: blue; }
`;
document.head.appendChild(style);
// 这些样式是全局生效的
// 会影响所有子应用和主应用
// JS 沙箱无能为力——CSS 隔离需要另一套机制
// (我们在第 5 章会详细讨论)
4.5.7 逃逸总结
// 完整的沙箱逃逸清单
interface SandboxEscapes {
// 完全无法隔离
prototypeChain: '原型链修改(Array.prototype 等)';
sharedDOM: '共享的 DOM 节点(document, document.body)';
browserAPIs: 'history, location, navigator, screen';
networkAPIs: 'fetch, XMLHttpRequest, WebSocket';
storage: 'localStorage, sessionStorage, cookie';
css: '所有 CSS 副作用';
webWorkers: 'Worker, SharedWorker, ServiceWorker';
// 需要额外 patch 才能隔离
timers: 'setTimeout, setInterval, requestAnimationFrame';
eventListeners: 'document/window 上的事件监听器';
mutationObserver: 'MutationObserver';
resizeObserver: 'ResizeObserver';
// 可以被 Proxy 沙箱隔离
windowProperties: 'window 上的直接属性读写';
globalVariables: '全局变量声明';
}
深度洞察:完美的 JS 隔离在浏览器中是不可能的
为什么浏览器中无法实现完美的 JS 隔离?根源在于浏览器的架构:同一个页面中的所有 JS 代码共享一个 V8 Isolate 实例。 而真正的隔离需要独立的 Isolate——这正是 iframe、Web Worker 和 Node.js 的 vm 模块所做的。Proxy 沙箱本质上是在”同一个 Isolate 内模拟多个上下文”,这注定只能拦截属性访问层面的操作,无法拦截引擎层面的共享状态(原型链、内建对象、DOM 树)。这不是乾坤的实现不够好——而是 Proxy 机制本身的理论边界。理解这一点很重要:选择 Proxy 沙箱,就意味着接受”近似隔离”而不是”完美隔离”。如果你的场景需要完美隔离,唯一的选择是 iframe。
4.6 手写实现三种沙箱核心逻辑
理论分析完了,从零开始实现三种沙箱。通过手写实现,可以把前面讲过的每一个设计决策串起来。
4.6.1 手写 SnapshotSandbox
下面是一个”可以独立运行、脱离乾坤主框架”的 mini 版本。它保留了核心逻辑、省略了边界处理和性能优化细节,核心算法的”骨架”因此清晰可见;读懂它之后再回头看乾坤源码里带各种边界处理的”丰满版”,就能一眼分辨哪些是核心、哪些是装饰。
// 完整的可运行实现
enum SandBoxType {
Snapshot = 'Snapshot',
LegacyProxy = 'LegacyProxy',
Proxy = 'Proxy',
}
interface SandBox {
name: string;
type: SandBoxType;
proxy: WindowProxy;
sandboxRunning: boolean;
active(): void;
inactive(): void;
}
class MiniSnapshotSandbox implements SandBox {
name: string;
type = SandBoxType.Snapshot;
sandboxRunning = false;
proxy: WindowProxy;
private windowSnapshot: Record<string, any> = {};
private modifyPropsMap: Record<string, any> = {};
constructor(name: string) {
this.name = name;
this.proxy = window; // 直接使用 window
}
active() {
// 第一步:拍摄快照
this.windowSnapshot = {};
for (const prop in window) {
if (window.hasOwnProperty(prop)) {
this.windowSnapshot[prop] = (window as any)[prop];
}
}
// 第二步:恢复之前的修改
for (const prop in this.modifyPropsMap) {
(window as any)[prop] = this.modifyPropsMap[prop];
}
this.sandboxRunning = true;
console.log(`[${this.name}] Snapshot sandbox activated`);
}
inactive() {
this.modifyPropsMap = {};
// 对比当前 window 与快照,找出差异
for (const prop in window) {
if (window.hasOwnProperty(prop)) {
const currentValue = (window as any)[prop];
const snapshotValue = this.windowSnapshot[prop];
if (currentValue !== snapshotValue) {
// 记录变更
this.modifyPropsMap[prop] = currentValue;
// 恢复快照值
(window as any)[prop] = snapshotValue;
}
}
}
this.sandboxRunning = false;
console.log(`[${this.name}] Snapshot sandbox deactivated. Changes recorded: ${Object.keys(this.modifyPropsMap).length}`);
}
}
// 验证
function testSnapshotSandbox() {
const sandbox = new MiniSnapshotSandbox('test-app');
// 记录初始状态
(window as any).__test_original = 'original';
sandbox.active();
(window as any).__test_original = 'modified';
(window as any).__test_new = 'new value';
console.log((window as any).__test_original); // 'modified'
console.log((window as any).__test_new); // 'new value'
sandbox.inactive();
console.log((window as any).__test_original); // 'original' ← 恢复了
console.log((window as any).__test_new); // undefined ← 被清理了
sandbox.active();
console.log((window as any).__test_original); // 'modified' ← 重新应用了
console.log((window as any).__test_new); // 'new value' ← 重新应用了
// 清理
sandbox.inactive();
delete (window as any).__test_original;
delete (window as any).__test_new;
}
4.6.2 手写 LegacySandbox
class MiniLegacySandbox implements SandBox {
name: string;
type = SandBoxType.LegacyProxy;
sandboxRunning = false;
proxy: WindowProxy;
// 新增的属性
private addedPropsMap = new Map<PropertyKey, any>();
// 被修改的属性的原始值
private modifiedPropsOriginalValueMap = new Map<PropertyKey, any>();
// 所有变更的当前值(新增 + 修改)
private currentUpdatedPropsValueMap = new Map<PropertyKey, any>();
constructor(name: string) {
this.name = name;
const self = this;
const rawWindow = window;
const fakeWindow = Object.create(null);
this.proxy = new Proxy(fakeWindow, {
set(_target: Window, prop: PropertyKey, value: any): boolean {
if (!self.sandboxRunning) {
if (process.env.NODE_ENV === 'development') {
console.warn(`[${name}] Setting ${String(prop)} while sandbox inactive`);
}
return true;
}
const originalHasProperty = rawWindow.hasOwnProperty(prop);
const originalValue = (rawWindow as any)[prop];
if (!originalHasProperty) {
// 新增属性
self.addedPropsMap.set(prop, value);
} else if (!self.modifiedPropsOriginalValueMap.has(prop)) {
// 修改已有属性(仅记录第一次修改前的原始值)
self.modifiedPropsOriginalValueMap.set(prop, originalValue);
}
// 记录当前值
self.currentUpdatedPropsValueMap.set(prop, value);
// 直接修改真实 window
(rawWindow as any)[prop] = value;
return true;
},
get(_target: Window, prop: PropertyKey): any {
// 特殊处理:确保 proxy.window === proxy
if (prop === 'window' || prop === 'self' || prop === 'globalThis') {
return self.proxy;
}
return (rawWindow as any)[prop];
},
has(_target: Window, prop: PropertyKey): boolean {
return prop in rawWindow;
},
deleteProperty(_target: Window, prop: PropertyKey): boolean {
if (rawWindow.hasOwnProperty(prop)) {
// 如果是子应用新增的属性,从记录中移除
if (self.addedPropsMap.has(prop)) {
self.addedPropsMap.delete(prop);
}
// 删除真实 window 上的属性
delete (rawWindow as any)[prop];
self.currentUpdatedPropsValueMap.delete(prop);
}
return true;
},
});
}
active() {
// 恢复子应用的所有变更
this.currentUpdatedPropsValueMap.forEach((value, prop) => {
(window as any)[prop] = value;
});
this.sandboxRunning = true;
console.log(`[${this.name}] Legacy sandbox activated`);
}
inactive() {
// 恢复被修改的属性的原始值
this.modifiedPropsOriginalValueMap.forEach((originalValue, prop) => {
(window as any)[prop] = originalValue;
});
// 删除新增的属性
this.addedPropsMap.forEach((_value, prop) => {
delete (window as any)[prop];
});
this.sandboxRunning = false;
console.log(`[${this.name}] Legacy sandbox deactivated`);
}
}
// 验证
function testLegacySandbox() {
const sandbox = new MiniLegacySandbox('test-legacy');
sandbox.active();
// 通过 proxy 设置
(sandbox.proxy as any).__legacy_test = 'hello';
console.log((window as any).__legacy_test); // 'hello' ← 真实 window 被修改了
sandbox.inactive();
console.log((window as any).__legacy_test); // undefined ← 恢复了
sandbox.active();
console.log((window as any).__legacy_test); // 'hello' ← 重新应用了
sandbox.inactive();
}
4.6.3 手写 ProxySandbox
这是最复杂也是最精妙的实现:
function miniCreateFakeWindow(globalContext: Window) {
const propertiesWithGetter = new Map<PropertyKey, boolean>();
const fakeWindow = {} as Window;
// 复制不可配置的属性
Object.getOwnPropertyNames(globalContext)
.filter((prop) => {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, prop);
return !descriptor?.configurable;
})
.forEach((prop) => {
const descriptor = Object.getOwnPropertyDescriptor(globalContext, prop)!;
if (descriptor.get) {
propertiesWithGetter.set(prop, true);
}
// 复制到 fakeWindow
// 注意:这里使用 rawDescriptor,保持与原始属性一致
Object.defineProperty(fakeWindow, prop, {
...descriptor,
// 对于不可配置的属性,我们不能改变它的 configurable
// 但可以复制其值
});
});
return { fakeWindow, propertiesWithGetter };
}
class MiniProxySandbox implements SandBox {
name: string;
type = SandBoxType.Proxy;
sandboxRunning = false;
proxy: WindowProxy;
private updatedValueSet = new Set<PropertyKey>();
constructor(name: string) {
this.name = name;
const self = this;
const rawWindow = window;
const { fakeWindow, propertiesWithGetter } = miniCreateFakeWindow(rawWindow);
this.proxy = new Proxy(fakeWindow, {
set(target: Window, prop: PropertyKey, value: any): boolean {
if (self.sandboxRunning) {
// 写入 fakeWindow,不修改真实 window
(target as any)[prop] = value;
self.updatedValueSet.add(prop);
} else {
console.warn(`[${name}] Setting ${String(prop)} while sandbox inactive`);
}
return true;
},
get(target: Window, prop: PropertyKey): any {
// 避免逃逸:确保 window.window 返回 proxy 而不是真实 window
if (prop === 'window' || prop === 'self' || prop === 'globalThis') {
return self.proxy;
}
if (prop === Symbol.unscopables) {
return undefined;
}
// 特殊处理 top 和 parent
if (prop === 'top' || prop === 'parent') {
if (rawWindow === rawWindow.parent) {
return self.proxy;
}
return (rawWindow as any)[prop];
}
// 优先从 fakeWindow 读取
if (target.hasOwnProperty(prop)) {
// 有 getter 的属性从真实 window 读取
if (propertiesWithGetter.has(prop)) {
return (rawWindow as any)[prop];
}
return (target as any)[prop];
}
// fakeWindow 上没有,从真实 window 读取
const rawValue = (rawWindow as any)[prop];
// 函数需要绑定 this 到 rawWindow
if (typeof rawValue === 'function') {
// 某些原生构造函数不能 bind(如 Map, Set, Symbol)
// 使用 try-catch 兜底
try {
const boundFn = rawValue.bind(rawWindow);
// 保留函数的可枚举属性
for (const key of Object.keys(rawValue)) {
boundFn[key] = (rawValue as any)[key];
}
return boundFn;
} catch (e) {
return rawValue;
}
}
return rawValue;
},
has(target: Window, prop: PropertyKey): boolean {
return prop in target || prop in rawWindow;
},
getOwnPropertyDescriptor(target: Window, prop: PropertyKey) {
if (target.hasOwnProperty(prop)) {
const descriptor = Object.getOwnPropertyDescriptor(target, prop);
if (descriptor) {
descriptor.configurable = true;
}
return descriptor;
}
const descriptor = Object.getOwnPropertyDescriptor(rawWindow, prop);
if (descriptor) {
descriptor.configurable = true;
}
return descriptor;
},
defineProperty(target: Window, prop: PropertyKey, attributes: PropertyDescriptor): boolean {
Object.defineProperty(target, prop, attributes);
return true;
},
deleteProperty(target: Window, prop: PropertyKey): boolean {
if (target.hasOwnProperty(prop)) {
delete (target as any)[prop];
self.updatedValueSet.delete(prop);
}
return true;
},
ownKeys(target: Window): ArrayLike<string | symbol> {
return [...new Set([
...Reflect.ownKeys(rawWindow),
...Reflect.ownKeys(target),
])];
},
});
}
active() {
this.sandboxRunning = true;
console.log(`[${this.name}] Proxy sandbox activated`);
}
inactive() {
this.sandboxRunning = false;
console.log(`[${this.name}] Proxy sandbox deactivated`);
}
}
// 验证多实例隔离
function testProxySandbox() {
const sandboxA = new MiniProxySandbox('app-A');
const sandboxB = new MiniProxySandbox('app-B');
// 同时激活
sandboxA.active();
sandboxB.active();
// 各自设置同名属性
(sandboxA.proxy as any).__proxy_test = 'value from A';
(sandboxB.proxy as any).__proxy_test = 'value from B';
// 验证隔离
console.log((sandboxA.proxy as any).__proxy_test); // 'value from A'
console.log((sandboxB.proxy as any).__proxy_test); // 'value from B'
console.log((window as any).__proxy_test); // undefined
// 验证 window 上的属性可以读取
console.log((sandboxA.proxy as any).console === console); // true
console.log((sandboxA.proxy as any).Array === Array); // true
// 验证 window.window 指向 proxy
console.log((sandboxA.proxy as any).window === sandboxA.proxy); // true
sandboxA.inactive();
sandboxB.inactive();
}
4.6.4 子应用代码如何在沙箱中执行
有了沙箱,还需要一个关键环节:如何让子应用的代码运行在沙箱中而不是真实 window 上。 乾坤使用了 with 语句 + eval 的组合:
// 乾坤执行子应用 JS 的核心逻辑(简化)
function execScriptInSandbox(
scriptText: string,
proxy: WindowProxy,
strictGlobal: boolean
): void {
// 将子应用的代码包裹在 with 语句中
// with(proxy) 使得代码中的全局变量查找会通过 proxy 的 has/get trap
const executableScript = strictGlobal
? `;(function(window, self, globalThis){
with(window){
${scriptText}
}
}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`
: `;(function(window, self, globalThis){
${scriptText}
}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`;
// 通过 eval 执行
(0, eval)(executableScript);
}
// 为什么用 (0, eval) 而不是 eval?
// 直接 eval 是"直接调用",代码在当前作用域执行
// (0, eval) 是"间接调用",代码在全局作用域执行
// 我们需要全局作用域——否则子应用的代码会受到当前函数作用域的影响
with 语句是沙箱机制的关键粘合剂。当子应用的代码在 with(proxy) 中执行时:
// 子应用的原始代码
var name = 'my-app';
console.log(name);
window.config = { version: '1.0' };
// 在 with(proxy) 中执行时的行为:
// var name = 'my-app' → 触发 proxy 的 set trap(var 声明的变量会挂载到 with 对象上)
// console.log(name) → 先触发 proxy 的 has trap,然后触发 get trap
// window.config = ... → window 参数已被替换为 proxy,所以写入 proxy
4.6.5 三种沙箱的源码对照表
| 设计决策 | SnapshotSandbox | LegacySandbox | ProxySandbox |
|---|---|---|---|
proxy 指向 | window 自身 | new Proxy(fakeWindow, ...) | new Proxy(fakeWindow, ...) |
| 写入目标 | window(直接) | window(通过 set trap) | fakeWindow(通过 set trap) |
| 读取来源 | window(直接) | window(通过 get trap) | fakeWindow 优先,降级 window |
| 变更追踪 | 事后 diff | 实时记录(3 个 Map) | updatedValueSet |
| active 操作 | 拍快照 + 恢复修改 | 恢复所有变更 | 翻转标志位 |
| inactive 操作 | diff + 恢复快照 | 恢复原值 + 删新增 | 翻转标志位 |
| 多实例 | 不支持 | 不支持 | 支持 |
| 性能等级 | O(N) per switch | O(M) per switch | O(1) per switch |
4.6.6 沙箱选择策略
乾坤保留三种沙箱并存,不是历史遗留问题,而是对真实部署环境多样性的承认:不是每个子应用都在现代浏览器运行、不是每个部署都允许 Proxy、不是每个场景都需要多实例。这种”能力探测 + 自动降级”的模式,和《Claude Code 源码》第 10 章讨论的执行沙箱降级策略(Unix 下 sandbox-exec、Linux 下 seccomp/namespace、都不支持则 fallback 到无沙箱)在思路上是一致的。
// 乾坤内部的沙箱选择逻辑(简化)
function createSandbox(
appName: string,
useLooseSandbox: boolean,
singular: boolean
): SandBox {
// 如果浏览器不支持 Proxy,只能用快照沙箱
if (!window.Proxy) {
return new SnapshotSandbox(appName);
}
// 如果是单实例模式且使用宽松沙箱
if (useLooseSandbox && singular) {
return new LegacySandbox(appName);
}
// 默认使用 ProxySandbox
return new ProxySandbox(appName);
}
// 在实际使用中,绝大多数情况会走到 ProxySandbox
// LegacySandbox 主要是为了向后兼容
// SnapshotSandbox 是 IE 环境的降级方案
本章小结
- SnapshotSandbox 通过全量 diff window 实现隔离,实现简单但性能差(O(N)),是 IE 环境的降级方案
- LegacySandbox 用 Proxy 实时拦截写入,用三个 Map 精准追踪变更,性能提升到 O(M),但仍直接修改 window,不支持多实例
- ProxySandbox 引入 fakeWindow 实现”写时隔离、读时降级”,切换成本降为 O(1),支持多实例,是生产环境推荐方案
- createFakeWindow 的设计受 Proxy invariant 约束驱动:不可配置的属性必须复制到 target 对象上
- JS 沙箱存在根本性的隔离边界:原型链污染、DOM 事件、定时器、localStorage、History/Location、CSS 副作用等均无法通过 Proxy 拦截
- 完美的 JS 隔离在浏览器中是理论上不可能的——同一页面共享一个 V8 Isolate,Proxy 只能拦截属性访问,无法拦截引擎层面的共享状态
- 子应用代码通过
with(proxy)+(0, eval)的方式在沙箱中执行,with语句使全局变量查找走 Proxy 的 has/get trap
三代沙箱的演进揭示了三条可迁移的设计观察:其一,SnapshotSandbox 和 LegacySandbox 做的是同一件事,但复杂度差一个量级——当”实际工作量”(子应用修改的 ~30 个属性)与”名义工作量”(window 上 ~500 个属性)相差一个量级,就是重新设计数据结构的时机;其二,LegacySandbox 到 ProxySandbox 的关键跃迁是”支持多个子应用并发激活”,这把沙箱从单例架构推向多例架构;其三,4.5 节讨论的”沙箱隔离不了的东西”往往被忽视,但它才是最重要的结论——任何抽象都有自己的边界,明确边界比假装抽象完美要负责得多。
与《Vue 3 源码》第 4 章的响应式系统对比:Vue 3 用 Proxy 追踪读取(实现依赖收集),乾坤用 Proxy 拦截写入(实现隔离)——同一个 ES 原语在不同问题场景下被复用。
思考题
-
源码理解:LegacySandbox 中的
modifiedPropsOriginalValueMapInSandbox只在第一次修改某个属性时记录原始值。如果删掉这个”只记录第一次”的逻辑(即每次修改都更新原始值),会导致什么问题?请用一个具体的例子说明。 -
设计分析:ProxySandbox 的 get trap 中,从 window 上读取函数后会做
rawValue.bind(rawWindow)。如果去掉这个 bind 操作,哪些常用的浏览器 API 会出错?请列出至少三个并解释原因。 -
性能对比:假设一个子应用在运行期间修改了 30 个 window 属性,而 window 上共有 500 个属性。请分别计算三种沙箱在一次 active + inactive 周期中的属性操作次数,并分析在什么条件下 SnapshotSandbox 的性能反而优于 LegacySandbox。
-
架构思考:本章指出”完美的 JS 隔离在浏览器中是不可能的”。如果让你设计一个新的浏览器 API 来实现完美的页面内 JS 隔离(不用 iframe),你会怎么设计?需要 V8 引擎做哪些配合?
-
实战问题:你的团队在使用乾坤时发现:子应用 A 修改了
Array.prototype.toJSON,导致子应用 B 的 JSON 序列化结果异常。JS 沙箱无法拦截原型链修改,你会如何解决这个问题?请给出至少两种方案并分析各自的优缺点。