设计前端沙箱隔离系统
问题
如何设计一个完整的前端沙箱隔离系统?从 JS 隔离、CSS 隔离、DOM 隔离到副作用清理,请详细说明核心模块的设计思路与关键技术实现,并对比各方案在微前端场景下的优劣。
答案
前端沙箱隔离系统是微前端架构的核心基础设施,其目标是让多个子应用在同一页面中互不干扰地运行。一个完整的沙箱系统需要解决 JS 全局变量隔离、CSS 样式冲突、DOM 操作隔离和副作用清理四大核心挑战。本文将从需求分析出发,系统性地介绍各类沙箱方案的原理与实现。
一、需求分析
功能需求
| 模块 | 功能点 |
|---|---|
| JS 隔离 | 隔离子应用对 window、document、location 等全局对象的修改 |
| CSS 隔离 | 防止子应用之间、主子应用之间的样式冲突 |
| DOM 隔离 | 限制子应用的 DOM 操作范围,防止越界访问 |
| 副作用清理 | 子应用卸载时自动清理定时器、事件监听、DOM 操作、网络请求 |
| 安全执行 | 在受限环境中安全执行第三方代码 |
| 多实例支持 | 支持多个子应用同时运行且互不影响 |
非功能需求
| 指标 | 目标 |
|---|---|
| 隔离完整性 | JS/CSS/DOM 三重隔离,无逃逸 |
| 性能开销 | 沙箱启停 < 5ms,运行时 Proxy 拦截开销可忽略 |
| 兼容性 | 核心方案支持现代浏览器,降级方案支持 IE11 |
| 透明性 | 子应用无需大量改造即可在沙箱中运行 |
| 可恢复性 | 子应用卸载后环境可完全恢复到初始状态 |
沙箱系统的设计原则是 "最小权限 + 最大兼容":在保证隔离完整性的前提下,尽量让子应用感知不到沙箱的存在,减少接入成本。
二、整体架构
架构分层说明
| 层级 | 职责 | 关键技术 |
|---|---|---|
| JS 隔离层 | 隔离全局变量修改,提供独立的 window 代理 | Proxy、快照 diff、iframe |
| CSS 隔离层 | 防止样式穿透与冲突 | Shadow DOM、Scoped CSS、CSS Modules |
| DOM 隔离层 | 限制 DOM 操作范围 | Shadow DOM、API 劫持 |
| 副作用管理层 | 收集并清理运行时副作用 | AOP 劫持、WeakRef 追踪 |
三、JS 沙箱方案对比
JS 沙箱是整个隔离系统的核心,目标是隔离子应用对全局变量(window)的修改。以下是主流方案的全面对比:
| 方案 | 实现原理 | 多实例 | 性能 | 兼容性 | 隔离强度 | 典型应用 |
|---|---|---|---|---|---|---|
| SnapshotSandbox | window 快照 + diff | 不支持 | 一般 | IE11+ | 弱 | qiankun 降级 |
| LegacySandbox | 单实例 Proxy | 不支持 | 好 | 不支持 IE | 中 | qiankun 单实例 |
| ProxySandbox | 多实例 Proxy + fakeWindow | 支持 | 好 | 不支持 IE | 强 | qiankun 默认 |
| IframeSandbox | 浏览器原生隔离 | 支持 | 一般 | 全平台 | 最强 | wujie |
| ShadowRealm | TC39 提案,原生隔离 | 支持 | 最好 | 未普及 | 强 | 未来方案 |
| with + eval | with 改变作用域链 | 支持 | 差 | 全平台 | 最弱 | 简易沙箱 |
四、核心模块设计
4.1 快照沙箱(SnapshotSandbox)
快照沙箱通过在子应用激活时拍摄 window 快照,在卸载时diff 还原来实现隔离。它是 Proxy 不可用时的降级方案。
type WindowProp = string;
class SnapshotSandbox {
private name: string;
private windowSnapshot: Map<WindowProp, unknown> = new Map();
private modifyPropsMap: Map<WindowProp, unknown> = new Map();
private isActive: boolean = false;
constructor(name: string) {
this.name = name;
}
/** 激活沙箱:保存当前 window 快照 */
activate(): void {
// 1. 拍摄 window 快照
this.windowSnapshot.clear();
for (const key in window) {
this.windowSnapshot.set(key, (window as Record<string, unknown>)[key]);
}
// 2. 恢复上次沙箱运行时的修改
this.modifyPropsMap.forEach((value, key) => {
(window as Record<string, unknown>)[key] = value;
});
this.isActive = true;
}
/** 失活沙箱:diff 找出修改并还原 */
deactivate(): void {
this.modifyPropsMap.clear();
// 遍历 window,找出被修改的属性
for (const key in window) {
const currentValue = (window as Record<string, unknown>)[key];
const originalValue = this.windowSnapshot.get(key);
if (currentValue !== originalValue) {
// 记录修改,以便下次激活时恢复
this.modifyPropsMap.set(key, currentValue);
// 还原 window 为原始值
(window as Record<string, unknown>)[key] = originalValue;
}
}
this.isActive = false;
}
}
- 性能差:每次激活/失活都需要遍历整个 window 对象,属性数量可达数百个
- 不支持多实例:同一时刻只能有一个子应用运行,因为直接修改的是真实 window
- 不可枚举属性遗漏:
for...in无法遍历不可枚举属性,导致隔离不完整
4.2 Proxy 单实例沙箱(LegacySandbox)
LegacySandbox 使用 Proxy 拦截对 window 的修改,记录新增和修改的属性,但仍然操作真实 window。相比快照沙箱,它避免了全量遍历,性能更好。
class LegacySandbox {
/** 子应用新增的全局属性 */
private addedPropsMap: Map<string, unknown> = new Map();
/** 子应用修改的全局属性(记录原始值) */
private modifiedOriginalValuesMap: Map<string, unknown> = new Map();
/** 当前子应用所有变更(包括新增和修改) */
private currentUpdatedPropsMap: Map<string, unknown> = new Map();
public proxy: WindowProxy;
private isActive: boolean = false;
constructor(name: string) {
const rawWindow = window;
const addedPropsMap = this.addedPropsMap;
const modifiedOriginalValuesMap = this.modifiedOriginalValuesMap;
const currentUpdatedPropsMap = this.currentUpdatedPropsMap;
const proxy = new Proxy(rawWindow, {
set(target: Window, key: string, value: unknown): boolean {
if (!rawWindow.hasOwnProperty(key)) {
// 新增属性
addedPropsMap.set(key, value);
} else if (!modifiedOriginalValuesMap.has(key)) {
// 首次修改,记录原始值
modifiedOriginalValuesMap.set(
key,
(target as Record<string, unknown>)[key]
);
}
currentUpdatedPropsMap.set(key, value);
// 仍然修改真实 window
(target as Record<string, unknown>)[key] = value;
return true;
},
get(target: Window, key: string): unknown {
return (target as Record<string, unknown>)[key];
},
});
this.proxy = proxy;
}
activate(): void {
// 恢复子应用运行时的修改
this.currentUpdatedPropsMap.forEach((value, key) => {
(window as Record<string, unknown>)[key] = value;
});
this.isActive = true;
}
deactivate(): void {
// 还原被修改的属性为原始值
this.modifiedOriginalValuesMap.forEach((value, key) => {
(window as Record<string, unknown>)[key] = value;
});
// 删除新增的属性
this.addedPropsMap.forEach((_, key) => {
delete (window as Record<string, unknown>)[key];
});
this.isActive = false;
}
}
LegacySandbox 通过 Proxy 实时记录变更,避免了 activate/deactivate 时的全量 diff,性能更好。但因为仍然操作真实 window,所以不支持多实例并行。
4.3 Proxy 多实例沙箱(ProxySandbox)
ProxySandbox 是最核心的沙箱方案,它为每个子应用创建一个 fakeWindow,所有写操作都写入 fakeWindow,读操作优先从 fakeWindow 读取,不存在时再从真实 window 读取。这样多个子应用各自操作自己的 fakeWindow,互不干扰。
class ProxySandbox {
public proxy: WindowProxy;
private isActive: boolean = false;
private fakeWindow: Map<string | symbol, unknown> = new Map();
private name: string;
constructor(name: string) {
this.name = name;
const rawWindow = window;
const fakeWindow = this.fakeWindow;
this.proxy = new Proxy(rawWindow, {
set: (
_target: Window,
key: string | symbol,
value: unknown
): boolean => {
if (this.isActive) {
// 所有写操作都写入 fakeWindow,不污染真实 window
fakeWindow.set(key, value);
}
return true;
},
get: (target: Window, key: string | symbol): unknown => {
// 避免通过 window.window 或 window.self 逃逸
if (key === 'window' || key === 'self' || key === 'globalThis') {
return this.proxy;
}
// 优先从 fakeWindow 读取
if (fakeWindow.has(key)) {
return fakeWindow.get(key);
}
// fakeWindow 中不存在,从真实 window 读取
const rawValue = (target as Record<string | symbol, unknown>)[key];
// 如果是函数,需要绑定到原始 window(避免 this 指向问题)
if (typeof rawValue === 'function' && !rawValue.toString().startsWith('class')) {
return rawValue.bind(target);
}
return rawValue;
},
has: (_target: Window, key: string | symbol): boolean => {
return fakeWindow.has(key) || key in window;
},
deleteProperty: (
_target: Window,
key: string | symbol
): boolean => {
if (fakeWindow.has(key)) {
fakeWindow.delete(key);
}
return true;
},
});
}
activate(): void {
this.isActive = true;
}
deactivate(): void {
this.isActive = false;
}
/** 销毁沙箱,释放内存 */
destroy(): void {
this.isActive = false;
this.fakeWindow.clear();
}
}
ProxySandbox 使用示例
// 创建两个独立沙箱
const sandbox1 = new ProxySandbox('app-1');
const sandbox2 = new ProxySandbox('app-2');
sandbox1.activate();
sandbox2.activate();
// 子应用 A 在自己的沙箱中设置变量
(sandbox1.proxy as Record<string, unknown>).appName = 'App A';
(sandbox1.proxy as Record<string, unknown>).theme = 'dark';
// 子应用 B 在自己的沙箱中设置变量
(sandbox2.proxy as Record<string, unknown>).appName = 'App B';
(sandbox2.proxy as Record<string, unknown>).theme = 'light';
// 互不干扰
console.log((sandbox1.proxy as Record<string, unknown>).appName); // 'App A'
console.log((sandbox2.proxy as Record<string, unknown>).appName); // 'App B'
// 真实 window 未被污染
console.log((window as Record<string, unknown>).appName); // undefined
4.4 iframe 沙箱
iframe 提供浏览器原生的隔离能力,每个 iframe 拥有独立的 window、document、location。现代微前端框架(如 wujie)巧妙地利用同域 iframe 实现了兼顾隔离强度和用户体验的方案。
class IframeSandbox {
private iframe: HTMLIFrameElement;
private iframeWindow: Window;
private name: string;
constructor(name: string, url?: string) {
this.name = name;
// 创建隐藏的 iframe,使用同域 src 避免跨域限制
this.iframe = document.createElement('iframe');
this.iframe.setAttribute('style', 'display: none;');
// 使用 about:blank 或同域 URL 确保可以访问 contentWindow
this.iframe.src = url ?? 'about:blank';
document.body.appendChild(this.iframe);
this.iframeWindow = this.iframe.contentWindow!;
}
/** 获取沙箱中的 window 对象 */
get sandboxWindow(): Window {
return this.iframeWindow;
}
/** 在沙箱中执行代码 */
execScript(code: string): void {
const iframeDocument = this.iframeWindow.document;
const script = iframeDocument.createElement('script');
script.textContent = code;
iframeDocument.head.appendChild(script);
}
/** 销毁沙箱 */
destroy(): void {
this.iframe.parentNode?.removeChild(this.iframe);
}
}
wujie 的 iframe 沙箱思路
wujie 采用了 iframe(JS 隔离) + WebComponent(CSS/DOM 隔离) 的组合方案:
核心思路:
- JS 在 iframe 中执行:天然隔离 window、document、location
- DOM 渲染在 WebComponent 的 Shadow DOM 中:CSS 天然隔离
- 通过 Proxy 将 iframe 中的 document 操作代理到 Shadow DOM:打通 JS 与 DOM
class WujieLikeSandbox {
private iframe: HTMLIFrameElement;
private shadowRoot: ShadowRoot;
private iframeWindow: Window;
constructor(name: string, container: HTMLElement) {
// 1. 创建 WebComponent 容器(CSS/DOM 隔离)
const hostElement = document.createElement('div');
container.appendChild(hostElement);
this.shadowRoot = hostElement.attachShadow({ mode: 'open' });
// 2. 创建同域 iframe(JS 隔离)
this.iframe = document.createElement('iframe');
this.iframe.style.display = 'none';
this.iframe.src = 'about:blank';
document.body.appendChild(this.iframe);
this.iframeWindow = this.iframe.contentWindow!;
// 3. 代理 iframe 中的 document 操作到 Shadow DOM
this.patchDocument();
}
/** 将 iframe 的 document 操作劫持到 Shadow DOM */
private patchDocument(): void {
const shadowRoot = this.shadowRoot;
const iframeDocument = this.iframeWindow.document;
// 劫持 document.querySelector
const originalQuerySelector = iframeDocument.querySelector.bind(iframeDocument);
iframeDocument.querySelector = function (selector: string) {
// 优先在 Shadow DOM 中查找
return shadowRoot.querySelector(selector) ?? originalQuerySelector(selector);
} as typeof iframeDocument.querySelector;
// 劫持 document.getElementById
iframeDocument.getElementById = function (id: string) {
return shadowRoot.getElementById(id);
} as typeof iframeDocument.getElementById;
// 劫持 document.createElement 后的 appendChild
// 使样式和 DOM 插入到 Shadow DOM 中
const originalAppendChild = iframeDocument.head.appendChild.bind(
iframeDocument.head
);
iframeDocument.head.appendChild = function <T extends Node>(node: T): T {
if (node instanceof HTMLStyleElement || node instanceof HTMLLinkElement) {
shadowRoot.appendChild(node);
return node;
}
return originalAppendChild(node);
};
}
/** 在沙箱中执行代码 */
execScript(code: string): void {
const scriptElement = this.iframeWindow.document.createElement('script');
scriptElement.textContent = code;
this.iframeWindow.document.head.appendChild(scriptElement);
}
destroy(): void {
this.iframe.parentNode?.removeChild(this.iframe);
this.shadowRoot.host.parentNode?.removeChild(this.shadowRoot.host);
}
}
4.5 with + eval 简易沙箱
with 语句可以改变作用域链,配合 eval 或 new Function 可以实现最简单的沙箱。虽然隔离能力有限,但有助于理解沙箱的基本原理。
function createSimpleSandbox(
code: string,
context: Record<string, unknown> = {}
): void {
// 使用 Proxy 拦截所有属性访问
const sandbox = new Proxy(context, {
has(_target: Record<string, unknown>, _key: string | symbol): boolean {
// 让 with 语句拦截所有变量查找
// 返回 true 意味着所有变量都在 sandbox 中查找
return true;
},
get(
target: Record<string, unknown>,
key: string | symbol
): unknown {
// 防止通过 Symbol.unscopables 逃逸
if (key === Symbol.unscopables) return undefined;
// 阻止访问真实 window
if (key === 'window' || key === 'globalThis') return target;
return target[key as string];
},
set(
target: Record<string, unknown>,
key: string | symbol,
value: unknown
): boolean {
target[key as string] = value;
return true;
},
});
// 使用 with + new Function 执行代码
const fn = new Function('sandbox', `with(sandbox) { ${code} }`);
fn(sandbox);
}
// 使用示例
const ctx: Record<string, unknown> = { console };
createSimpleSandbox(`
var x = 100;
console.log("sandbox x:", x);
`, ctx);
// x 只存在于沙箱中
console.log('global x:', (window as Record<string, unknown>)['x']); // undefined
console.log('sandbox x:', ctx['x']); // 100
- 严格模式不可用:
with语句在严格模式下被禁止 - 性能差:
with会影响 JS 引擎的优化 - 逃逸风险:可以通过原型链、隐式类型转换等方式逃逸
- 仅适合简单场景:如低代码平台的表达式计算、模板引擎等
五、CSS 隔离方案
方案对比
| 方案 | 隔离强度 | 性能 | 兼容性 | 侵入性 | 适用场景 |
|---|---|---|---|---|---|
| Shadow DOM | 最强(双向隔离) | 好 | 现代浏览器 | 低 | 微前端、Web Components |
| Scoped CSS | 强(单向隔离) | 好 | 全平台 | 中 | qiankun experimentalStyleIsolation |
| CSS Modules | 中(构建时隔离) | 最好 | 全平台 | 高 | 组件级隔离 |
| 命名空间 | 弱(约定隔离) | 最好 | 全平台 | 高 | BEM、CSS-in-JS |
5.1 Shadow DOM 隔离
Shadow DOM 提供了浏览器原生的样式隔离能力,外部样式无法穿透 Shadow Boundary,内部样式也不会泄露到外部。
class ShadowDOMIsolation {
private shadowRoot: ShadowRoot;
private hostElement: HTMLElement;
constructor(container: HTMLElement, appName: string) {
// 创建 Shadow DOM 宿主元素
this.hostElement = document.createElement('div');
this.hostElement.setAttribute('data-sandbox', appName);
container.appendChild(this.hostElement);
// 创建 Shadow Root
this.shadowRoot = this.hostElement.attachShadow({ mode: 'open' });
}
/** 将子应用的 HTML 注入 Shadow DOM */
mount(html: string, styles: string[]): void {
// 注入样式
styles.forEach((css) => {
const styleEl = document.createElement('style');
styleEl.textContent = css;
this.shadowRoot.appendChild(styleEl);
});
// 注入 HTML
const wrapper = document.createElement('div');
wrapper.innerHTML = html;
this.shadowRoot.appendChild(wrapper);
}
/** 获取 Shadow Root(供子应用操作 DOM) */
getRoot(): ShadowRoot {
return this.shadowRoot;
}
/** 卸载 */
unmount(): void {
this.shadowRoot.innerHTML = '';
}
destroy(): void {
this.hostElement.parentNode?.removeChild(this.hostElement);
}
}
- 弹窗逃逸:Ant Design 的 Modal、Popover 等组件默认挂载到
document.body,脱离 Shadow DOM 后样式丢失。需要通过getPopupContainer指定挂载点 - 全局样式失效:字体、主题 CSS 变量等全局样式无法穿透 Shadow DOM,需要手动注入
- 事件 retarget:Shadow DOM 内部事件冒泡到外部时,
event.target会被重定向为 Host Element
5.2 Scoped CSS
Scoped CSS 通过在运行时为子应用的所有 CSS 选择器添加属性限定来实现隔离。这是 qiankun 的 experimentalStyleIsolation 方案。
class ScopedCSS {
/**
* 为 CSS 规则添加作用域前缀
* 例如: .btn { color: red } => div[data-qiankun="app"] .btn { color: red }
*/
static process(css: string, appName: string): string {
const prefix = `div[data-qiankun="${appName}"]`;
return css.replace(
// 匹配 CSS 规则的选择器部分
/([^{}]+)\{/g,
(match: string, selector: string): string => {
// 跳过 @media、@keyframes 等 at-rules
if (selector.trim().startsWith('@')) {
return match;
}
// 处理多个选择器(逗号分隔)
const scopedSelector = selector
.split(',')
.map((s: string) => {
const trimmed = s.trim();
// 跳过空选择器
if (!trimmed) return s;
// body、html 等根选择器替换为前缀
if (trimmed === 'body' || trimmed === 'html') {
return ` ${prefix}`;
}
// 普通选择器添加前缀
return ` ${prefix} ${trimmed}`;
})
.join(',');
return `${scopedSelector} {`;
}
);
}
}
// 示例
const originalCSS = `
.header { background: #fff; }
.nav, .footer { padding: 10px; }
body { margin: 0; }
`;
const scopedCSS = ScopedCSS.process(originalCSS, 'my-app');
// 输出:
// div[data-qiankun="my-app"] .header { background: #fff; }
// div[data-qiankun="my-app"] .nav, div[data-qiankun="my-app"] .footer { padding: 10px; }
// div[data-qiankun="my-app"] { margin: 0; }
5.3 CSS Modules 与命名空间
- CSS Modules
- 命名空间
CSS Modules 在构建时自动为类名生成唯一哈希,从源头避免命名冲突:
/* 编译前 */
.button { color: blue; }
.icon { margin-right: 8px; }
/* 编译后 */
/* .button_abc123 { color: blue; } */
/* .icon_def456 { margin-right: 8px; } */
import styles from './Button.module.css';
function Button(): JSX.Element {
// styles.button => "button_abc123"
return <button className={styles.button}>Click</button>;
}
通过约定命名前缀来隔离样式,实现简单但依赖开发者自觉遵守:
/* 子应用 A 的所有样式都加上前缀 */
.app-a__header { background: #fff; }
.app-a__nav { padding: 10px; }
.app-a__btn--primary { color: blue; }
/* 子应用 B 使用不同前缀 */
.app-b__header { background: #000; }
.app-b__nav { padding: 20px; }
.app-b__btn--primary { color: green; }
六、DOM 隔离与全局变量代理
6.1 DOM API 劫持
除了 Shadow DOM,还可以通过劫持 document 上的 DOM 操作 API 来限制子应用的 DOM 访问范围:
class DOMIsolation {
private appContainer: HTMLElement;
private originalMethods: Map<string, Function> = new Map();
constructor(container: HTMLElement) {
this.appContainer = container;
}
/** 激活 DOM 隔离,劫持 document 方法 */
activate(): void {
const container = this.appContainer;
// 劫持 document.querySelector
this.originalMethods.set(
'querySelector',
document.querySelector.bind(document)
);
document.querySelector = function (selector: string): Element | null {
return container.querySelector(selector);
} as typeof document.querySelector;
// 劫持 document.querySelectorAll
this.originalMethods.set(
'querySelectorAll',
document.querySelectorAll.bind(document)
);
document.querySelectorAll = function (
selector: string
): NodeListOf<Element> {
return container.querySelectorAll(selector);
} as typeof document.querySelectorAll;
// 劫持 document.getElementById
this.originalMethods.set(
'getElementById',
document.getElementById.bind(document)
);
document.getElementById = function (id: string): HTMLElement | null {
return container.querySelector(`#${id}`);
} as typeof document.getElementById;
// 劫持 document.head/body 的 appendChild
this.patchAppendChild(document.head, container);
this.patchAppendChild(document.body, container);
}
/** 劫持 appendChild,将样式和脚本追加到子应用容器中 */
private patchAppendChild(
target: HTMLElement,
container: HTMLElement
): void {
const originalAppendChild = target.appendChild.bind(target);
this.originalMethods.set(
`${target.tagName}_appendChild`,
originalAppendChild
);
target.appendChild = function <T extends Node>(node: T): T {
// 样式元素追加到子应用容器
if (
node instanceof HTMLStyleElement ||
node instanceof HTMLLinkElement
) {
container.appendChild(node);
return node;
}
return originalAppendChild(node);
};
}
/** 恢复原始方法 */
deactivate(): void {
this.originalMethods.forEach((method, key) => {
if (key === 'querySelector') {
document.querySelector = method as typeof document.querySelector;
} else if (key === 'querySelectorAll') {
document.querySelectorAll = method as typeof document.querySelectorAll;
} else if (key === 'getElementById') {
document.getElementById = method as typeof document.getElementById;
}
// ... 其他方法类似恢复
});
this.originalMethods.clear();
}
}
6.2 全局变量代理(window/document/location)
完整的沙箱需要代理 window 上的关键全局对象,特别是 document 和 location:
interface SandboxContext {
container: HTMLElement;
baseUrl: string;
}
function createGlobalProxy(
rawWindow: Window,
fakeWindow: Map<string | symbol, unknown>,
context: SandboxContext
): WindowProxy {
return new Proxy(rawWindow, {
get(target: Window, key: string | symbol): unknown {
// 代理 document —— 限制 DOM 操作范围
if (key === 'document') {
return createDocumentProxy(
document,
context.container
);
}
// 代理 location —— 隔离路由
if (key === 'location') {
return createLocationProxy(
window.location,
context.baseUrl
);
}
// 代理 history
if (key === 'history') {
return window.history;
}
// 优先从 fakeWindow 读取
if (fakeWindow.has(key)) {
return fakeWindow.get(key);
}
const value = (target as Record<string | symbol, unknown>)[key];
if (typeof value === 'function') {
return value.bind(target);
}
return value;
},
set(
_target: Window,
key: string | symbol,
value: unknown
): boolean {
fakeWindow.set(key, value);
return true;
},
});
}
/** 创建 document 代理 */
function createDocumentProxy(
rawDocument: Document,
container: HTMLElement
): Document {
return new Proxy(rawDocument, {
get(target: Document, key: string | symbol): unknown {
// 将查询操作限制在容器内
if (key === 'querySelector') {
return (selector: string) => container.querySelector(selector);
}
if (key === 'querySelectorAll') {
return (selector: string) => container.querySelectorAll(selector);
}
if (key === 'getElementById') {
return (id: string) => container.querySelector(`#${id}`);
}
if (key === 'body') {
return container;
}
const value = (target as Record<string | symbol, unknown>)[key];
if (typeof value === 'function') {
return value.bind(target);
}
return value;
},
}) as Document;
}
/** 创建 location 代理 */
function createLocationProxy(
rawLocation: Location,
baseUrl: string
): Location {
return new Proxy(rawLocation, {
get(target: Location, key: string | symbol): unknown {
if (key === 'href') {
return `${baseUrl}${target.pathname}${target.search}${target.hash}`;
}
const value = (target as Record<string | symbol, unknown>)[key];
if (typeof value === 'function') {
return value.bind(target);
}
return value;
},
}) as Location;
}
七、副作用清理
子应用运行期间会产生各种副作用(定时器、事件监听、DOM 变更、网络请求),如果卸载时不清理,会导致内存泄漏和运行异常。
7.1 副作用收集与清理系统
class SideEffectCleaner {
private timers: Set<ReturnType<typeof setTimeout>> = new Set();
private intervals: Set<ReturnType<typeof setInterval>> = new Set();
private rafIds: Set<number> = new Set();
private eventListeners: Array<{
target: EventTarget;
type: string;
handler: EventListenerOrEventListenerObject;
options?: boolean | AddEventListenerOptions;
}> = [];
private mutationObservers: Set<MutationObserver> = new Set();
private abortControllers: Set<AbortController> = new Set();
private originalMethods: Map<string, Function> = new Map();
/** 激活副作用收集 */
activate(sandboxWindow: Record<string, unknown>): void {
this.patchTimers(sandboxWindow);
this.patchEventListeners(sandboxWindow);
this.patchRAF(sandboxWindow);
this.patchFetch(sandboxWindow);
this.patchMutationObserver(sandboxWindow);
}
/** 劫持定时器 */
private patchTimers(sandboxWindow: Record<string, unknown>): void {
const timers = this.timers;
const intervals = this.intervals;
// 劫持 setTimeout
const rawSetTimeout = window.setTimeout.bind(window);
sandboxWindow.setTimeout = function (
handler: TimerHandler,
timeout?: number,
...args: unknown[]
): ReturnType<typeof setTimeout> {
const id = rawSetTimeout(handler, timeout, ...args);
timers.add(id);
return id;
};
// 劫持 clearTimeout
const rawClearTimeout = window.clearTimeout.bind(window);
sandboxWindow.clearTimeout = function (
id: ReturnType<typeof setTimeout>
): void {
timers.delete(id);
rawClearTimeout(id);
};
// 劫持 setInterval
const rawSetInterval = window.setInterval.bind(window);
sandboxWindow.setInterval = function (
handler: TimerHandler,
timeout?: number,
...args: unknown[]
): ReturnType<typeof setInterval> {
const id = rawSetInterval(handler, timeout, ...args);
intervals.add(id);
return id;
};
// 劫持 clearInterval
const rawClearInterval = window.clearInterval.bind(window);
sandboxWindow.clearInterval = function (
id: ReturnType<typeof setInterval>
): void {
intervals.delete(id);
rawClearInterval(id);
};
}
/** 劫持事件监听器 */
private patchEventListeners(
sandboxWindow: Record<string, unknown>
): void {
const listeners = this.eventListeners;
// 劫持 addEventListener
const rawAddEventListener = window.addEventListener.bind(window);
sandboxWindow.addEventListener = function (
type: string,
handler: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions
): void {
listeners.push({ target: window, type, handler, options });
rawAddEventListener(type, handler, options);
};
// 同样劫持 document.addEventListener
const rawDocAddEventListener =
document.addEventListener.bind(document);
const originalDocAddEventListener = document.addEventListener;
document.addEventListener = function (
type: string,
handler: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions
): void {
listeners.push({ target: document, type, handler, options });
rawDocAddEventListener(type, handler, options);
};
this.originalMethods.set(
'document.addEventListener',
originalDocAddEventListener
);
}
/** 劫持 requestAnimationFrame */
private patchRAF(sandboxWindow: Record<string, unknown>): void {
const rafIds = this.rafIds;
const rawRAF = window.requestAnimationFrame.bind(window);
sandboxWindow.requestAnimationFrame = function (
callback: FrameRequestCallback
): number {
const id = rawRAF(callback);
rafIds.add(id);
return id;
};
const rawCancelRAF = window.cancelAnimationFrame.bind(window);
sandboxWindow.cancelAnimationFrame = function (id: number): void {
rafIds.delete(id);
rawCancelRAF(id);
};
}
/** 劫持 fetch,支持自动取消 */
private patchFetch(sandboxWindow: Record<string, unknown>): void {
const controllers = this.abortControllers;
const rawFetch = window.fetch.bind(window);
sandboxWindow.fetch = function (
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
const controller = new AbortController();
controllers.add(controller);
return rawFetch(input, {
...init,
signal: init?.signal ?? controller.signal,
}).finally(() => {
controllers.delete(controller);
});
};
}
/** 劫持 MutationObserver */
private patchMutationObserver(
sandboxWindow: Record<string, unknown>
): void {
const observers = this.mutationObservers;
const RawMutationObserver = window.MutationObserver;
sandboxWindow.MutationObserver = class extends RawMutationObserver {
constructor(callback: MutationCallback) {
super(callback);
observers.add(this);
}
};
}
/** 清理所有副作用 */
cleanup(): void {
// 清理定时器
this.timers.forEach((id) => clearTimeout(id));
this.timers.clear();
// 清理 interval
this.intervals.forEach((id) => clearInterval(id));
this.intervals.clear();
// 清理 requestAnimationFrame
this.rafIds.forEach((id) => cancelAnimationFrame(id));
this.rafIds.clear();
// 移除所有事件监听器
this.eventListeners.forEach(({ target, type, handler, options }) => {
target.removeEventListener(type, handler, options);
});
this.eventListeners.length = 0;
// 断开所有 MutationObserver
this.mutationObservers.forEach((observer) => observer.disconnect());
this.mutationObservers.clear();
// 取消所有未完成的网络请求
this.abortControllers.forEach((controller) => controller.abort());
this.abortControllers.clear();
// 恢复被劫持的原始方法
this.originalMethods.forEach((method, key) => {
if (key === 'document.addEventListener') {
document.addEventListener = method as typeof document.addEventListener;
}
});
this.originalMethods.clear();
console.log('所有副作用已清理');
}
}
副作用清理流程
八、完整沙箱容器
将以上模块组合成一个完整的沙箱容器:
interface SandboxOptions {
name: string;
container: HTMLElement;
/** 沙箱类型 */
sandboxType?: 'proxy' | 'snapshot' | 'iframe';
/** 是否启用 CSS 隔离 */
cssIsolation?: 'shadow' | 'scoped' | 'none';
/** 是否启用副作用收集 */
sideEffectCleanup?: boolean;
}
class SandboxContainer {
private name: string;
private jsSandbox: ProxySandbox | SnapshotSandbox | IframeSandbox;
private cssIsolation: ShadowDOMIsolation | null = null;
private sideEffectCleaner: SideEffectCleaner | null = null;
private isActive: boolean = false;
constructor(private options: SandboxOptions) {
this.name = options.name;
// 1. 初始化 JS 沙箱
switch (options.sandboxType ?? 'proxy') {
case 'proxy':
this.jsSandbox = new ProxySandbox(this.name);
break;
case 'snapshot':
this.jsSandbox = new SnapshotSandbox(this.name);
break;
case 'iframe':
this.jsSandbox = new IframeSandbox(this.name);
break;
}
// 2. 初始化 CSS 隔离
if (options.cssIsolation === 'shadow') {
this.cssIsolation = new ShadowDOMIsolation(
options.container,
this.name
);
}
// 3. 初始化副作用收集器
if (options.sideEffectCleanup !== false) {
this.sideEffectCleaner = new SideEffectCleaner();
}
}
/** 激活沙箱 */
activate(): void {
this.jsSandbox.activate();
if (this.sideEffectCleaner && 'proxy' in this.jsSandbox) {
this.sideEffectCleaner.activate(
this.jsSandbox.proxy as unknown as Record<string, unknown>
);
}
this.isActive = true;
console.log(`[Sandbox:${this.name}] activated`);
}
/** 失活沙箱 */
deactivate(): void {
this.jsSandbox.deactivate();
this.sideEffectCleaner?.cleanup();
this.isActive = false;
console.log(`[Sandbox:${this.name}] deactivated`);
}
/** 销毁沙箱 */
destroy(): void {
this.deactivate();
this.jsSandbox.destroy?.();
this.cssIsolation?.destroy();
console.log(`[Sandbox:${this.name}] destroyed`);
}
/** 获取沙箱代理对象 */
getProxy(): WindowProxy | Window {
if ('proxy' in this.jsSandbox) {
return this.jsSandbox.proxy;
}
if ('sandboxWindow' in this.jsSandbox) {
return this.jsSandbox.sandboxWindow;
}
return window;
}
}
九、性能优化
9.1 Proxy 性能优化
class OptimizedProxySandbox {
public proxy: WindowProxy;
private fakeWindow: Record<string | symbol, unknown> = Object.create(null);
private propertiesWhiteList: Set<string>;
constructor(name: string) {
// 白名单属性直接透传,减少 Proxy 拦截开销
this.propertiesWhiteList = new Set([
'console',
'performance',
'navigator',
'undefined',
'NaN',
'Infinity',
'Array',
'Object',
'String',
'Number',
'Boolean',
'Symbol',
'Map',
'Set',
'WeakMap',
'WeakSet',
'Promise',
'Proxy',
'Reflect',
'JSON',
'Math',
'Date',
'RegExp',
'Error',
'TypeError',
'RangeError',
'parseInt',
'parseFloat',
'isNaN',
'isFinite',
'encodeURIComponent',
'decodeURIComponent',
'encodeURI',
'decodeURI',
'atob',
'btoa',
]);
const rawWindow = window;
const fakeWindow = this.fakeWindow;
const whiteList = this.propertiesWhiteList;
this.proxy = new Proxy(rawWindow, {
get(target: Window, key: string | symbol): unknown {
// 白名单属性直接返回,不经过 fakeWindow
if (typeof key === 'string' && whiteList.has(key)) {
return (target as Record<string, unknown>)[key];
}
if (key === 'window' || key === 'self' || key === 'globalThis') {
return proxy;
}
if (key in fakeWindow) {
return fakeWindow[key];
}
const value = (target as Record<string | symbol, unknown>)[key];
if (typeof value === 'function') {
// 缓存 bind 结果,避免重复 bind
const boundFn = value.bind(target);
fakeWindow[key] = boundFn;
return boundFn;
}
return value;
},
set(
_target: Window,
key: string | symbol,
value: unknown
): boolean {
fakeWindow[key] = value;
return true;
},
});
const proxy = this.proxy;
}
}
9.2 性能对比
| 优化项 | 优化前 | 优化后 | 说明 |
|---|---|---|---|
| Proxy get 拦截 | 每次查找经过 fakeWindow | 白名单直接透传 | 减少 ~60% 拦截次数 |
| 函数 bind | 每次 get 都重新 bind | 缓存 bind 结果 | 避免重复创建函数 |
| fakeWindow 存储 | 使用 Map | 使用 Object.create(null) | 避免原型链查找 |
| 快照沙箱遍历 | 遍历所有 window 属性 | 使用 Proxy 记录变更 | 避免全量 diff |
十、微前端沙箱方案对比
10.1 主流框架方案对比
| 框架 | JS 隔离 | CSS 隔离 | DOM 隔离 | 多实例 | 通信 | 特点 |
|---|---|---|---|---|---|---|
| qiankun | Proxy 沙箱 / 快照沙箱 | Shadow DOM / Scoped CSS | 无(可选 Shadow DOM) | 支持 | GlobalState / Props | 成熟稳定,生态完善 |
| wujie | iframe 原生隔离 | Shadow DOM | Shadow DOM | 支持 | Props / EventBus / window.parent | 隔离最强,兼容性好 |
| micro-app | Proxy 沙箱 | Shadow DOM / Scoped CSS | Shadow DOM | 支持 | data / EventCenter | 类 WebComponent,接入简单 |
| garfish | Proxy 沙箱 / 快照沙箱 | CSS Module / Scope | 无 | 支持 | EventEmitter / Shared | 字节出品,性能优化 |
| Module Federation | 无(同域共享) | 无(需自行处理) | 无 | 不适用 | 共享模块 | 模块级共享,非沙箱隔离 |
10.2 选型建议
十一、ShadowRealm 提案
ShadowRealm 是 TC39 Stage 3 提案,提供了原生的代码隔离能力,是未来前端沙箱的理想方案。
// ShadowRealm 提案语法(Stage 3,尚未普及)
// 创建一个隔离的执行环境
const realm = new ShadowRealm();
// 在隔离环境中执行代码
const result = realm.evaluate('1 + 2');
console.log(result); // 3
// 导入模块到隔离环境
const doSomething = await realm.importValue(
'./my-module.js',
'doSomething'
);
doSomething(); // 在隔离环境中执行
ShadowRealm vs 现有方案
| 特性 | ShadowRealm | Proxy 沙箱 | iframe 沙箱 |
|---|---|---|---|
| 隔离级别 | 独立全局对象 | 代理拦截 | 独立浏览上下文 |
| DOM 访问 | 无 DOM API | 可代理 | 独立 document |
| 性能 | 最好(原生支持) | 好(Proxy 开销) | 一般(iframe 开销) |
| 同步通信 | 支持 | 支持 | 不支持(跨域时) |
| 模块支持 | 原生 ESM import | 需自行处理 | 独立加载 |
| 浏览器支持 | 尚未普及 | 现代浏览器 | 全平台 |
ShadowRealm 适合纯 JS 逻辑隔离(如低代码平台的公式计算、插件系统),但它没有 DOM API,无法直接用于微前端的子应用渲染。微前端场景仍需要配合 Shadow DOM 或 iframe 实现完整隔离。
常见面试问题
Q1: Proxy 沙箱怎么实现?核心原理是什么?
答案:
Proxy 沙箱的核心原理是利用 ES6 Proxy 拦截子应用对 window 的读写操作:
- 创建 fakeWindow:为每个子应用创建一个隔离的变量存储空间(
Map或空对象) - 拦截 set:所有写操作写入 fakeWindow,不污染真实 window
- 拦截 get:优先从 fakeWindow 读取,不存在时从真实 window 读取
- 拦截 has:让
in操作符在两个空间中查找 - 防止逃逸:访问
window、self、globalThis时返回 proxy 自身
// 核心实现(简化版)
class ProxySandbox {
private fakeWindow = new Map<string | symbol, unknown>();
public proxy: WindowProxy;
constructor() {
const fakeWindow = this.fakeWindow;
this.proxy = new Proxy(window, {
// 写操作 -> fakeWindow
set: (_target, key, value) => {
fakeWindow.set(key, value);
return true;
},
// 读操作 -> fakeWindow 优先,fallback 到 rawWindow
get: (target, key) => {
if (key === 'window' || key === 'self') return this.proxy;
if (fakeWindow.has(key)) return fakeWindow.get(key);
const value = (target as Record<string | symbol, unknown>)[key];
return typeof value === 'function' ? value.bind(target) : value;
},
});
}
}
// 多实例互不影响
const sandbox1 = new ProxySandbox();
const sandbox2 = new ProxySandbox();
(sandbox1.proxy as Record<string, unknown>).name = 'app1';
(sandbox2.proxy as Record<string, unknown>).name = 'app2';
// 互不干扰,真实 window 未被修改
关键注意点:
- 函数 bind:从 rawWindow 读取的函数需要
bind(rawWindow),否则函数内部的this指向 proxy 会导致错误 - 不可配置属性:Proxy 规范要求
get返回的不可配置属性值必须与原对象一致,需要特殊处理 - Symbol.unscopables:需要阻止该属性,防止
with语句逃逸
Q2: 快照沙箱 vs Proxy 沙箱,各有什么优劣?
答案:
| 对比维度 | 快照沙箱 (SnapshotSandbox) | Proxy 沙箱 (ProxySandbox) |
|---|---|---|
| 实现原理 | 激活时快照 window,卸载时 diff 还原 | Proxy 拦截读写,操作 fakeWindow |
| 多实例 | 不支持(直接操作 window) | 支持(每个实例有独立 fakeWindow) |
| 性能 | 差(每次需全量遍历 window) | 好(按需拦截,无全量遍历) |
| 隔离完整性 | 弱(for...in 遗漏不可枚举属性) | 强(所有读写都经过 Proxy) |
| 兼容性 | IE11+(不依赖 Proxy) | 现代浏览器(依赖 Proxy) |
| 运行时开销 | 无额外开销(直接操作 window) | 有 Proxy 拦截开销(可忽略不计) |
| 应用场景 | IE 兼容场景、降级方案 | 主流方案、多实例并行 |
选择建议:
- 不需要 IE 支持 -> 优先使用 ProxySandbox
- 需要 IE 11 支持 -> 降级使用 SnapshotSandbox
- 需要多个子应用同时运行 -> 只能使用 ProxySandbox
Q3: CSS 隔离方案对比,各方案的优缺点?
答案:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| Shadow DOM | 浏览器原生隔离,样式不穿透 Shadow Boundary | 隔离最彻底、无运行时开销 | 弹窗逃逸、全局样式无法穿透、部分库不兼容 |
| Scoped CSS | 运行时为选择器添加属性限定前缀 | 兼容性好、弹窗样式正常 | 运行时处理有性能开销、选择器权重提升 |
| CSS Modules | 构建时生成唯一类名哈希 | 零运行时开销、与框架无关 | 需要构建工具支持、无法隔离全局样式 |
| BEM/命名空间 | 约定命名规范避免冲突 | 最简单、无工具依赖 | 完全依赖人为约定、无法隔离第三方库样式 |
实际项目中的最佳实践:
// 方案一:Shadow DOM + 全局样式注入(推荐)
function mountWithShadowDOM(
container: HTMLElement,
appHtml: string,
globalCSS: string
): ShadowRoot {
const shadow = container.attachShadow({ mode: 'open' });
// 注入全局基础样式(字体、CSS 变量等)
const globalStyle = document.createElement('style');
globalStyle.textContent = globalCSS;
shadow.appendChild(globalStyle);
const wrapper = document.createElement('div');
wrapper.innerHTML = appHtml;
shadow.appendChild(wrapper);
return shadow;
}
// 方案二:Scoped CSS(兼容性更好)
function applyScopedCSS(css: string, appId: string): string {
return css.replace(
/([^{}]+)\{/g,
(match, selector: string) => {
if (selector.trim().startsWith('@')) return match;
return `[data-app="${appId}"] ${selector.trim()} {`;
}
);
}
Q4: 如何清理副作用?子应用卸载时需要处理哪些内容?
答案:
子应用运行时会产生以下副作用,卸载时必须全部清理:
| 副作用类型 | 收集方式 | 清理方式 |
|---|---|---|
| setTimeout / setInterval | 劫持 API,记录 id | clearTimeout / clearInterval |
| addEventListener | 劫持 API,记录 target + type + handler | removeEventListener |
| requestAnimationFrame | 劫持 API,记录 id | cancelAnimationFrame |
| MutationObserver | 劫持构造函数,记录实例 | observer.disconnect() |
| fetch / XMLHttpRequest | 注入 AbortController | controller.abort() |
| DOM 变更 | 记录插入的 DOM 节点 | 移除插入的节点 |
| 全局变量 | Proxy 拦截记录 | 清空 fakeWindow |
核心实现思路是 AOP(面向切面编程) -- 通过劫持原生 API,在调用时自动收集副作用引用,在卸载时统一清理:
// 副作用收集的核心模式:劫持 -> 记录 -> 清理
class SideEffects {
private timers = new Set<ReturnType<typeof setTimeout>>();
private listeners: Array<[EventTarget, string, EventListener]> = [];
patch(): void {
// 1. 劫持 setTimeout,记录 timerId
const raw = window.setTimeout;
window.setTimeout = ((handler: TimerHandler, ms?: number) => {
const id = raw(handler, ms);
this.timers.add(id);
return id;
}) as typeof setTimeout;
// 2. 劫持 addEventListener,记录监听器
const rawAdd = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (
type: string,
handler: EventListener
) {
sideEffects.listeners.push([this, type, handler]);
rawAdd.call(this, type, handler);
};
}
cleanup(): void {
this.timers.forEach((id) => clearTimeout(id));
this.listeners.forEach(([target, type, handler]) => {
target.removeEventListener(type, handler);
});
}
}
const sideEffects = new SideEffects();
回答副作用清理问题时,先列出需要清理的副作用类型,再阐述"劫持 API -> 收集引用 -> 统一清理"的通用模式,最后可以补充 wujie 等框架利用 iframe 天然隔离副作用的思路。