安装
npm install @tuvix.js/sandbox
# or pnpm add @tuvix.js/sandbox何时使用
当微应用的 CSS 和页面的 CSS 必须互不影响时,使用 CssSandbox。它把应用的 DOM 移入容器上的一个 open shadow root,让应用的选择器留在内部,页面的选择器留在外部。用 sandbox: true 注册应用时,@tuvix.js/core 中的编排器会替你完成这一步。不通过编排器挂载应用时,自己调用这个包。
当你运行第三方脚本文本,并希望它写入的全局变量不落到真实的 window 上时,使用 JsSandbox。你通过 execScript 运行代码,它会通过一个 Proxy 解析 window、this 和自由标识符。编排器不会应用它。
如果需要对不可信代码做严格隔离,请选择别的方案。带 sandbox 属性的 <iframe> 会给代码独立的全局对象和源;这个包不会。
快速开始
通过编排器(仅 CSS):
import { createOrchestrator } from '@tuvix.js/core';
const orchestrator = createOrchestrator();
orchestrator.register({
name: 'cart',
entry: { scripts: ['/apps/cart/main.js'], styles: ['/apps/cart/style.css'] },
container: '#cart',
activeWhen: '/cart/*',
sandbox: true,
});
await orchestrator.start();编排器把 #cart 包裹进一个 shadow root,把应用渲染到其中的 <div data-tuvix-sandbox-root="cart">,并把 style.css 加载到 shadow root 而不是 <head>。挂载会等待这些样式表加载,最长等待加载器的 timeout(默认 10000 ms)。应用再次挂载时,编排器会复用这个 shadow root。
手动使用:
import { createSandbox } from '@tuvix.js/sandbox';
const container = document.getElementById('cart')!;
const sandbox = createSandbox({ css: true, js: true, strict: true, allowedGlobals: ['cartState'] });
const shadowRoot = sandbox.activate(container);
const target = shadowRoot ?? container;
const adopted = sandbox.css.adoptStyles(shadowRoot!, { urls: ['/apps/cart/style.css'] });
await adopted.ready;
target.append(Object.assign(document.createElement('p'), { textContent: 'Cart' }));
sandbox.js.execScript('window.cartState = { items: 0 }');
console.log('cartState' in window); // false
sandbox.deactivate(container); // children back in the light DOM, styles set aside
sandbox.activate(container); // same shadow root, styles restored
sandbox.destroy(container);API
createSandbox
import type { ISandbox, SandboxOptions } from '@tuvix.js/sandbox';
declare function createSandbox(options?: SandboxOptions): ISandbox;返回 new Sandbox(options)。
| 选项 | 类型 | 默认值 | 含义 |
|---|---|---|---|
css | boolean | true | activate 把容器包裹进 shadow root。设为 false 时,activate 返回 null,不附加任何内容。 |
js | boolean | true | activate/deactivate 切换 sandbox.js.active。无论如何,sandbox.js.proxyWindow 上的代理都会让写入不落到真实的 window 上。 |
allowedGlobals | string[] | [] | strict 模式下允许沙箱代码赋值的额外全局名称。对它们的写入仍落在代理上。 |
strict | boolean | false | 沙箱代码赋值或定义内置允许列表和 allowedGlobals 之外的全局变量时,抛出 TypeError。 |
Sandbox
import { Sandbox } from '@tuvix.js/sandbox';
const sandbox = new Sandbox({ css: false, js: true });
console.log(sandbox.activate(document.body)); // nullcreateSandbox 背后的类。它实现了 ISandbox:
| 成员 | 签名 | 行为 |
|---|---|---|
css | readonly ICssSandbox | 它使用的 CssSandbox 实例。 |
js | readonly IJsSandbox | 它使用的 JsSandbox 实例。 |
activate | (container: HTMLElement) => ShadowRoot | null | css 开启时调用 css.wrap(container),然后在 js 开启时调用 js.activate()。返回 shadow root 或 null。 |
deactivate | (container: HTMLElement) => void | 先调用 js.deactivate(),再调用 css.unwrap(container)。 |
destroy | (container: HTMLElement) => void | 先调用 deactivate(container),再调用 js.reset(),后者清除所有已存储的全局变量。 |
CssSandbox
import { CssSandbox } from '@tuvix.js/sandbox';
const cssSandbox = new CssSandbox();
const container = document.querySelector<HTMLElement>('#widget')!;
const shadowRoot = cssSandbox.wrap(container);
const style = cssSandbox.addStyle(shadowRoot, ':host { display: block; } p { color: teal; }');
cssSandbox.unwrap(container); // the <style> leaves the DOM and waits for the next wrap
cssSandbox.removeStyle(shadowRoot, style); // removed for good: the next wrap does not restore it| 方法 | 签名 | 行为 |
|---|---|---|
wrap | (container: HTMLElement) => ShadowRoot | 把容器的子节点移入一个 open shadow root 并返回它。 |
unwrap | (container: HTMLElement) => void | 把子节点移回 light DOM,把 shadow root 的样式表暂存起来,并在 shadow root 中留下一个 <slot>。 |
addStyle | (shadowRoot: ShadowRoot, css: string) => HTMLStyleElement | 在 shadow root 末尾追加一个包含 css 的 <style>。 |
removeStyle | (shadowRoot: ShadowRoot, styleEl: HTMLStyleElement) => void | 移除 styleEl。如果宿主元素处于 unwrap 状态,还会把 styleEl 从暂存列表中删除,让下一次 wrap 不再恢复它。 |
adoptStyles | (shadowRoot: ShadowRoot, styles: AdoptStylesOptions) => AdoptedStyles | 在 shadow root 开头插入 <link> 和 <style> 元素。 |
wrap 与重新挂载
DOM 元素一旦附加了 shadow root 就无法再移除。wrap 这样处理:
- 如果这个
CssSandbox实例已经 wrap 了该容器且尚未 unwrap,wrap返回同一个 shadow root,不移动任何节点。 - 如果容器已有一个 open
shadowRoot(来自之前的wrap、unwrap或另一个CssSandbox实例),wrap会移除unwrap留下的<slot>,放回unwrap暂存的样式表(放在开头,保持原有顺序),并复用这个 root。 - 否则调用
container.attachShadow({ mode: 'open' })。
随后它把所有 light DOM 子节点移入 shadow root。wrap 只在 attachShadow 失败时抛出异常,例如元素无法承载 shadow root(<img>、<input>),或已经有一个 closed root:[Tuvix Sandbox] Cannot attach Shadow DOM to <img>: ...。
unwrap 与 slot
unwrap 把 shadow root 的子节点移回容器。它把 <style> 和 <link rel="stylesheet"> 节点从 DOM 中取出,无论它们来自 addStyle、adoptStyles 还是应用本身,这样应用的 CSS 不再生效,也不会影响到页面。它把这些节点和 shadow root 保存在一起。shadow root 本身保留,并包含一个 <slot data-tuvix-sandbox-slot>,浏览器通过它渲染恢复的子节点。对于不是由这个实例 wrap 的容器,unwrap 什么也不做。
之后对该宿主元素的下一次 wrap,无论来自这个还是其他 CssSandbox 实例,都会移除 slot,按顺序放回暂存的样式表,并把子节点移回。重新挂载后你不需要再次采纳样式。在宿主元素处于 unwrap 状态时调用 removeStyle(shadowRoot, el) 或 adopted.remove(),会永久移除这些样式表。
adoptStyles
import { CssSandbox } from '@tuvix.js/sandbox';
const cssSandbox = new CssSandbox();
const shadowRoot = cssSandbox.wrap(document.querySelector<HTMLElement>('#cart')!);
const adopted = cssSandbox.adoptStyles(shadowRoot, {
urls: ['/apps/cart/style.css', '/shared/tokens.css'],
cssText: ':host { display: block; }',
});
await adopted.ready; // every <link> fired load or error
console.log(adopted.elements.length); // 3
adopted.remove();| 选项 | 类型 | 默认值 | 含义 |
|---|---|---|---|
urls | readonly string[] | [] | 每个 URL 按顺序生成一个 <link rel="stylesheet" data-tuvix-style>。 |
cssText | string | readonly string[] | 无 | 每个字符串生成一个 <style data-tuvix-style>,放在 link 之后。 |
行为:
- 这些元素放在 shadow root 开头、之前采纳的样式之后,因此应用内容和
addStyle的样式在层叠顺序中排在后面。 - 已采纳到这个 shadow root 的 URL 会被跳过,也不会出现在
elements中。这包括unwrap暂存的 link。如果已有的 link 由更早的调用创建,ready仍会等待它。 - 每个 link 触发
load或error后,ready就会解析。它从不拒绝,所以返回 404 的样式表不会阻塞你。jsdom 不会触发这两个事件,所以不要在 jsdom 测试中 awaitready。 remove()只移除本次调用插入的元素,包括已暂存的元素,因此下一次wrap不会恢复它们。
JsSandbox
import { JsSandbox } from '@tuvix.js/sandbox';
const jsSandbox = new JsSandbox(['analyticsQueue'], true);
jsSandbox.activate();
jsSandbox.execScript('window.analyticsQueue = []'); // allowed, stored on the proxy
console.log('analyticsQueue' in window); // false
console.log(Reflect.get(jsSandbox.proxyWindow, 'analyticsQueue')); // []
try {
jsSandbox.execScript('window.tracker = {}');
} catch (error) {
console.log(error instanceof TypeError); // true
}
jsSandbox.reset();构造函数:new JsSandbox(allowedGlobals: string[] = [], strict = false)。
| 成员 | 签名 | 行为 |
|---|---|---|
proxyWindow | readonly WindowProxy | 真实 window 之上的 Proxy。 |
active | readonly boolean | 由 activate 设置、由 deactivate/reset 清除的标志。 |
activate | () => void | 把 active 设为 true。 |
deactivate | () => void | 把 active 设为 false。已存储的全局变量保留。 |
execScript | (code: string) => unknown | 通过 new Function 在 with (scope) 中以非严格模式(sloppy mode)脚本运行 code。window、self、globalThis 和顶层 this 都是代理,自由标识符通过它解析。eval("...") 在该作用域内仍是直接 eval。返回代码的返回值。 |
reset | () => void | 清除所有已存储的全局变量,并把 active 设为 false。 |
代理的行为:
- 读取时先检查沙箱自己的存储,再回退到真实的
window。真实 window 上没有prototype的函数(fetch、setTimeout)返回时会绑定到真实的window,每个函数只绑定一次,因此proxyWindow.fetch === proxyWindow.fetch。 proxyWindow.window、.self、.globalThis返回代理本身;页面不在 iframe 中时,.top/.parent/.frames也返回代理本身。在 iframe 中,top和parent保持为真实对象。- 赋值和
Object.defineProperty把值存储在代理上。真实的window永远不变。不带value的描述符({ enumerable: false })会保留已存储的值。访问器描述符(get/set)和configurable: false会让Object.defineProperty抛出TypeError。 delete proxyWindow.x删除已存储的值。对于真实 window 的属性,它返回false,这在严格模式代码中会抛出TypeError。in同时检查存储和真实的window。
strict 模式(strict: true):对内置允许列表和 allowedGlobals 之外的名称进行赋值(window.x = 1 或裸的 x = 1)、顶层 var x = 1 或调用 Object.defineProperty,会抛出 TypeError: [Tuvix Sandbox] Strict mode blocked write to global "tracker". Add it to allowedGlobals to permit it.,且不存储任何值。无论沙箱是否处于激活状态,都会执行这项检查。Symbol 键始终被视为禁止写入。内置允许列表涵盖标准全局变量,如 console、定时器、Promise、Map、JSON、fetch、URL、navigator、document、location 和 history。允许的写入,包括对这些名称的写入,都保留在代理上。
类型
| 类型 | 含义 |
|---|---|
SandboxOptions | { css?, js?, allowedGlobals?, strict? },即 createSandbox 的选项。 |
ISandbox | Sandbox 实现的接口:css、js、activate、deactivate、destroy。 |
ICssSandbox | CssSandbox 实现的接口。 |
IJsSandbox | JsSandbox 实现的接口。 |
AdoptStylesOptions | adoptStyles 的 { urls?, cssText? }。 |
AdoptedStyles | adoptStyles 返回的 { elements, ready, remove }。 |
边界情况
- 在
execScript中,x = 1、顶层var x = 1、window.x = 1、window.window.x = 1、top.x = 1,以及eval("...")中的同样语句,都把x存储在代理上,从不存储在真实的window上。let和const仍是脚本的局部变量。 - 在
execScript中读取未声明的标识符会返回undefined,而不是抛出ReferenceError;code中的"use strict"指令不起作用,因为with需要非严格模式。 - 在
f()这样的普通函数调用中,this是真实的全局对象,这是非严格模式 JavaScript 的定义。在这种函数中执行this.x = 1会写入真实的window。在通过execScript运行的代码中,请写window.x = 1。 execScript使用new Function,因此不包含'unsafe-eval'的内容安全策略(CSP)会阻止它。- 构造
JsSandbox、Sandbox或调用createSandbox()会读取window,因此在服务端会抛出ReferenceError。CssSandbox只在你调用它的方法时才需要document。 - 两个
CssSandbox实例可以共享同一个宿主元素。第二次wrap会复用第一个实例的 shadow root。 - 容器处于 wrap 状态时你添加的 light DOM 子节点留在 shadow root 之外,且没有 slot 渲染它们,所以不会显示。请改为追加到 shadow root 中。
Sandbox({ css: false }).activate()返回null。请渲染到容器中。
它不做什么
- 它不会修补定时器、事件监听器、
document或<script>标签。不经过proxyWindow的代码会影响真实页面。 - 它不会清理应用遗留的 interval 或监听器。请在应用的
unmount中清除它们。 - 它不会给代码独立的源或全局对象。对不可信代码请使用
<iframe sandbox>。 - 它不会分离 shadow root。DOM 没有提供这样的 API;
unwrap改为留下一个 slot。 - 它不会阻止继承。在宿主元素或其祖先上设置的
color、font-family和 CSS 自定义属性仍会传入 shadow root。
从 0.4 迁移
Sandbox({ css: false }).activate(container)返回null,且不附加 shadow root。在 0.4 中它会附加一个,并隐藏容器的子节点。ISandbox.activate现在返回ShadowRoot | null:tsimport { createSandbox } from '@tuvix.js/sandbox'; const container = document.querySelector<HTMLElement>('#app')!; const sandbox = createSandbox({ css: false }); // 0.4: const root: ShadowRoot = sandbox.activate(container); const root = sandbox.activate(container) ?? container;strict: true在写入被阻止时抛出TypeError。在 0.4 中它只记录警告并存储该值。请把脚本设置的名称添加到allowedGlobals。execScript在with块中运行代码。代码不能是严格模式代码,读取未声明的标识符会返回undefined而不是抛出异常,裸的x = 1现在落在代理上,而不是真实的window上。unwrap保留 shadow root 的样式表,下一次wrap会恢复它们。在 0.4 中unwrap会删除它们。请删除在重新挂载后再次添加cssText或addStyle样式的代码,否则会出现重复。0.4 文档称
js: true会拦截addEventListener、定时器和document.createElement。它从未这样做过,0.5 也不会。