跳到主要内容

设计前端沙箱隔离系统

问题

如何设计一个完整的前端沙箱隔离系统?从 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)的修改。以下是主流方案的全面对比:

方案实现原理多实例性能兼容性隔离强度典型应用
SnapshotSandboxwindow 快照 + diff不支持一般IE11+qiankun 降级
LegacySandbox单实例 Proxy不支持不支持 IEqiankun 单实例
ProxySandbox多实例 Proxy + fakeWindow支持不支持 IEqiankun 默认
IframeSandbox浏览器原生隔离支持一般全平台最强wujie
ShadowRealmTC39 提案,原生隔离支持最好未普及未来方案
with + evalwith 改变作用域链支持全平台最弱简易沙箱

四、核心模块设计

4.1 快照沙箱(SnapshotSandbox)

快照沙箱通过在子应用激活时拍摄 window 快照,在卸载时diff 还原来实现隔离。它是 Proxy 不可用时的降级方案。

sandbox/snapshot-sandbox.ts
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。相比快照沙箱,它避免了全量遍历,性能更好。

sandbox/legacy-sandbox.ts
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 vs SnapshotSandbox

LegacySandbox 通过 Proxy 实时记录变更,避免了 activate/deactivate 时的全量 diff,性能更好。但因为仍然操作真实 window,所以不支持多实例并行。


4.3 Proxy 多实例沙箱(ProxySandbox)

ProxySandbox 是最核心的沙箱方案,它为每个子应用创建一个 fakeWindow,所有写操作都写入 fakeWindow,读操作优先从 fakeWindow 读取,不存在时再从真实 window 读取。这样多个子应用各自操作自己的 fakeWindow,互不干扰。

sandbox/proxy-sandbox.ts
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 使用示例
sandbox/proxy-sandbox-usage.ts
// 创建两个独立沙箱
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 实现了兼顾隔离强度和用户体验的方案。

sandbox/iframe-sandbox.ts
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 隔离) 的组合方案:

核心思路:

  1. JS 在 iframe 中执行:天然隔离 window、document、location
  2. DOM 渲染在 WebComponent 的 Shadow DOM 中:CSS 天然隔离
  3. 通过 Proxy 将 iframe 中的 document 操作代理到 Shadow DOM:打通 JS 与 DOM
sandbox/wujie-like-sandbox.ts
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 语句可以改变作用域链,配合 evalnew Function 可以实现最简单的沙箱。虽然隔离能力有限,但有助于理解沙箱的基本原理。

sandbox/with-eval-sandbox.ts
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 + eval 的局限
  • 严格模式不可用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,内部样式也不会泄露到外部。

sandbox/shadow-dom-isolation.ts
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);
}
}
Shadow DOM 的已知问题
  1. 弹窗逃逸:Ant Design 的 Modal、Popover 等组件默认挂载到 document.body,脱离 Shadow DOM 后样式丢失。需要通过 getPopupContainer 指定挂载点
  2. 全局样式失效:字体、主题 CSS 变量等全局样式无法穿透 Shadow DOM,需要手动注入
  3. 事件 retarget:Shadow DOM 内部事件冒泡到外部时,event.target 会被重定向为 Host Element

5.2 Scoped CSS

Scoped CSS 通过在运行时为子应用的所有 CSS 选择器添加属性限定来实现隔离。这是 qiankun 的 experimentalStyleIsolation 方案。

sandbox/scoped-css.ts
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 在构建时自动为类名生成唯一哈希,从源头避免命名冲突:

components/Button.module.css
/* 编译前 */
.button { color: blue; }
.icon { margin-right: 8px; }

/* 编译后 */
/* .button_abc123 { color: blue; } */
/* .icon_def456 { margin-right: 8px; } */
components/Button.tsx
import styles from './Button.module.css';

function Button(): JSX.Element {
// styles.button => "button_abc123"
return <button className={styles.button}>Click</button>;
}

六、DOM 隔离与全局变量代理

6.1 DOM API 劫持

除了 Shadow DOM,还可以通过劫持 document 上的 DOM 操作 API 来限制子应用的 DOM 访问范围:

sandbox/dom-isolation.ts
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 上的关键全局对象,特别是 documentlocation

sandbox/global-proxy.ts
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 副作用收集与清理系统

sandbox/side-effect-cleaner.ts
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('所有副作用已清理');
}
}

副作用清理流程


八、完整沙箱容器

将以上模块组合成一个完整的沙箱容器:

sandbox/sandbox-container.ts
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 性能优化

sandbox/optimized-proxy.ts
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 隔离多实例通信特点
qiankunProxy 沙箱 / 快照沙箱Shadow DOM / Scoped CSS无(可选 Shadow DOM)支持GlobalState / Props成熟稳定,生态完善
wujieiframe 原生隔离Shadow DOMShadow DOM支持Props / EventBus / window.parent隔离最强,兼容性好
micro-appProxy 沙箱Shadow DOM / Scoped CSSShadow DOM支持data / EventCenter类 WebComponent,接入简单
garfishProxy 沙箱 / 快照沙箱CSS Module / Scope支持EventEmitter / Shared字节出品,性能优化
Module Federation无(同域共享)无(需自行处理)不适用共享模块模块级共享,非沙箱隔离

10.2 选型建议


十一、ShadowRealm 提案

ShadowRealm 是 TC39 Stage 3 提案,提供了原生的代码隔离能力,是未来前端沙箱的理想方案。

sandbox/shadow-realm-example.ts
// 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 现有方案

特性ShadowRealmProxy 沙箱iframe 沙箱
隔离级别独立全局对象代理拦截独立浏览上下文
DOM 访问无 DOM API可代理独立 document
性能最好(原生支持)好(Proxy 开销)一般(iframe 开销)
同步通信支持支持不支持(跨域时)
模块支持原生 ESM import需自行处理独立加载
浏览器支持尚未普及现代浏览器全平台
ShadowRealm 的定位

ShadowRealm 适合纯 JS 逻辑隔离(如低代码平台的公式计算、插件系统),但它没有 DOM API,无法直接用于微前端的子应用渲染。微前端场景仍需要配合 Shadow DOM 或 iframe 实现完整隔离。


常见面试问题

Q1: Proxy 沙箱怎么实现?核心原理是什么?

答案

Proxy 沙箱的核心原理是利用 ES6 Proxy 拦截子应用对 window 的读写操作:

  1. 创建 fakeWindow:为每个子应用创建一个隔离的变量存储空间(Map 或空对象)
  2. 拦截 set:所有写操作写入 fakeWindow,不污染真实 window
  3. 拦截 get:优先从 fakeWindow 读取,不存在时从真实 window 读取
  4. 拦截 has:让 in 操作符在两个空间中查找
  5. 防止逃逸:访问 windowselfglobalThis 时返回 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/命名空间约定命名规范避免冲突最简单、无工具依赖完全依赖人为约定、无法隔离第三方库样式

实际项目中的最佳实践:

css-isolation-best-practice.ts
// 方案一: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,记录 idclearTimeout / clearInterval
addEventListener劫持 API,记录 target + type + handlerremoveEventListener
requestAnimationFrame劫持 API,记录 idcancelAnimationFrame
MutationObserver劫持构造函数,记录实例observer.disconnect()
fetch / XMLHttpRequest注入 AbortControllercontroller.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 天然隔离副作用的思路。


相关链接