Appearance
第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 三代沙箱对比总览
| 特性 | SnapshotSandbox | LegacySandbox | ProxySandbox |
|---|---|---|---|
| 实现原理 | 全量 diff window | Proxy 拦截 + 记录变更 | Proxy + fakeWindow |
| 多实例支持 | 不支持 | 不支持 | 支持 |
| 性能 | 差(遍历 window) | 好(精准拦截) | 好 |
| 浏览器兼容 | IE 9+ | ES6 Proxy | ES6 Proxy |
| 隔离粒度 | 激活/失活时整体切换 | 激活/失活时整体切换 | 每个实例独立 |
| 对 window 的影响 | 直接修改 window | 直接修改 window | 不修改 window |
| 适用场景 | 降级方案 | 单实例过渡方案 | 生产推荐方案 |
下图展示了三代沙箱的演进脉络与核心差异:
三代沙箱的演进轨迹非常清晰:
- SnapshotSandbox:不支持 Proxy 的环境下的降级方案,通过保存和恢复 window 快照实现隔离
- LegacySandbox:引入 Proxy,不再需要遍历 window,但仍然直接修改真实的 window 对象
- 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 核心思想
想象你和室友合租一间房间,但你们不能同时在房间里。你的使用时段是白天,室友是晚上。为了避免冲突,你们约定:
- 你进入房间前,拍一张照片记录房间的初始状态
- 你在房间里随意使用——挪桌子、换窗帘、贴海报
- 你离开时,对比当前状态和初始照片,把所有改动记录下来,然后恢复原样
- 下次你再进来时,根据之前的记录重新应用你的改动
这就是 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 的完整时序: