scriptc:将TypeScript编译为零运行时的原生可执行文件
Vercel Labs的scriptc将普通TypeScript编译成小巧、快速的原生二进制文件,无需Node或V8。了解其工作原理、可编译内容以及与Go和Rust的对比。
scriptc:将TypeScript编译为零运行时的原生可执行文件
Vercel Labs 开源了 scriptc,一个 TypeScript 到原生的编译器,可将普通 TypeScript 转换为独立的原生可执行文件——最终二进制文件中无需 Node.js、V8 或任何 JavaScript 引擎。结果如何?二进制文件启动时间约 2 毫秒,占用 170–200KB 空间,内存消耗仅 1–4MB。
问题:TypeScript 的运行时开销
TypeScript 开发者喜爱该语言的类型安全性和工具链,但部署 Node.js 应用意味着要捆绑一个庞大的运行时。即使使用 Node.js 单可执行应用程序(SEA),二进制文件也可能膨胀到 60–100MB。在现代硬件上启动时间约为 47 毫秒,内存使用通常超过 100MB RSS。
scriptc 采取了不同的方法:它不提供 JavaScript 引擎,而是通过 LLVM 或 C 后端直接将 TypeScript 编译为原生代码。结果是一个行为与 Node.js 逐字节一致但无额外开销的二进制文件。
工作原理
scriptc 采用三层编译模型:
- 静态编译(默认)—— 可直接编译为原生代码的 TypeScript 结构被直接编译,无需引擎参与。
- 动态运行(
--dynamic)—— 嵌入式 QuickJS-ng 引擎(约 620KB)执行无法静态编译的代码,例如 npm 依赖项附带的 JavaScript 或any类型的代码。跨越回静态代码的值会在运行时进行验证。 - 拒绝—— 其他所有情况都会失败,并给出特定的错误代码、代码帧以及通常的重写提示。不会静默地错误编译任何内容。
$ scriptc coverage app.ts
statements analyzed 4481
compile statically 4451 (99%)
blockers:
×2 functions with optional parameters as values SC1090
×1 Promise.reject SC2020
可编译为原生代码的内容
scriptc 的静态编译覆盖了实际程序使用的语言特性和标准库:
语言特性
- 支持单继承和真正动态分派的类(在可证明安全时去虚拟化)
- 具有 JavaScript 捕获语义的闭包
- 泛型(单态化)
- 由 TypeScript 自身窄化驱动的标记值判别联合
- 基于栈式纤程的
async/await,具有与 JavaScript 精确的调度 - 带
finally的异常 - 解构、展开、可选/默认/剩余参数
- getter/setter
- 字符串、数组、Map、Set 上的迭代器
- 模板字面量
- 正则表达式(使用与 QuickJS 相同的 ECMAScript 精确字节码解释器)
标准库
- 具有 UTF-16 精确语义的字符串
- 具有 JavaScript 精确顺序和同一性的数组、Map、Set
- 带运行时验证转换的 JSON
Math、类型化数组、Buffer- 带类型化 catch 的错误层次结构
Node.js API 接口
fs(同步和 Promise)path(逐字节精确移植)process、带管道流的child_processos、crypto、url/URL、zlib- 基于无依赖事件循环的定时器和信号处理器
- 服务器栈:
net、http、https、tls(内置 mbedTLS)、dgram、dns、fs.watch、readline
Web API
fetch和 WHATWG 网络子集(流、Headers、AbortSignal),基于相同的原生网络/TLS 栈- 重定向、gzip、
AbortSignal.timeout、Node 形状的错误原因 - 无 libcurl,无系统 HTTP 依赖
npm 依赖项(使用 --dynamic)
- 包使用 Node 自身的算法解析
- 根据其附带的
.d.ts进行类型检查 - JavaScript 在构建时嵌入到二进制文件中
- 二进制文件在运行时从不读取
node_modules
性能对比
在 Apple M 系列上针对逐字节相同的工作负载进行测量:
| 维度 | scriptc | 上下文 |
|---|---|---|
| 启动时间 | ~2.4ms | Node:~47ms;与 Zig 相当,领先于 Go/Rust |
| 二进制大小 | 静态 170–200KB,--dynamic 约 3MB |
Go:约 2MB;Node SEA:60–100MB |
| 内存(RSS) | 典型 1–4MB | Node:67–116MB |
| 运行时 | 与 JS 一致的 f64 语义;与系统语言竞争 | 整数推断和所有权分析已在路线图中 |
正确性保证
scriptc 对每次更改运行两种强制机制:
差异测试—— 每个语料库程序(800+ 测试)在 Node 和原生二进制文件下运行;stdout、stderr 和退出码必须逐字节匹配。数字格式与 JS 精确一致(最短往返,在百万个双精度浮点数上通过模糊测试与 Node 验证)。服务器使用实时客户端驱动程序针对两种实现进行测试。
内存安全通道—— 整个语料库在 AddressSanitizer 下重新运行,并进行引用计数审计;内存泄漏和释放后使用被视为构建失败。
逃生舱口
scriptc 提供了多个逃生舱口,用于需要突破静态模型的情况:
comptime(() => ...)—— 在构建时(在编译器内的隔离 VM 中)运行 TypeScript,并将结果作为字面量烘焙到二进制文件中。- 原生 FFI(
--ffi)—— 将仅签名的 TypeScript 声明绑定到直接的 C ABI 调用,并链接清单声明的存档、对象和系统库。 --dynamic—— 为 npm 依赖项和任何代码嵌入引擎。scriptc coverage --dynamic精确报告哪些语句在何处运行。- 检查转换——
JSON.parse(...) as Config插入一个运行时验证,如果失败则抛出一个可捕获的错误,并指出有问题的路径。
入门指南
# 安装
npm install -g scriptc
# 直接运行 TypeScript 文件
$ cat fib.ts
function fib(n: number): number {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
console.log(fib(30));
$ scriptc run fib.ts
832040
# 构建原生二进制文件
$ scriptc build fib.ts && ls -la fib
-rwxr-xr-x 178K fib # 一个自包含的原生二进制文件,启动时间约 2ms
要求:clang(预装在 Xcode 命令行工具中)。macOS arm64 是主要平台;Linux 和 Windows 二进制文件通过交叉编译构建。
架构
该项目分为三个包:
packages/compiler—— 前端(tsc API → IR)、带验证器/序列化器的 IR,以及 LLVM 和 C 后端。IR 是两端之间的唯一接口;LLVM 是默认代码生成器,C 是参考后端。packages/runtime—— C 运行时:带循环收集器的引用计数值、栈式纤程和事件循环(kqueue)、服务器栈、与 JS 精确的数字格式。功能单元通过链接门控:二进制文件仅为其使用的功能付费。packages/cli——scriptc build | run | coverage。
为何重要
scriptc 代表了我们对 TypeScript 部署方式的重大转变。无需接受 Node.js 运行时开销,您现在可以将 TypeScript 编译为原生二进制文件,这些文件在毫秒内启动,使用最少的内存,并且小到可以作为单个文件分发。对于无服务器函数、CLI 工具和边缘计算,这可能是变革性的。
该项目仍处于早期阶段(撰写时为 v0.0.17),但基础扎实:800+ 差异测试、内存安全验证,以及清晰的未来改进路线图,如整数推断和所有权分析。
如果您正在构建启动时间、内存使用或二进制大小至关重要的 TypeScript 应用程序,scriptc 值得认真考虑。