
一句话看懂
项目地址:github.com/stencil-hq/... 
dquickts 是 QuickJS 的一个分支,跟踪上游 2026-06-04 版本。它在原有的轻量 JS 引擎基础上加了三件事:让正在执行的代码在任意位置暂停,把整个运行时堆序列化成字节流存起来,以及让 .ts 文件不经编译直接运行。项目当前 343 星,当日新增 303 星,仓库创建于今年 10 月中,主语言是 C。
它解决什么问题
普通的 JavaScript 引擎有个硬限制:进程一退出,内存里的变量、调用栈、正在跑的循环全部消失。如果一段脚本要跑很久,或者需要把执行状态从一台机器搬到另一台,常规做法是自己在代码里埋检查点、手动序列化业务数据,引擎本身管不了这件事。
dquickts 把这个能力下沉到引擎层。它提供 JS_RequestSuspend() 这样的接口,可以在执行过程中任意一个安全点把整个调用栈连带局部变量、挂起的 Promise、生成器状态全部冻住,再用 JS_WriteSnapshot() 写成一段字节。这段字节可以存到磁盘,传到网络对端,或者干脆进另一个进程,用 JS_ReadSnapshot() 读回来,执行从暂停的那一条指令继续往下走。README 里的例子显示,一个空运行时的完整快照只有 13KB,一个简单的 REPL 会话快照约 400 字节,体积很小。
另外,想跑 TypeScript 通常得先过一遍 tsc 或 esbuild 之类的工具把类型擦掉再执行。dquickts 把这一步挪到了引擎的解析阶段,.ts 文件读进来的时候类型注解就被直接剥掉,枚举、命名空间、装饰器这些语法也能处理,不需要额外的构建步骤。
核心概念速览
Durable execution(可持续执行) :指程序被暂停后能在别处或稍后继续跑,而不是从头重来。dquickts 用 JS_RequestSuspend()、JS_IsSuspended()、JS_Resume() 这组接口实现,暂停和恢复都发生在引擎内部,业务代码不需要感知。
Incremental snapshot(增量快照) :只存储相对于某个基准发生变化的部分,而不是每次都把整堆内存重新写一遍。项目用 JS_SNAPSHOT_BASE 和 JS_SNAPSHOT_DELTA 两种模式区分基准快照和增量快照。
Durable host call(可持续主机调用) :C 语言写的宿主函数被 JS 代码调用后,可以让调用方一直等着,等很久之后再把结果递回去,中间这段等待也能被快照保存。这是通过 JS_SuspendCall() 和 JS_ResumeWith() 实现的。
Safepoint(安全点):指代码执行过程中可以安全停下来做快照或暂停的位置,不是任意一条机器指令都能停。
架构拆解

