在网页里运行用户代码,Web Worker 还不够
目录
给代码块加一个“运行”按钮,先拿这行代码试一下:
while (true) {}如果页面跟着卡住,连“停止”都点不了,这个按钮还不能交给读者用。
Galley 的文章支持运行 JavaScript 示例。下面就拿它的实现来拆:代码放在哪儿执行,时间到了谁负责叫停,哪些浏览器能力可以交给它。开头这段只展示源码,文末有放进沙箱后可以实际运行的版本。
一个 setTimeout 救不了死循环#
很容易写出这样一个“超时保护”:
// 错误示范:这段不会在两秒后自行结束
const timer = setTimeout(() => {
throw new Error("运行超时");
}, 2000);
new Function("while (true) {}")();
clearTimeout(timer);问题出在执行顺序。同一线程里的任务会运行到完成,后面的回调才有机会执行。循环没结束,定时器的回调就轮不到;如果这段代码跑在页面主线程,点击“停止”的事件也在等。
把代码搬到 Worker,页面才有机会继续处理操作。主线程可以调用 worker.terminate() 结束它,不用等循环自己退出。这个调用会直接终止 Worker,所以收尾工作要由外面的页面来做,不能指望 Worker 里的 finally 再执行一遍。
但如果到这里就把 Worker 当成了完整的安全沙箱,还漏了一件事。
Worker 里仍然有 fetch#
在浏览器原生 Worker 中,下面两行的结果不同:
console.log(typeof document); // "undefined"
console.log(typeof fetch); // "function"Worker 不能直接操作 DOM,但仍然可以发网络请求。网络请求照样受浏览器的相关策略约束;“代码被放进后台线程了”,却没有自动变成“代码只能算一个结果”。
如果只是执行自己写的计算任务,这些能力可能正好有用。可博客的需求窄得多:读者改一段算法,看看输出。网络请求并不是完成这件事所必需的。
Galley 的处理方式是,在 Worker 里再运行一个 QuickJS 解释器:
这里的 quickjs-emscripten 是编译成 WebAssembly 的 QuickJS。它默认不向用户代码开放宿主功能。Worker 自己有 fetch,不代表里面的 QuickJS 上下文也有。需要什么接口,由外面的程序明确提供。
连文章里用的 console,都是 Galley 给它装进去的。同样,如果将来提供一个“帮我请求任意 URL”的宿主函数,网络能力也就跟着放进去了。选了哪个解释器之后,仍然得看自己往里面传了什么。
两秒到了,由解释器检查#
QuickJS 自己也要执行那段循环。仅仅多包一层解释器,循环仍然不会结束。
QuickJS 会在执行过程中周期性调用中断回调。宿主可以在这个回调里检查截止时间,要求解释器停止。这条检查发生在解释器执行代码的过程中,和刚才那个一直排不上队的 setTimeout 是两回事。
为看清这部分,把项目里的运行器缩成下面这个函数。参数 QuickJS 是 Worker 内已经加载好的 Wasm 模块;这个简版只返回同步表达式的结果,不注入 console,也不处理定时器和 Promise 回调。
import type { QuickJSWASMModule } from "quickjs-emscripten-core";
// 在 Worker 内调用;QuickJS 是已经加载好的 Wasm 模块。
// 这个简版只处理同步表达式,不注入 console、网络或定时器。
function runExpression(QuickJS: QuickJSWASMModule, code: string) {
const runtime = QuickJS.newRuntime();
runtime.setMemoryLimit(32 * 1024 * 1024);
runtime.setMaxStackSize(512 * 1024);
const deadline = performance.now() + 2000;
runtime.setInterruptHandler(() => performance.now() >= deadline);
const vm = runtime.newContext();
try {
const result = vm.evalCode(code, "example.js");
const handle = result.error ?? result.value;
try {
return result.error
? { ok: false, error: vm.dump(handle) }
: { ok: true, value: vm.dump(handle) };
} finally {
handle.dispose();
}
} finally {
vm.dispose();
runtime.dispose();
}
}返回值和错误值的句柄都需要显式 dispose,然后再释放 context 和 runtime。这也是代码里留了两层 finally 的原因。
Galley 复用 Wasm 模块,但每次执行新建 runtime 和 context。上一段代码改过的全局变量,不会留给下一段继续用。
项目当前还在主线程设了另一道期限。两秒由解释器尝试中断,四秒仍未返回就终止 Worker。四秒的计时从 Worker 发来“准备好了,开始执行”的消息以后开始,前面的 Wasm 下载和初始化不算进去。
这些数字是这个博客给短示例定的预算。后台标签页的调度会影响主线程定时器,不能把“四秒”理解成浏览器一定会在那个精确时刻终止执行。
32 MiB 限制的是 QuickJS 运行时的内存分配,也不能拿它当整个页面的内存上限。值一旦通过 vm.dump() 变成宿主对象,还得考虑后面的输出量。
结果也可能把页面撑满#
用户不写死循环,写一万次 console.log,一样能制造麻烦。解释器跑完了,页面还要接收和展示那些输出。
Galley 最多保存 200 行、累计长度不超过 20000 的输出,超出后标记截断。这里的长度按 JavaScript 的 string.length 算,不是网络传输的字节数。这个限制管的是准备交给页面的日志量。
停止后的状态也要收拾。当前几个代码块共用一个 Worker,终止会结束其中未完成的请求,下次运行再创建一个。如果要改成互不影响的并行运行,这个共享方式就需要一起调整。
回到那个运行按钮#
下面三个例子用的就是 Galley 的运行器。先跑一段正常代码,确认结果能回来:
console.log([10, 2, 7].sort((a, b) => a - b));输出应该是 [ 2, 7, 10 ]。再看它拿得到哪些宿主接口:
console.log("fetch:", typeof fetch);
console.log("document:", typeof document);
console.log("localStorage:", typeof localStorage);在这个沙箱里,三行都是 undefined。这和前面原生 Worker 里的结果不同,正好能看到多加一层解释器以后,能力边界落在了哪里。
现在再试开头那段代码:
while (true) {}本次用项目运行器验证,约两秒后收到了超时错误,符合沙箱设置的两秒中断预算。页面仍由主线程处理操作;运行期间也可以点“停止”。
这几个例子只用到了同步 JavaScript。Galley 提供的定时器是模拟的,不按真实时间等待,因此不要拿这里的 setTimeout 去测浏览器计时精度。
完整实现分在三个文件里:主线程管理请求和停止,Worker加载解释器,沙箱提供允许使用的接口、执行代码并收集输出。上面的死循环结束后,可以再运行一次排序,看看下一次执行是否还能正常返回。