scriptc 深度拆解:Vercel 把 TypeScript 编译成 178KB 原生二进制——没有 Node,没有 V8,冷启动 2.4ms
2026 年 7 月 22 日,Vercel Labs 悄悄开了一个仓库:
vercel-labs/scriptc,一句话介绍是 "TypeScript-to-Native Compiler"。八天时间,2497 颗星。但真正让我坐直身体的不是星星数,而是 README 里那个
ls -la的输出:178K。一个fib(30)的 TypeScript 程序,编译出来是 178KB 的自包含原生可执行文件,启动 2 毫秒,二进制里没有 V8,没有 QuickJS,没有任何 JavaScript 引擎。这篇文章我们把它彻底拆开:它凭什么敢说"零运行时",它的三档分级设计解决了什么根本问题,typed IR 到 C/LLVM 的双后端怎么保证语义不跑偏,以及——它到底能不能用在你的生产环境里。
一、先说清楚:这不是又一个 "把 JS 打包成 exe"
过去十年,"让 JavaScript 变成独立可执行文件"这条路上尸横遍野,但绝大多数方案本质上都是同一招:把引擎和你的代码打个包。
- pkg / nexe:把 Node 二进制 + V8 快照 + 你的 JS 塞进一个文件。产物 40MB 起步。
- Node.js SEA(Single Executable Application):官方版的同一思路,产物 60–100MB。
- Bun / Deno compile:把自家运行时(JavaScriptCore / V8)和代码打包。Bun 产物大约 50–90MB,Deno 类似量级。
- Electron 那一挂:不用说了,Chromium 全家桶。
这些方案的共性是:你的 TypeScript 到最后一刻仍然是 JavaScript,仍然需要一个引擎在运行时把它解释/JIT 执行。 二进制体积、内存占用、冷启动时间,全部由那个引擎决定,跟你写了多少业务代码几乎无关。你写一个 hello world 和写一个中型 CLI,产物大小差不了几个 MB。
scriptc 走的是另一条路:它是编译器,不是打包器。
$ 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 启动
这个 178KB 里面装的是:编译出来的机器码 + scriptc 的 C 运行时(引用计数、事件循环、字符串实现等),按需链接。没用到正则?正则引擎不进二进制。没用到 TLS?mbedTLS 不进二进制。这是典型的系统语言链接模型,不是脚本语言打包模型。
这是本质区别,也是理解后面所有设计的前提。
二、核心洞察:「大部分 TypeScript 比生态假设的要静态得多」
scriptc README 里有一句话,我认为是整个项目的立论根基:
Most TypeScript is far more static than the ecosystem assumes.
这句话值得展开。
TypeScript 的类型系统是擦除式的——tsc 把类型检查完就把类型全扔了,产物是纯 JavaScript。这个设计当初是为了"零成本接入 JS 生态",代价是:类型信息里蕴含的巨量优化机会,全部浪费了。
想想你日常写的 TypeScript:
interface User {
id: number;
name: string;
tags: string[];
}
function summarize(users: User[]): string {
return users
.filter(u => u.tags.length > 0)
.map(u => `${u.id}:${u.name}`)
.join(",");
}
这段代码里,没有任何一处需要动态类型。users 是数组,u.id 是 f64,u.name 是字符串,tags 是字符串数组。一个编译器完全可以:
- 把
User布局成固定偏移的结构体,u.id变成[ptr + 0]的一条 load 指令,而不是哈希表查找 - 把
filter/map单态化(monomorphize)成具体类型的循环 - 把
join的字符串拼接预估容量,一次分配
V8 靠 hidden class + inline cache 在运行时猜出这些信息,猜对了走快路径,猜错了 deopt。而 TypeScript 的类型标注在编译期就把答案写在源码里了——只是没人用。
scriptc 干的事情就是:把 tsc 的类型检查结果接过来,当作编译期真值使用。
2.1 「可见的静态性」:三档分级
但现实是残酷的:不是所有 TypeScript 都能静态编译。any、eval、动态 import、npm 包里那些十年前的 CommonJS……总有编译不了的东西。
大多数同类项目在这里选择了两条错路:
- 定义方言(AssemblyScript 路线):只支持 TypeScript 的一个子集,用
i32/u64这样的特殊类型,写出来的东西不是 TypeScript,是"长得像 TypeScript 的另一门语言"。你不能拿现有代码直接跑。 - 静默降级:编译不了的部分偷偷塞进解释器,用户不知道自己的热点函数根本没被编译。等到性能不达标才开始猜到底哪里出了问题。
scriptc 的答案是第三条路:把分级摆到台面上,用命令行工具让你看见。
$ scriptc coverage app.ts
statements analyzed 4481
compile statically 4451 (99%)
blockers:
×2 functions with optional parameters as values SC1090
×1 Promise.reject SC2020
三档,每一档都有明确契约:
| 档位 | 触发条件 | 行为 | 代价 |
|---|---|---|---|
| 静态编译 | 默认档,类型可证明 | 编译成原生代码,二进制里没有引擎 | 无 |
动态执行(--dynamic) | npm 依赖的 JS、any 类型代码 | 内嵌 quickjs-ng(~620KB)执行 | 体积 +620KB,性能降级 |
| 拒绝 | 既不能静态也没开 dynamic | 编译失败,给出错误码 + 代码帧 + 改写提示 | 编译期就挂 |
关键设计:静态是默认,动态必须显式 opt-in。 README 里那句 "a binary never silently grows an engine"(二进制永远不会悄悄长出一个引擎)——这是一个非常强的工程承诺。
对比一下这个设计的价值。假设你在做一个 CLI 工具,性能敏感。用 scriptc:
$ scriptc coverage cli.ts
statements analyzed 1203
compile statically 1150 (95%)
blockers:
×31 npm package 'chalk' ships JS only SC3001
×22 any-typed value from JSON.parse SC1020
你立刻知道:换掉 chalk(或者接受 620KB 的引擎),给 JSON.parse 加类型断言,就能 100% 静态。这是可执行的优化清单,不是玄学调优。
而在 Bun/Deno compile 里,你根本没有这个维度的信息——所有代码都跑在引擎上,没有"静态"这个概念。
2.2 「Checked Cast」:把 as 从谎言变成契约
这是我个人最欣赏的一个设计细节。
TypeScript 的 as 是纯编译期断言,运行时零检查。这意味着下面这段代码是个定时炸弹:
const config = JSON.parse(fs.readFileSync("config.json", "utf8")) as Config;
server.listen(config.port); // config.port 可能是字符串 "8080"
在 Node 里,listen("8080") 可能歪打正着能跑;也可能在深处某个地方炸出一个莫名其妙的错误。类型系统在这里彻底失灵,因为它信任了一个来自外部世界的谎言。
对编译到原生代码的 scriptc 来说,这个谎言更危险:如果它按 number 布局去读一个实际是字符串的槽位,那就是内存越界,是段错误,是安全漏洞。
scriptc 的处理:在 as 的位置插入运行时校验。
Error: expected number at $.port, got string
抛出的是可捕获的错误,带着精确的 JSON 路径。README 里那句话说得很漂亮:
TypeScript's
asis a promise; scriptc verifies it.
(TypeScript 的as是一句承诺;scriptc 负责验证它。)
同样的机制也用在动态边界上:任何从内嵌引擎回到静态代码的值,都会在跨界时校验。一个说谎的类型抛出可捕获的 TypeError,而不是破坏内存。
这是内存安全的第一道防线,也是"编译型 TypeScript"这条路能不能走通的关键——你必须在类型系统的乐观假设和机器码的严苛现实之间架一座桥。
三、架构拆解:tsc → typed IR → C/LLVM → clang
scriptc 的编译流水线,README 里给了一张 mermaid 图:
TypeScript --[tsc: parse + typecheck]--> lowering --> typed IR --> C --[clang]--> native executable
仓库结构对应三个 package:
packages/
├── compiler # 前端(tsc API → IR)+ IR 校验器/序列化器 + LLVM 后端 + C 后端
├── runtime # C 运行时:引用计数 + 循环收集器、stackful fiber、事件循环(kqueue)、服务器栈
└── cli # scriptc build | run | coverage
3.1 前端:直接复用 tsc,不重造轮子
这是一个非常务实的决策。scriptc 不自己写类型检查器,而是直接调用 TypeScript 官方的 Compiler API。
好处巨大:
- 语义 100% 对齐官方。TypeScript 的类型系统有多少边角?条件类型、映射类型、模板字面量类型、
infer、变体标注、控制流收窄……自己实现一遍等于重写一个 tsc,注定跟不上版本。 tsconfig.json直接生效。你的strict、noUncheckedIndexedAccess、paths全部照旧。- 类型收窄(narrowing)直接复用。这一点在下面 discriminated union 的实现上体现得淋漓尽致。
README 明确说了:程序按 TypeScript 真实的 es2025 lib 做类型检查(项目里有 @types/node 的话也一并算上),tsconfig.json 决定检查器严格度。
代价:tsc 慢。TypeScript 7.0 用 Go 重写编译器把类型检查提速了近 10 倍,如果 scriptc 后续能接上 Go 版的 tsc(@typescript/native-preview),整条流水线的前端耗时会有数量级改善。这大概是它路线图上迟早要碰的事。
3.2 中端:typed IR 是唯一接口
The IR is the only interface between the ends.
这句话是架构洁癖,但非常正确。
前端(tsc AST + 类型信息)经过 lowering 变成 typed IR,后端从 IR 出发生成代码。IR 自带 validator(校验器)和 serializer(序列化器),可以用 --emit-ir 把它 dump 出来:
$ pnpm scriptc build x.ts --emit-ir # 保留 .scriptc/x.c 和 x.ir.json
这个设计带来三个直接收益:
- 双后端可以并存。LLVM 是默认代码生成器,C 是"永远的参考后端"。
- 后端可以互相验证。同一份 IR 走两条后端,输出必须一致——这是一个天然的正确性交叉检查。
- 调试可读。C 后端的输出是带源码行标注的可读 C 代码(
--backend c)。当你怀疑编译器出 bug 时,你能直接看它到底生成了什么。
我特别想强调"C 是永远的参考后端"这个承诺。很多编译器项目早期用 C 后端过渡,一旦 LLVM 后端跑起来就把 C 后端砍掉——然后调试体验断崖式下跌。scriptc 明确说 C 后端会一直保留(forever),并且当程序超出 LLVM 后端支持范围时会透明回退到 C 后端。
3.3 后端运行时:引用计数 + 循环收集器
这是整个项目最硬核也最有争议的部分。
JavaScript 的对象模型有两个特点让原生编译异常困难:
- 任意对象图,可以有环(
a.b = b; b.a = a) - 闭包捕获,变量生命周期不跟随作用域
Go 用 tracing GC 解决,代价是 GC 暂停和几 MB 的运行时;Rust 用所有权 + 生命周期解决,代价是要程序员写标注——但 TypeScript 程序员不会写标注。
scriptc 的选择:引用计数(refcount)+ 循环收集器(cycle collector)。
这套组合的工程权衡是:
| 维度 | 引用计数 + 循环收集 | Tracing GC |
|---|---|---|
| 内存占用 | 低(README:1–4MB RSS) | 高(需要堆余量) |
| 暂停时间 | 分摊,无长 STW | 有 GC 暂停 |
| 吞吐 | 略低(每次赋值有计数开销) | 高 |
| 实现复杂度 | 中(环检测是难点) | 高 |
| 二进制体积 | 小 | 大 |
对 CLI 工具、边缘函数、短生命周期进程这类场景,引用计数是明显更优解——你不需要为一个跑 200ms 就退出的进程准备一套 tracing GC。
而为了保证这套东西不出内存安全问题,scriptc 上了狠手:
Memory-safety lane — 整个测试语料库在 AddressSanitizer 下重跑一遍,外加引用计数审计(RC audit);内存泄漏和 use-after-free 直接判定构建失败。
$ SCRIPTC_SAN=1 pnpm test # 同一套语料在 ASan + RC 审计下跑
把"引用计数审计"做成 CI 门禁,这是我在同类项目里没见过的严格程度。
3.4 async/await:stackful fiber + JS 精确调度
这里有个魔鬼细节。
JavaScript 的 async/await 依赖微任务队列(microtask queue),执行顺序有极其精确的规范定义。下面这段代码的输出顺序,能准确说对的人不多:
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
(async () => {
console.log("4");
await null;
console.log("5");
})();
console.log("6");
// Node: 1 4 6 3 5 2
如果编译到原生代码时,用 C 的协程库或者状态机变换来实现 async,调度顺序极容易跑偏。跑偏的结果是:你的代码在 Node 上跑对,编译后跑错,而且是那种间歇性、难复现的错。
scriptc 的实现:stackful fibers(有栈协程)+ JS 精确调度。
有栈协程的好处是它天然保留完整调用栈,await 就是一次栈切换,不需要做 CPS 变换或状态机拆分——因此 try/finally、闭包捕获、深层调用栈里的 await,全部自然工作。代价是每个 fiber 要预留栈空间。
而"JS-exact scheduling"意味着它实现了完整的微任务/宏任务模型。配合下面这条:
Differential testing — 每一个语料程序(800+ 测试)同时在 Node 下运行和作为原生二进制运行;stdout、stderr、退出码必须逐字节一致。
这就是这个项目最有说服力的地方:正确性不是靠嘴说的,是靠差分测试逐字节比对出来的。
甚至连数字格式化都做了:
Number formatting is JS-exact(shortest-roundtrip,在一百万个 double 上对 Node 做过 fuzz 验证)。
浮点数转字符串这件事看着简单,实际上是编译器领域的经典深坑(Ryu、Grisu、Dragon4 算法之争)。JS 的规范要求"最短往返表示"——0.1 + 0.2 必须打印成 0.30000000000000004,一个字符都不能差。scriptc 用一百万个 double 去 fuzz 对拍,这个投入量说明他们非常清楚这里的坑有多深。
四、代码实战:从 hello world 到 HTTP 服务器
4.1 安装与第一个程序
npm install -g scriptc
前置依赖:clang(macOS 上装了 Xcode Command Line Tools 就有)。主平台是 macOS arm64,Linux 和 Windows 通过交叉编译产出,各自有独立的差分测试通道验证。
// hello.ts
const name = process.argv[2] ?? "world";
console.log(`hello, ${name}`);
$ scriptc run hello.ts claw
hello, claw
$ scriptc build hello.ts
$ ./hello claw
hello, claw
$ ls -la hello
-rwxr-xr-x ~180K hello
注意 scriptc run 和 scriptc build 的区别:run 是编译后直接执行(适合开发迭代),build 产出可分发的二进制。
4.2 类、泛型、判别联合:真正的 TypeScript
scriptc 支持的语言子集非常宽,来看一个稍微复杂的例子:
// shapes.ts
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; w: number; h: number }
| { kind: "tri"; base: number; height: number };
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.radius ** 2;
case "rect": return s.w * s.h;
case "tri": return s.base * s.height / 2;
}
}
abstract class Renderer<T> {
abstract render(item: T): string;
renderAll(items: T[]): string {
return items.map(i => this.render(i)).join("\n");
}
}
class ShapeRenderer extends Renderer<Shape> {
render(s: Shape): string {
return `${s.kind.padEnd(8)} area=${area(s).toFixed(3)}`;
}
}
const shapes: Shape[] = [
{ kind: "circle", radius: 2 },
{ kind: "rect", w: 3, h: 4 },
{ kind: "tri", base: 6, height: 5 },
];
console.log(new ShapeRenderer().renderAll(shapes));
这段代码里用到的每一个特性,README 都明确列在静态编译范围内:
- 判别联合(discriminated unions) → 编译成 tagged value,由 TypeScript 自己的类型收窄驱动。这一点很关键:
switch (s.kind)之后s.radius能访问,是因为 tsc 的控制流分析告诉编译器"这个分支里 s 一定是 circle 变体"。scriptc 直接复用这个结论去生成 tag 判断和字段偏移。 - 泛型 → 单态化(monomorphized)。
Renderer<Shape>编译成一个具体类型的实现,没有装箱,没有运行时类型参数。 - 类 + 单继承 + 真正的动态派发 → 有虚表;而当编译器能证明调用点只有一个实现时会去虚化(devirtualize),直接变成静态调用。
- 模板字面量、
padEnd、toFixed→ 标准库,UTF-16 精确语义。
编译一下看覆盖率:
$ scriptc coverage shapes.ts
statements analyzed 28
compile statically 28 (100%)
100% 静态。这就是那句 "no annotations, no dialect" 的实际含义——你不需要为了编译改任何一行代码。
4.3 文件与进程:Node API 直接可用
// wc.ts —— 一个统计代码行数的小工具
import { readdir, readFile, stat } from "node:fs/promises";
import { join, extname } from "node:path";
interface Stat { files: number; lines: number; bytes: number }
async function walk(dir: string, exts: Set<string>, acc: Stat): Promise<void> {
const entries = await readdir(dir);
for (const e of entries) {
if (e === "node_modules" || e === ".git") continue;
const p = join(dir, e);
const st = await stat(p);
if (st.isDirectory()) {
await walk(p, exts, acc);
} else if (exts.has(extname(e))) {
const buf = await readFile(p, "utf8");
acc.files += 1;
acc.bytes += buf.length;
acc.lines += buf.split("\n").length;
}
}
}
const root = process.argv[2] ?? ".";
const acc: Stat = { files: 0, lines: 0, bytes: 0 };
await walk(root, new Set([".ts", ".tsx", ".js"]), acc);
console.log(`${acc.files} files ${acc.lines} lines ${acc.bytes} bytes`);
注意最后那个顶层 await——这是 v0.0.18 才加进来的能力,而且加得非常认真:
顶层
await在整个程序的 ESM 图上编译。 模块求值遵循 Node 24 的依赖排序、一次性 promise 缓存、循环根定位、rejection 优先级,以及未 settle 模块的退出码 13,LLVM 和 C 两个后端都实现了。
"未 settle 模块退出码 13"——这种级别的细节对齐,说明团队是照着 Node 源码和 ECMAScript 规范逐条抠的。
README 列出的 Node API 覆盖面:
fs(sync 和 promises 两套)path(逐字节移植,包括 Windows 路径那堆恶心的边角)process、os、crypto、url/URL、zlibchild_process(带管道流)timers和信号处理器,跑在无外部依赖的事件循环上(macOS 是 kqueue)- 服务器栈:
net、http、https、tls(内置 mbedTLS)、dgram、dns、fs.watch、readline
4.4 HTTP 服务器:真的能编译
这是我最意外的部分。README 原话:"Real proxy servers compile."
// server.ts
import { createServer } from "node:http";
const routes: Record<string, () => object> = {
"/health": () => ({ ok: true, ts: Date.now() }),
"/version": () => ({ name: "scriptc-demo", version: "1.0.0" }),
};
const server = createServer((req, res) => {
const path = (req.url ?? "/").split("?")[0];
const handler = routes[path];
if (!handler) {
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not found", path }));
return;
}
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(handler()));
});
server.listen(8787, () => {
console.log("listening on http://127.0.0.1:8787");
});
$ scriptc build server.ts
$ ls -la server
-rwxr-xr-x ~600K server # 含网络栈的二进制
$ ./server &
$ curl -s localhost:8787/health
{"ok":true,"ts":1785000000000}
一个 HTTP 服务,几百 KB,冷启动 2 毫秒,常驻内存几 MB。这个数字放在 Serverless / 边缘计算场景里意味着什么,做过这块的都懂——冷启动从 47ms 降到 2.4ms,是从"需要预热池"到"不需要预热池"的质变。
而且 fetch 也是原生实现的:
fetch和 WHATWG web 子集(streams、Headers、AbortSignal)跑在同一套原生 net/TLS 栈上——重定向、gzip、AbortSignal.timeout、Node 形状的 error cause;不用 libcurl,不依赖系统 HTTP 库。
const ctl = AbortSignal.timeout(3000);
const r = await fetch("https://api.example.com/data", {
signal: ctl,
headers: { "user-agent": "scriptc/0.0.19" },
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json() as { items: string[] };
console.log(data.items.length);
CHANGELOG v0.0.14 还专门修了一个很有意思的 bug:早期版本的 http/https/TLS 客户端根本不做 DNS 解析,只有字面 IP 能连。修复方式的描述很讲究:
只有 dial(拨号)拿解析后的地址——原始域名留在 request 上,因为那才是 Host 头携带的、SNI 和证书校验看到的东西。
这是写过网络库的人才会关心的细节。
4.5 --dynamic:给 npm 生态留的后门
现实是,你的项目大概率依赖 npm 包,而 npm 包发布的是 JS,不是 TypeScript。
$ scriptc build app.ts --dynamic
开了这个开关之后:
- npm 包用 Node 自己的解析算法定位(不是自造一套)
- 用包自带的
.d.ts做类型检查 - 包的 JS 在构建时嵌入二进制
- 二进制在运行时永远不读
node_modules
最后这一条是杀手锏:产物依然是单文件,可以直接扔到一个空容器里跑。
代价是二进制从 ~180KB 涨到 ~3MB(620KB 引擎 + 嵌入的依赖代码),并且那部分代码走解释执行,性能是引擎级别而不是原生级别。
配套的观测工具:
$ scriptc coverage app.ts --dynamic
它会精确报告哪些语句在静态侧、哪些在动态侧、剩下的 blocker 是什么。这是一份可执行的重构清单:你可以按性能收益排序,逐个把热点路径从动态搬到静态。
CHANGELOG v0.0.13 里还有一个很硬的改进——动态值不再是"黑盒":
持有在
unknown里、来自内嵌引擎的值,现在能响应键读写、调用与方法派发、Object静态方法、??,方式是在引擎里执行这些操作——所以它答出的结果和 Node 完全一致,而不是撞上一堵墙。标量在包装处归一化,复合值按引用保持为引擎值,一个值穿过 checked-dynamic 树再回来,还是原来那个值。
"再回来还是原来那个值"——保持对象同一性(identity),这是很多混合执行方案会翻车的地方。
4.6 comptime 与 FFI:两个逃生舱
comptime——编译期执行
const TABLE = comptime(() => {
const t: number[] = [];
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t.push(c >>> 0);
}
return t;
});
comptime(() => ...) 在编译器内部的隔离 VM 里跑 TypeScript,把结果当作字面量烘焙进二进制。CRC 表、查找表、编译期配置解析、代码生成——这些活都可以用它干,而且用的是 TypeScript,不是宏语言。
对比 Zig 的 comptime、Rust 的 const fn 和过程宏——scriptc 的版本心智负担最低,因为它就是你已经会写的 TypeScript。
FFI——调 C 库
v0.0.15 引入的 --ffi:
// zstd.d.ts —— 只有签名,没有实现
declare function ZSTD_compressBound(srcSize: number): number;
declare function ZSTD_compress(
dst: Uint8Array, dstCapacity: number,
src: Uint8Array, srcSize: number,
level: number,
): number;
$ scriptc build app.ts --ffi ffi.manifest.json
设计上的克制值得注意:
- 只支持数值、布尔、UTF-8 字符串、字节 span 作为参数,标量和 void 作为返回值
- 边界是显式且长度界定的(length-delimited)——不玩裸指针
- 无运行时符号查找,manifest 声明的静态库/目标文件/系统库在链接期就进二进制
- 边界上不引入动态引擎
- 每个绑定在 emit 前全部校验,包括未使用和被遮蔽的声明;缺失符号等链接失败报成有界的
SC5004诊断,而不是编译器内部错误
这是"能用但不让你玩火"的典型设计。你不能通过 FFI 传结构体、回调、裸指针——但你能调 zstd、libsodium、SQLite 这类以标量和字节缓冲为主接口的库,覆盖了绝大多数实际需求。
五、性能数据:把数字放进上下文
README 给的对比表(Apple M 系列芯片,同样的 workload,Node / Go / Rust / Zig 横向对比,输出逐字节一致,已验证):
| 维度 | scriptc | 对照 |
|---|---|---|
| 启动时间 | ~2.4ms | Node: ~47ms;与 Zig 持平,快于 Go/Rust |
| 二进制体积 | 170–200KB 静态~3MB(--dynamic + 嵌入依赖) | Go: ~2MB;Node SEA: 60–100MB |
| 内存 RSS | 1–4MB | Node: 67–116MB |
| 运行时性能 | JS 忠实的 f64 语义;多数 workload 与系统语言竞争力相当 | 整数推断与所有权分析在路线图上 |
5.1 这些数字意味着什么
启动时间 2.4ms vs Node 47ms(约 20 倍)
对交互式 CLI 来说,47ms 是"能感觉到卡一下",2.4ms 是"瞬间"。人类感知的交互延迟阈值大约在 100ms,但 CLI 通常要串起来用——for f in *.ts; do mytool $f; done 跑 500 个文件,Node 版光启动就烧掉 23 秒,scriptc 版 1.2 秒。
对 Serverless 更是要命。冷启动 47ms 意味着你必须维护预热实例池;2.4ms 意味着你可以真正做到按需拉起。
内存 1–4MB vs Node 67–116MB(约 30 倍)
这个数字直接换算成钱。一台 8GB 内存的机器,Node 进程按 100MB 算能跑 80 个,scriptc 按 3MB 算能跑 2600 个。对于要跑大量小进程的场景(sidecar、agent、边缘节点),这是数量级的密度差异。
也让"在 RISC-V 小板子上跑 TypeScript 服务"这类事情从幻想变成工程问题。
二进制 178KB vs Node SEA 60–100MB(约 400 倍)
容器镜像层、CI 产物传输、边缘节点分发——每一个环节都受益。一个 178KB 的二进制配 FROM scratch,整个镜像不到 1MB。
5.2 别被数字骗了:运行时性能是另一回事
我要给上面的兴奋泼一盆冷水。README 自己也很诚实地写了:
runtime: JS-faithful f64 semantics; competitive with the systems languages on most workloads —— integer inference and ownership analysis are on the roadmap(整数推断和所有权分析在路线图上)
翻译成人话:scriptc 目前所有的 number 都是 f64。
这是为了语义忠实——JS 的 number 就是 f64,0.1 + 0.2 !== 0.3,位运算要先转 i32 再转回来。scriptc 必须复现这个行为,否则差分测试过不了。
但代价是:一个纯整数循环,Rust 会用 i64 寄存器跑,scriptc 只能用 f64 浮点单元跑。现代 CPU 上浮点加法和整数加法吞吐差距不大,但数组索引、位运算、循环计数这些地方,f64 会明显吃亏——每次索引都要做浮点转整数。
CHANGELOG v0.0.19 显示他们已经在啃这块骨头了:
Library integer slots compose with optional numbers. 声明为
number | null或可选数字的槽位投影为optional<i64>,并且只对其确实存在的数值证明这一点,跨记录、tagged-message payload、以及辅助函数的参数/返回值都成立。
optional<i64> 出现了——整数推断已经在路上了。当编译器能证明一个 number 槽位的所有可能值都在 i64 安全范围内,就可以用整数表示。这会是性能上的下一个大跳。
还有 "ownership analysis"(所有权分析)——如果能证明一个对象没有别名,就可以栈分配、省掉引用计数操作。这是 Swift 编译器和 Lobster 语言用过的路子,收益很大。
所以现在的定位应该是: scriptc 在启动、体积、内存上是碾压性优势,在稳态吞吐上大致是"和系统语言同一量级但不占优"。如果你的瓶颈是数值密集计算,现在还不是它的主场。
六、横向对比:它在生态里的位置
| 方案 | 路线 | 二进制 | 启动 | 需要改代码 | npm 生态 |
|---|---|---|---|---|---|
| scriptc | TS → typed IR → 原生 | 170KB–3MB | ~2.4ms | 否 | --dynamic 内嵌 |
| Node SEA | 打包引擎 | 60–100MB | ~47ms | 否 | 完整 |
| Bun compile | 打包 JSC | ~50–90MB | ~10ms 量级 | 否 | 完整 |
| Deno compile | 打包 V8 | ~60–80MB | ~20ms 量级 | 否 | 大部分 |
| AssemblyScript | TS 方言 → WASM | 几十 KB | 极快 | 是(方言) | 无 |
| Porffor | JS AOT → WASM/原生 | 小 | 快 | 否 | 极有限 |
| Go / Rust | 另一门语言 | 2MB / 300KB+ | 快 | 是(重写) | 无 |
scriptc 占的是一个此前空缺的生态位:不换语言、不换方言、不打包引擎。
跟 AssemblyScript 比:AS 用的是 "TypeScript-like" 语法,你得写 i32、StaticArray<T>,标准库是自己的一套。已有代码不能直接编译。scriptc 是真 TypeScript,tsc 类型检查,@types/node 直接用。
跟 Bun/Deno compile 比:那两个是分发方案,不是编译方案。产物大小和启动时间由引擎决定。但它们的 npm 兼容性是 100%,scriptc 是"静态子集 + 动态兜底"。
跟 Go 比:Go 的 2MB 二进制和快启动一直是 CLI 工具的黄金标准(Docker、kubectl、Terraform 全是 Go)。scriptc 现在在体积和启动上优于 Go,而语言是你已经会的 TypeScript。这个组合对前端团队自研工具链有相当大的吸引力。
还有一个视角:站内前几天写过 TypeScript 7.0 用 Go 重写编译器、Zerolang 把语义图升格为程序数据库。把这几件事连起来看,会发现一条清晰的线索——TypeScript 正在从"给 JS 加类型的语言"演化成"一门有严肃编译器基础设施的语言"。tsc 用 Go 重写解决了检查速度;scriptc 解决了产物形态;Vercel Labs 同时在做这两件事,不是巧合。
七、局限与冷思考:现在该不该用
我必须把话说明白:scriptc 现在的版本号是 v0.0.19,仓库建了 8 天,34 个 open issue。这是一个实验项目。
7.1 明确的限制
平台成熟度不均衡。 README 说 macOS arm64 是主平台,Linux 和 Windows 靠交叉编译。CHANGELOG v0.0.17 才修好"CLI 能在 Windows 上构建和运行",v0.0.16 才修好"Linux 原生构建能用宿主 clang 链接"。Linux 服务器场景的成熟度明显落后于 macOS 开发场景——这有点讽刺,因为服务器才是最需要小二进制的地方。
静态覆盖率的天花板取决于你的依赖。 你自己写的业务代码 95%+ 静态很正常,但只要引入一个纯 JS 的 npm 包,那部分就得走引擎。真实项目的 --dynamic 是常态而不是例外。
已知的语义分歧。 README 说有"几十处"刻意的与 Node 的差异,主要在计时内部机制和 error 对象属性上,全部有编号有文档。这意味着:你不能假设编译后行为和 Node 100% 一致,你需要读那份分歧清单。
编译需要 clang。 这不是 npm install 就完事的,CI 环境要装工具链,交叉编译要配 sysroot。相比 Go 的 GOOS=linux go build 一条命令,工程复杂度更高。
API 覆盖仍在扩张。 从 CHANGELOG 能看到,http.Agent、https.request(options, cb)、DNS 解析、顶层 await 这些东西是最近两周才陆续补上的。你的代码碰到某个还没实现的 API,编译就是失败——好消息是它会明确告诉你,坏消息是你得等或者自己提 PR。
7.2 现在适合谁
适合:
- CLI 工具,尤其是需要分发给不装 Node 的用户,或者调用频率极高的工具
- 边缘函数 / Serverless,冷启动敏感的场景
- 嵌入式 / 资源受限环境,几 MB 内存跑 TypeScript 服务
- 想在生产之外做技术预研的团队——现在参与,能影响它的演进方向
不适合:
- 重度依赖 npm 生态的应用(Next.js 应用别想了)
- 数值密集计算(等整数推断落地)
- 需要长期稳定 API 的生产系统(v0.0.x,破坏性变更是必然的)
- Windows/Linux 为主的生产环境(成熟度还在追赶)
7.3 我的判断
抛开成熟度,这个项目在架构选型上几乎没有走错的地方:
- 复用 tsc 而不是重写类型检查器 —— 保证了语义正确性的上限
- typed IR 作为唯一接口 + 双后端 —— 保证了可调试性和交叉验证
- 三档分级 + coverage 工具 —— 把"能编译多少"从玄学变成可测量的工程指标
- 差分测试 + ASan 门禁 —— 正确性有硬约束,不靠人品
- 静态是默认,动态要 opt-in —— 避免了性能悄悄退化
- checked cast —— 在类型系统的乐观和机器码的严苛之间架了桥
这六条里的任何一条做错,项目都会歪掉。它们全做对了。
技术风险主要在两处:引用计数在复杂对象图下的性能表现(环检测的开销可能在某些 workload 下很难看),以及 API 覆盖面的长尾(Node 的 API 表面积极大,补完是个体力活)。
生态风险在于:Vercel Labs 是实验性组织,Zerolang、scriptc 这类项目的存活率历史上不高。但这次不一样的地方在于——Vercel 自己就是最大的潜在用户。他们的 Edge Functions、Vercel Sandbox 都直接受益于"178KB 二进制、2.4ms 冷启动"。有内部客户的实验项目,活下来的概率会高得多。
八、总结:编译型 TypeScript 是不是个真需求
回到最开始那个问题。
十年前,TypeScript 的定位是"给 JavaScript 加个类型检查器",类型在运行时被擦除,产物是 JS。这个定位让它以极低摩擦占领了前端。
但今天的 TypeScript 早就不只在浏览器里跑了。它在写 CLI、写服务端、写基础设施、写 AI Agent。而在这些场景里,"必须带着一个 100MB 的运行时"是一个越来越难忍受的税。
- 你想写个 CLI 给用户 —— 得让人先装 Node
- 你想部署到边缘 —— 冷启动 47ms 打底
- 你想跑在小设备上 —— 100MB 内存起步
- 你想放进容器 —— 镜像 200MB 起
Go 之所以在基础设施领域全面胜出,很大程度上就是因为它解决了这个税:单文件、静态链接、快启动、跨平台交叉编译。 无数团队为了这四个特性,硬着头皮学了一门新语言。
scriptc 提出的问题是:为什么要换语言?你的类型标注已经把答案写在源码里了。
这个问题问得好。而且它给出的答案,不是靠定义方言绕过去,不是靠打包引擎糊过去,而是老老实实做了一个编译器——复用 tsc 的类型结论,做 lowering,生成 typed IR,走 LLVM 或 C 后端,链上一个按需裁剪的 C 运行时,最后用 800+ 个差分测试和 AddressSanitizer 把正确性钉死。
这是编译器该有的做法。
现在它 v0.0.19,还很嫩。整数推断没做,所有权分析没做,Linux 成熟度落后,API 长尾还在补。但方向是对的,工程质量是硬的,而且——它有一个愿意为它买单的东家。
如果你是做 CLI、做边缘、做工具链的,值得现在就装一个玩玩:
npm install -g scriptc
scriptc coverage your-project/src/index.ts
先看看你的代码有多少能静态编译。那个百分比会告诉你很多关于你代码库的事——它比你以为的要静态得多。
项目信息
- 仓库:
github.com/vercel-labs/scriptc - 协议:Apache-2.0
- 语言:TypeScript(编译器)+ C(运行时)
- 创建时间:2026-07-22
- 当前版本:v0.0.19
- 文档站:
scriptc.dev
本文技术细节均来自项目 README、CHANGELOG 与 GitHub 公开数据。性能数字为项目方在 Apple M 系列上的自测结果,未经第三方复现,实际表现请以你自己的 workload 为准。观点部分为个人判断,欢迎拍砖。