整个项目的结构围绕一个核心加两条扩展线。核心是 quickjs.c,这是引擎本身,定义了 JSValue 这种统一的值表示方式以及对象的类系统,所有暂停和快照功能都是在这套底层表示之上做的。
快照功能被拆成三个文件。quickjs-snapshot.c 定义快照的整体格式,负责写入和读取的主流程,是其他两个快照文件的调度者。quickjs-snapshot-data.c 负责具体数据结构的序列化,比如集合、弱引用容器、字节缓冲区怎么变成字节流再变回来。quickjs-snapshot-control.c 负责运行时控制状态的序列化,也就是生成器、Promise、模块这些带执行语义的对象怎么存。调用关系是 quickjs.c 通过 include 引入快照模块,quickjs-snapshot.c 再分别调用 data 和 control 两个文件里的具体负载函数,职责分得比较清楚,数据结构的持久化和执行状态的持久化没有混在一起写。
另一条线是 Rust 绑定,放在 rquickjs/ 目录下,这是对 DelSkayn/rquickjs 项目的移植,版本号标的是 0.14.0。它直接绑定 quickjs.h 里声明的 C API,把暂停和快照这些底层能力包成 Rust 接口暴露出去,这样写 Rust 的人不需要直接跟 C 指针打交道。
关键实现走读
第一段来自 README,展示中断处理器里怎么触发暂停:
c
static int on_interrupt(JSRuntime *rt, void *opaque)
{
JS_RequestSuspend(rt); /* suspend at the next safepoint */
return 0;
}
int main(void)
{
const char *src =
"let sum = 0;\n"
"for (let i = 0; i < 1e6; i++) sum += i;\n"
"print(`sum = ${sum}`);\n"
"sum\n";
JSRuntime *rt = JS_NewRuntime();
JSContext *ctx = JS_NewContext(rt);
JSValue global = JS_GetGlobalObject(ctx), val;
做了什么:on_interrupt 是注册给引擎的中断回调,每到检查点就会被调用一次。它里面只做一件事,调用 JS_RequestSuspend(rt),告诉运行时在下一个安全点暂停,不是立刻停。主函数部分构造了一段简单的循环求和脚本,用来演示暂停会发生在一个正在运行的循环中间。
为什么这样写:暂停请求和真正暂停这两件事分开,是因为引擎不能在任意一条机器指令上说停就停,栈帧、寄存器状态都可能处于不一致的中间态。把"请求"和"执行"拆成两步,引擎可以等到下一个安全点,也就是栈状态干净的时刻,再真正把调用栈搬到堆上。
没有它会怎样:如果暂停是立即生效而不等安全点,运行时的内部状态可能在不完整的时候被冻住,之后恢复执行时会出现栈损坏或者取到未初始化的值,这是比崩溃更难查的那类 bug。
第二段来自 quickjs-snapshot.c,是快照写入时用到的一个哈希表查找函数:
c
static SnapMapEntry *snap_map_find(const SnapMap *m, const void *key)
{
uint32_t i;
if (!m->tab)
return NULL;
for(i = snap_ptr_hash(key) & m->mask; m->tab[i].key; i = (i + 1) & m->mask) {
if (m->tab[i].key == key)
return &m->tab[i];
}
return NULL;
}
做了什么:这是一个开放寻址哈希表的查找逻辑,输入一个内存指针 key,先用 snap_ptr_hash 算哈希再和 m->mask 做位运算定位起始槽位,如果槽位被占用但 key 不匹配就线性探测下一个槽位,直到找到匹配项或者空槽位为止。
为什么这样写:快照要把堆里的对象存成一段线性字节流,但对象之间原本是靠内存指针互相引用的,进程重启之后这些指针全部失效。所以写快照的时候要先给每个已经处理过的指针分配一个序号,SnapMap 就是指针到序号的映射表,后续如果同一个指针再次出现,查这个表就能拿到已经分配的序号,而不是重复写一份数据。
没有它会怎样:少了这张映射表,同一个对象被多处引用时会被重复序列化成多份,不但快照文件变大,读回来的时候这些本该是同一个对象的引用也会变成互相独立的副本,数据结构的共享关系全部丢失。
动手上手

先克隆仓库并编译:
bash
git clone https://github.com/stencil-hq/dquickts
cd dquickts
make # builds qjs, qjsc and libquickjs.a
make 执行完,目录下应该出现 qjs(解释器)、qjsc(编译器)和 libquickjs.a(静态库)三个产物。如果这一步没有报错退出,说明 C 编译链路跑通了。
直接运行一个 TypeScript 文件:
bash
./qjs hello.ts
这一步验证的是类型擦除解析有没有生效,.ts 后缀的文件能不被预处理就直接执行,没有报语法错误就算通过。
跑一个状态能跨重启保留的 REPL:
bash
./qjs --drepl session.snap
在这个 REPL 里定义的变量和函数,退出后重新用同一个命令打开 session.snap,之前定义的内容应该还在。README 给的例子里这类会话文件大小在 400 字节左右,可以用这个数量级判断自己的会话是否正常。
跑内置的暂停与快照测试:
bash
make test-snapshot # builds and runs the suspension and snapshot test hosts
README 给出的参考输出是:
ini
suspended, snapshot = 13185 bytes
sum = 499999500000
第一行说明一个正在跑循环的运行时被成功暂停,快照大小在一万三千字节左右;第二行说明这个快照被读回另一个运行时后,循环继续跑完,算出了正确的求和结果。两行都出现且数字量级相符,就说明暂停和恢复整条链路是通的。
应用场景
长时间运行的脚本可以借助暂停机制中途写盘休眠,之后在新进程里恢复,不需要让进程一直占着资源空转。只能在没有 JS 代码正在 C 栈上运行的时候取快照,不能在任意 C 函数调用的中途截断。
持久化 REPL 适合需要反复调试、积累状态的开发场景,用 qjs --drepl FILE 打开,变量和函数定义会保留到下次打开同一个文件。但这种会话里 std 和 os 两个标准模块不可用,涉及文件操作或系统调用的代码在这个模式下跑不了。
跨进程的耐久主机调用适合那种结果要等很久才能拿到的场景,比如等外部系统响应。JS 代码调用一个 C 函数后,这个调用本身可以被挂起,等 C 侧通过 JS_ResumeWith 把结果递回来再继续。调用方必须直接调用这个特定的 C 函数,如果中间经过别的包装,会抛出 TypeError。
独立分析

增量快照把存储成本压得比较低。JS_SNAPSHOT_DELTA 模式下只记录相对基准发生变化的那些堆单元,而不是每次都把全堆重新写一份,这对需要频繁打快照的场景,比如每隔几秒存一次状态,能明显减少写入量和磁盘占用。
持久化 REPL 的实现思路是依赖一份可复现的基准快照,每次恢复先重建这个 base,再叠加增量部分。这种分层结构把重复不变的部分和真正变化的部分分开存,降低了存储负担,跟增量快照是同一套思路在 REPL 场景里的应用。
跟原版 QuickJS(Bellard 版本)比,dquickts 多出来的是暂停执行、堆快照和原生 TypeScript 这三项,原版引擎没有这些能力,这是分支存在的核心价值。跟 quickjs-ng 比,README 提到 dquickts 从 quickjs-ng 移植了 queueMicrotask()、atob()/btoa() 等若干特性,它不是完全独立发展,而是在吸收社区分支已经做好的那部分改进。
局限与风险
--drepl 会话里 std 和 os 模块不可用,持久化 REPL 本质上是一个受限的沙盒环境,不能拿来做文件读写或者系统调用的调试。
会话快照文件只能被写出它的那个 qjs 构建加载,换一个编译版本的 qjs 去读之前的快照,目前没有看到兼容性保证,跨版本升级的时候需要留意这一点。
另外有几个问题现有资料没有覆盖:快照机制在安全性上的边界,比如读取一个来源不可信的快照文件是否存在风险;不同操作系统或 CPU 架构之间快照文件能不能互相读取;以及引擎在多线程环境下是否线程安全。这些对于打算把它用在生产环境的人来说都是需要自己动手验证的点。
结论卡片

适合需要在 C 或 Rust 项目里嵌入一个轻量 JS 引擎,同时要求暂停恢复或者跨进程持久化能力的开发者,也适合想让 .ts 文件不经编译直接跑起来的场景。如果只是需要一个标准的 JS 引擎,不涉及状态持久化,也不关心原生 TypeScript 解析,这个分支带来的额外复杂度就没有必要。
下一步值得做的,是对照自己的具体持久化需求,看快照机制和 Rust 绑定提供的接口能不能对上,尤其是跨版本兼容性和多线程安全这两块现有资料没讲清楚的地方,最好自己写一组测试去验证。
项目地址:github.com/stencil-hq/...
原文地址:tools.wflynn.cn/aitools/git...
关注公众号「YourTools」,每天推送 GitHub 趋势与开源工具速报:
