04 · 沙箱:Membrane、Compartment 与 DOM 补丁
目标与边界
JS 沙箱要解决的是:子应用代码大量假设存在「类似浏览器的全局对象」(window / document / 全局变量污染),在微前端下同页多应用并存时,需要:
- 限制全局读写:默认写到沙箱副本,读时合并宿主与副本;
- 让脚本仍在真实浏览器里执行(非 iframe VM),以便复用 DOM/CSSOM 等能力;
- 卸载时可清理:定时器、监听、动态插入的 DOM 等。
Qiankun 3 采用 Proxy Membrane + Compartment(with(this)) 而非单纯 iframe,换取更轻的实例成本与更直接的 DOM 集成,但这也意味着存在白名单逃逸与原生绑定等工程折中。
Membrane:读写路径
核心类型在 packages/sandbox/src/core/membrane/index.ts:
- 写:落到
membraneTarget,并记录modifications、更新latestSetProp(供 loader 解析入口导出)。 - 读:
endowments优先;否则在membraneTarget与incubatorContext(真实 window)之间选择;对 原生全局 API 做rebind,避免Illegal invocation。
fetch 等必须绑定到 nativeGlobal,注释中举例说明了 Proxy 直出会炸:
/* Some dom api must be bound to native window, otherwise it would cause exception like 'TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation'
See this code:
const proxy = new Proxy(window, {});
const proxyFetch = fetch.bind(proxy);
proxyFetch('https://qiankun.com');
*/
const boundTarget = useNativeWindowForBindingsProps.get(p) ? nativeGlobal : incubatorContext;
return rebindTarget2Fn(boundTarget, value, receiver);latestSetProp:入口导出探测
子应用 UMD/IIFE 常写作 var bundle = ...; window.__QIANKUN_EXPORT__ = bundle。Membrane 在 set 成功时记录最后一次赋值的属性名,Loader 在入口脚本 onload 后读取 sandbox.globalThis[latestSetProp] 作为 loadEntry 的 resolve 值。
依据:比「固定全局名」更通用,但仍依赖 bundle 最后一次赋值就是导出对象 的约定;若入口脚本多次写全局,可能误判(工程上应保证入口尾部的导出赋值)。
白名单逃逸:SystemJS / 开发环境
globalVariableWhiteList 明确列出 System、__cjsWrapper 等,注释指向 SystemJS 使用间接 eval导致作用域落到真实全局的问题:
const globalVariableWhiteList: string[] = [
// FIXME System.js used a indirect call with eval, which would make it scope escape to global
// To make System.js works well, we write it back to global window temporary
// see https://github.com/systemjs/systemjs/blob/457f5b7e8af6bd120a279540477552a07d5de086/src/evaluate.js#L106
'System',
// see https://github.com/systemjs/systemjs/blob/457f5b7e8af6bd120a279540477552a07d5de086/src/instantiate.js#L357
'__cjsWrapper',
...variableWhiteListInDev,
];开发环境额外白名单(如 React overlay、event)也有 issue 依据注释:https://github.com/umijs/qiankun/issues/2375。
要点:沙箱不是数学意义上的「完全隔离」,而是在兼容真实生态(SystemJS、HMR、DOM API)前提下做尽力隔离。
Compartment:with(this) 执行
Compartment.makeEvaluateFactory 生成包装代码,把用户脚本放在 with(this) 中,并 bind 到 window.__compartment_globalThis__* 上保存的 proxy:
makeEvaluateFactory(source: string, sourceURL?: string): string {
const sourceMapURL = sourceURL ? `//# sourceURL=${sourceURL}\n` : '';
const globalObjectOptimizer = this.constantIntrinsicNames.length
? `const {${this.constantIntrinsicNames.join(',')}} = this;`
: '';
// eslint-disable-next-line max-len
return `;(function(){with(this){${globalObjectOptimizer}${source}\n${sourceMapURL}}}).bind(window.${this.id})();`;
}原因:让未声明赋值(如 foo = 1)落到 this(即伪全局)上;同时用 const { ... } = this 优化频繁访问的内置名。
StandardSandbox:window/self/globalThis 闭环
StandardSandbox 构造函数里对 window/self/globalThis 做 getter,全部指向 realmGlobal(Proxy),防止 window.window 链逃逸到真 window:
const intrinsics: Record<string, PropertyDescriptor> = {
// avoid who using window.window or window.self to escape the sandbox environment to touch the real window
// see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
window: { get: getRealmGlobal, enumerable: true, configurable: false },
self: { get: getRealmGlobal, enumerable: true, configurable: false },
globalThis: { get: getRealmGlobal, enumerable: false, configurable: true },document 初值指向真实 document,随后在 createSandboxContainer mount 阶段由 dynamicAppend patch 替换为容器视角的代理(见 forStandardSandbox.ts)。
dynamicAppend:动态插入重定向
patchDocument 会劫持容器内 head/body 的 appendChild/insertBefore 等,使子应用 document.head.appendChild(style) 实际落到 <qiankun-head> 等容器子树,并记录 elementAttachSandboxConfigMap 以便卸载清理。
文件内 FIXME 指出:全局变量 __currentLockingSandbox__ 可能在运行时容器变化或业务 monkey patch 场景下不够可靠(packages/sandbox/src/patchers/dynamicAppend/forStandardSandbox.ts 约 306 行附近),这是已知技术债。
active / inactive 与 lock
卸载路径会 inactive() → membrane.lock(),此时再对伪全局 set 会 warn 并吞掉严格模式下的 TypeError 风险(见 membrane set trap 尾部注释)。