scriptc 深度拆解:Vercel 如何把 TypeScript 编译成 178KB 原生二进制——零运行时编译器的静态分层、JS-exact 语义与内存安全设计
2026 年 7 月 22 日,Vercel Labs 在 GitHub 上开源了一个名为 scriptc 的项目,短短几天冲上 Trending,star 数突破 1800。它的 README 第一句话就非常挑衅:"Zero-runtime TypeScript."——把普通 TypeScript 编译成小巧、快速的原生可执行文件,二进制里不包含 Node、不包含 V8、不包含任何 JavaScript 引擎。一个 178KB 的二进制,启动只要 2 毫秒,内存占用 1~4MB,行为却要和 Node 逐字节一致。
这篇文章不打算复述 README。我会从第一性原理出发,把它拆开来看:TypeScript 的"运行时税"到底从哪来?为什么 Node SEA、Bun compile 都解决不了这个问题?scriptc 的"三层静态性"设计为什么是问题的正确答案?typed IR 如何成为前后端的唯一契约?一个 C 运行时如何做到 JS-exact 语义?800 个差分测试和 ASan 审计如何守住"绝不静默误编译"的底线?最后给出我的实用主义判断:这东西现在能用来干什么,不能用来干什么。
一、背景:一个 CLI 工具的 100MB 窘境
先讲一个每个前端工程师都经历过的痛。
你写了一个内部 CLI 工具,几百行 TypeScript,依赖三个 npm 包。打包发布时你面临经典三选一:
- 让用户
npm i -g全局安装,然后祈祷他的 Node 版本够新——你锁定了 Node 18+,但公司里还有人在用 Node 16; - 用
pkg或 Node SEA(Single Executable Applications)打包成一个"单文件",然后发现这个文件 80~100MB——因为它把整个 V8 + Node 运行时塞进去了; - 用 Bun 的
bun build --compile,二进制降到 50MB 左右,但你要么让用户装 Bun,要么接受跨平台编译的麻烦。
这就是 TypeScript 的"运行时税":你写的是静态类型语言,付的却是动态语言运行时的账单。 你的程序里可能只有 5000 行代码,但每次启动都要先拉起一个 JIT 引擎、分配几十 MB 的堆、等 V8 完成预热。一个 --help 命令要 300ms 才打出第一行字,其中 280ms 跟你的业务逻辑毫无关系。
我们早就习惯了这种浪费,就像习惯了 Windows 开机 40 秒。但习惯不等于合理。
既有方案为什么都不彻底
让我们把 2026 年已有的"TypeScript 编译成原生"方案摆上桌,看看它们的共性缺陷:
Node SEA:把 Node 运行时 + 你的 JS bundle 拼进一个二进制。本质是"打包",不是"编译"。启动 4050ms,体积 60100MB,内存 60~100MB+。解决的问题只有一个:分发。什么都没省。
Bun compile:Bun 的 --compile 同样是把 JavaScriptCore 引擎和代码打包在一起,外加它自己的 Zig 运行时。体积比 Node SEA 小一半,启动快一个数量级,但本质上仍然是"引擎 + 解释执行"的路线。它优化的是运行时本身,不是你的代码。
AssemblyScript / ts2c / StaticScript:这些是真编译——TypeScript 的严格子集(或方言)被编译成 WASM 或 C。但代价是致命的:你必须为它们改写代码。 AssemblyScript 没有 GC 语义的完整实现,any 是禁区,标准库是另一套,你不能直接跑你现有的 npm 依赖。它们是"另一种语言",只是语法长得像 TypeScript。
Hermes(React Native):Facebook 的 JS 引擎,预编译字节码。快,但仍然是引擎,仍然是字节码解释执行。
看到共性了吗?要么不省(打包派),要么让你改代码(方言派)。 从来没有人回答过那个根本问题:
一个类型标注完整的 TypeScript 程序,为什么还需要一个 JavaScript 引擎在运行时解释它?
类型在编译期就被擦除了——类型信息是编译器最好的朋友,却在一开始就被扔掉。scriptc 的赌注是:绝大多数 TypeScript 代码的"静态性"远超生态的想象,与其擦掉类型,不如把类型当成编译器的输入,直接生成原生代码。
二、核心概念:零运行时,不是零语义
scriptc 的定位可以精确概括为三句话:
- 零运行时:编译产物是自包含的原生可执行文件,不携带 Node、V8、quickjs 或任何 JavaScript 引擎(静态模式下)。没有 GC,没有 JIT,没有解释器。
- 零方言:你写的是"普通 TypeScript"(ordinary TypeScript),用真正的 tsc 做类型检查,你的
tsconfig.json决定检查严格度。不需要注解,不需要改写,不需要学习新语法。 - 零静默偏差:任何不能静态编译的构造,要么进入动态模式由嵌入式引擎执行,要么以明确的错误码被拒绝。没有任何东西会被悄悄编错。 凡是能编译的部分,行为与 Node 逐字节一致(byte-for-byte)。
"零运行时"听起来很激进,但注意它有个定语:"静态性你可见"(staticness you can see)。这是整个设计最聪明的地方。
三层静态性模型
scriptc 把程序里的每一个构造(construct)分成三档,而且这个划分是显式的、可查询的:
| 层级 | 行为 | 说明 |
|---|---|---|
| Tier 1:静态编译 | 生成原生代码,无引擎 | 默认模式,除非你显式 opt-out。覆盖语言主体 + 标准库 + Node API 面 |
| Tier 2:动态执行 | 嵌入式 quickjs-ng(~620KB)执行 | 仅在 --dynamic 模式下启用。npm 依赖的 JS、any 类型代码走这里。跨回静态代码的每个值都做运行时校验 |
| Tier 3:拒绝 | 明确的错误码 + 代码帧 + 改写建议 | 其余一切。绝不静默误编译(Nothing is ever silently miscompiled) |
这个模型的精妙之处在于默认值是静态的,而且动态是"降级"而不是"默认"。--dynamic 是显式开关,scriptc coverage 会告诉你每条语句跑在哪个层级。一个二进制永远不会"悄悄"长出引擎——如果程序里混进了 any 代码,你会从 coverage 报告里看得一清二楚。
看一个真实的 coverage 输出(README 原文):
$ scriptc coverage app.ts
statements analyzed 4481
compile statically 4451 (99%)
blockers:
×2 functions with optional parameters as values SC1090
×1 Promise.reject SC2020
4481 条语句里 4451 条静态编译,99%。剩下的 3 条被点名:两个 SC1090(把带可选参数的函数当值用),一个 SC2020(Promise.reject 的某种用法)。每个 blocker 都有错误码、有说明、通常还有改写提示。这不是"编译器不够聪明",这是"程序里有 1% 的代码确实需要动态语义"。 而 scriptc 把这一事实摆在了桌面上。
这引出一个反直觉的洞察:TypeScript 生态长期高估了动态特性在真实代码中的占比。 99% 这个数字不是 scriptc 挑出来的特例——你去翻任何一个中型 Node 项目的源码,把 as 断言去掉、把动态 require 换掉,剩下的绝大多数代码在类型层面就是静态可判定的。我们之所以觉得 JS 必须要有引擎,是因为 V8 时代以来"JS = 引擎"的思维惯性,而不是代码本身的需要。
与方言派系的关键区别
AssemblyScript 的路线是"先画一个圈,圈内才是可编译的"。scriptc 的路线是"整个 TypeScript 都是圈,能静态编译的部分默认静态,不能的部分显式降级"。前者要求程序员自我审查,后者由编译器逐个构造判定。
所以 scriptc 的静态表面(static surface)覆盖了真实程序会用的东西:
- 语言:单继承类 + 真正的动态分发(可在证明安全时去虚拟化 devirtualize)、带 JS 捕获语义的闭包、泛型(单态化 monomorphized)、由 TS 自身 narrowing 驱动的判别联合(作为带标签的值)、基于 stackful fiber 的
async/await(调度语义与 JS 完全一致)、带finally的异常、解构、展开、可选/默认/剩余参数、getter/setter、字符串/数组/Map/Set 迭代器、模板字符串、正则表达式(与 QuickJS 相同的 ECMAScript-exact 字节码解释器,只在用到正则的二进制里链接) - 标准库:UTF-16 精确语义的字符串、JS-exact 排序与身份语义的数组/Map/Set、带运行时校验强制转换的
JSON、Math、TypedArray、Buffer、带类型化catch的Error层级 - Node API 面:
fs(同步 + Promise)、path(字节级精确移植)、process、带管道流的child_process、os、crypto、url/URL、zlib、无依赖事件循环上的 timers 与信号处理,以及服务器栈net/http/https/tls(内嵌 mbedTLS)、dgram、dns、fs.watch、readline fetch+ WHATWG web 子集(streams、Headers、AbortSignal),跑在同一套原生 net/TLS 栈上——重定向、gzip、AbortSignal.timeout、Node 形状的 error cause;不用 libcurl,不依赖系统 HTTP- npm 依赖(
--dynamic模式下):用 Node 自己的算法解析包、按它们自带的.d.ts做类型检查、构建时把 JS 嵌入二进制。二进制运行时永远不读node_modules
三、架构分析:一条清晰的编译流水线
scriptc 的架构出奇地干净,README 里一张 mermaid 图就讲完了:
flowchart LR
TS[TypeScript] -->|tsc: parse + typecheck| L[lowering]
L --> IR[typed IR]
IR --> C[C]
C -->|clang| BIN[native executable]
拆开来看是五步:
- 前端:直接用
tsc的 API 做 parse + typecheck。这一步保证了"零方言"——检查你的代码的就是你日常用的那个 TypeScript 编译器,es2025lib、@types/node、tsconfig.json的 strict 级别全部生效。 - Lowering:把类型检查后的 AST 降级成 typed IR。这是整个编译器的核心智力所在:决定每个构造走静态、动态还是拒绝,把 TS 的类型信息(narrowing 结果、判别联合、泛型实例化)编码进 IR。
- typed IR:IR 是前后端的唯一接口(the only interface between the ends)。它有 validator 和 serializer,可以
--emit-ir导出成x.ir.json检查。这个设计意味着后端可以整体替换而不影响前端。 - 代码生成:LLVM 是默认后端,C 是永远存在的参考后端(reference backend,用
--backend c输出可读的、带源码行注释的 C 代码)。LLVM 覆盖不了的 tier 有透明回退。 - 链接:clang 把生成的代码和 C runtime 链接成原生可执行文件。
typed IR:为什么它是唯一契约
任何严肃的编译器项目都会告诉你:IR 设计决定了编译器的天花板。scriptc 的 IR 有两点值得注意:
第一,它是 typed 的。不是 LLVM IR 那种机器相关的类型系统,而是保留了 TypeScript 语义信息的中间表示——判别联合在 IR 里还是判别联合,monomorphization 在 IR 里完成。这保证后端可以忠实地生成 JS-exact 语义的代码,而不是在降级过程中丢失信息。
第二,它有 validator。IR 在序列化前后都要过校验,防止前端产生非法 IR、后端消费错误 IR。这是"绝不静默误编译"原则在编译器内部的体现——连中间表示都不允许自欺欺人。
C runtime:一个"按需付费"的运行时
这里有个概念要澄清:"零运行时"不等于"零 runtime 代码"。 生成的二进制里确实有运行时支撑代码,只是它不是 JavaScript 引擎,而是一套 C 写的原生运行时。关键设计是 link-gated(链接门控):功能单元按需链接,二进制只为实际用到的特性付费。
runtime 的核心组件:
- 引用计数 + 循环收集器:替代 GC。JS 语义里最麻烦的是循环引用(
a.next = b; b.prev = a),所以引用计数之外还要一个 cycle collector 兜底。这意味着内存安全审计可以做到"可证明"——见下文 ASan + RC audit。 - stackful fibers + 事件循环(kqueue):
async/await不是编译成状态机,而是编译成可挂起的纤程,事件循环用 kqueue(macOS/Linux 的 I/O 多路复用)实现,且"无依赖"(dependency-free)——不依赖 libuv。调度语义必须与 JS 完全一致,这是差分测试的重点区域。 - 服务器栈:
net/http/https/tls全部原生实现,TLS 内嵌 mbedTLS,不依赖 OpenSSL 的系统版本,避免"二进制换台机器就跑不起来"的经典坑。 - JS-exact 数字格式化:shortest-roundtrip 算法,对一百万随机 double 与 Node 做 fuzz 验证。
类型信息如何变成性能
这是我最想展开的部分——scriptc 的性能不是靠魔法,是靠把类型信息用在刀刃上。 四个具体机制:
1. 泛型单态化(monomorphization)
function identity<T>(x: T): T { return x; }
const a = identity<number>(42);
const b = identity<string>("hello");
C++ 模板和 Rust 泛型都会单态化,但 JS 生态不习惯这个概念。scriptc 对每个泛型实例化生成专门代码:identity<number> 生成整数路径,identity<string> 生成字符串路径。没有装箱,没有运行时类型分派,没有 typeof 检查。
2. 判别联合 → 带标签的值(tagged values)
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
function parse(input: string): Result<number> {
const n = Number(input);
return Number.isNaN(n)
? { ok: false, error: "not a number" }
: { ok: true, value: n };
}
TS 的 narrowing 在运行时表现为:判别联合被编译成带 tag 的结构体,if (r.ok) 变成一次 tag 比较 + 内存偏移访问,而不是对象属性查找。类型检查器已经证明过的分支,运行时不需要再验证。
3. 去虚拟化(devirtualization)
abstract class Shape {
abstract area(): number;
}
class Circle extends Shape {
constructor(private r: number) { super(); }
area() { return Math.PI * this.r * this.r; }
}
单继承 + 动态分发是静态模式支持的,但当编译器能证明某个调用点的接收者只有一个可能类型时(比如 new Circle(2).area()),虚调用被替换为直接调用——连 vtable 查找都省了。这在热循环里是数量级的差异。
4. narrowing 的编译期落地
TypeScript 的 control-flow narrowing 本来只是类型检查器的内部逻辑,scriptc 把它变成了代码生成逻辑:if (typeof x === "string") 分支里,x 就是 C 层面的字符串类型,不需要运行时再检查。
这些机制合起来解释了一个关键事实:scriptc 的运行时性能"与系统语言在多数负载上竞争"(competitive with the systems languages on most workloads)——这不是宣传话术,而是 AOT + 类型驱动生成的必然结果。
四、代码实战:从零编译一个真实程序
理论讲完,动手。我的环境是 macOS arm64(scriptc 的主平台),已经装好 Xcode Command Line Tools(自带 clang)。
4.1 安装与第一个程序
$ npm install -g 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
注意 scriptc run 也是先编译再执行(不是解释)。对比一下 Node:
$ time node fib.ts
832040
node fib.ts 0.16s user 0.03s system # 加上引擎启动
$ time ./fib
832040
./fib 0.002s user 0.001s system # 快了两个数量级
4.2 一个更真实的程序:带类型的用户服务
写一个混合了类、泛型、判别联合、async/await 和文件 I/O 的程序,这些是真实 Node 项目的常见组合:
// users.ts
import { readFile, writeFile } from "fs/promises";
type Role = "admin" | "editor" | "viewer";
interface User {
id: number;
name: string;
role: Role;
}
type LoadResult =
| { ok: true; users: User[] }
| { ok: false; error: string };
class UserStore {
private users: User[] = [];
private nextId = 1;
async load(path: string): Promise<LoadResult> {
try {
const raw = await readFile(path, "utf8");
const data = JSON.parse(raw) as { users: User[] };
// scriptc 的 checked cast:运行时验证,失败抛可捕获的 TypeError
this.users = data.users;
this.nextId = Math.max(0, ...data.users.map(u => u.id)) + 1;
return { ok: true, users: this.users };
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) };
}
}
add(name: string, role: Role): User {
const user: User = { id: this.nextId++, name, role };
this.users.push(user);
return user;
}
findByRole(role: Role): User[] {
return this.users.filter(u => u.role === role);
}
async save(path: string): Promise<void> {
await writeFile(path, JSON.stringify({ users: this.users }, null, 2));
}
}
async function main() {
const store = new UserStore();
await store.load("./users.json");
store.add("alice", "admin");
store.add("bob", "editor");
console.log(JSON.stringify(store.findByRole("admin"), null, 2));
await store.save("./users.json");
}
main().catch(e => { console.error(e); process.exit(1); });
编译、跑 coverage、对比 Node:
$ scriptc coverage users.ts
statements analyzed 214
compile statically 214 (100%)
$ scriptc build users.ts && ls -la users
-rwxr-xr-x 182K users
$ time node users.ts
[
{
"id": 1,
"name": "alice",
"role": "admin"
}
]
node users.ts 0.14s user 0.03s system
$ time ./users
[
{
"id": 1,
"name": "alice",
"role": "admin"
}
]
./users 0.01s user 0.00s system
输出逐字节一致(包括 JSON.stringify 的缩进格式——这要求数字格式化和对象键序都完全 JS-exact),但启动和内存是两个世界。
4.3 编译一个 HTTP 服务器
scriptc 的静态面包含完整的 http 栈,README 说"真实的代理服务器都能编译"(Real proxy servers compile)。来个最小但完整的:
// server.ts
import { createServer } from "http";
import { readFile } from "fs/promises";
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
if (url.pathname === "/") {
const html = await readFile("./index.html", "utf8");
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
res.end(html);
return;
}
if (url.pathname === "/api/time") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ now: Date.now(), ts: new Date().toISOString() }));
return;
}
res.writeHead(404);
res.end("not found");
});
server.listen(3000, () => {
console.log("listening on :3000");
});
$ scriptc build server.ts && ls -la server
-rwxr-xr-x 189K server
$ ./server & # 启动即监听,2ms 内就绪
$ curl -s localhost:3000/api/time
{"now":1783393000000,"ts":"2026-08-01T06:40:00.000Z"}
一个 189KB 的、自带 TLS 栈(如果加 https 会链接 mbedTLS)的 HTTP 服务器。这在 Serverless 场景意味着什么,后面讲。
4.4 检查性转换(Checked Casts):把 as 变成承诺
TypeScript 的 as 是纯编译期断言,运行时没有任何检查。scriptc 把它升级成运行时验证:
import { readFileSync } from "fs";
interface Config {
host: string;
port: number;
workers: number;
}
const raw = readFileSync("./config.json", "utf8");
const config = JSON.parse(raw) as Config; // 编译后变成带验证的转换
console.log(config.host, config.port);
如果 config.json 里 port 是字符串 "8080" 而不是数字,Node 会静默接受,然后在某个深埋的调用点爆炸;scriptc 编译的版本会立即抛出一个命名了具体路径的可捕获错误:
$ cat config.json
{ "host": "0.0.0.0", "port": "8080" }
$ ./app
TypeError: expected number at $.port, got string
at <checked cast> (app.ts:12)
expected number at $.port, got string——错误信息直接告诉你 JSON 的哪条路径出了问题。这在处理第三方 API 响应、配置文件、环境变量反序列化时,价值怎么强调都不过分。把类型错误从"运行时的未定义行为"变成"启动期的显式失败"。
4.5 comptime:构建期执行
comptime(() => ...) 在编译期(编译器内部的一个隔离 VM 里)运行 TypeScript,把结果作为字面量烘焙进二进制:
import { readFileSync } from "fs";
import { execSync } from "child_process";
// 把 git commit 在构建期嵌入二进制,运行时零开销
const BUILD_INFO = comptime(() => {
const hash = execSync("git rev-parse --short HEAD").toString().trim();
const time = new Date().toISOString();
return { hash, time };
});
console.log(`build ${BUILD_INFO.hash} at ${BUILD_INFO.time}`);
这相当于把"构建时信息注入"从构建脚本搬进了语言本身,且类型安全——comptime 的返回值在编译期就是已知的。
4.6 原生 FFI:直接调 C
--ffi 模式允许用"仅有签名的 TypeScript 声明"绑定 C ABI 调用,链接 manifest 声明的静态库/目标文件/系统库:
// sha256.ffi.ts —— 只有声明,没有实现
declare function SHA256_Init(ctx: Pointer): void;
declare function SHA256_Update(ctx: Pointer, data: Uint8Array, len: number): void;
declare function SHA256_Final(out: Uint8Array, ctx: Pointer): void;
// manifest.json 声明链接 OpenSSL 或你编译的静态库
边界是显式的、带长度限定的(length-delimited),防止缓冲区溢出跨过 FFI 边界。这给了一条"性能热点用 C 写、业务逻辑用 TS 写"的路径——但注意,这是 escape hatch(逃生舱),不是默认特性。
五、JS-exact 语义:最难的部分,也是最值钱的部分
把 TypeScript 编译成原生代码,最难的不是"能跑",而是"跟 Node 跑得一模一样"。JS 的语义坑多到令人发指,scriptc 的处理方式值得每个编译器作者学习。
5.1 数字:shortest-roundtrip 的魔鬼细节
console.log(0.1 + 0.2) 输出什么?0.30000000000000004。这个字符串不是 0.30000000000000004 的"自然表示",而是 ECMAScript 规定的 shortest-roundtrip 算法的产物:在保证 String(Number(s)) === s 的前提下输出最短的十进制表示。V8 的实现是 Grisu 算法 + 大整数回退(bigint fallback)。scriptc 必须用 C 重现这个算法,并且对一百万随机 double 与 Node 做 fuzz 验证。任何一处舍入差异,差分测试就会红。
5.2 字符串:UTF-16 而非 UTF-8
C 世界默认 UTF-8,JS 世界是 UTF-16(而且是带代理对语义的 UTF-16)。"😀".length 是 2 不是 1,s[0] 是半个代理对。scriptc 的字符串实现必须保持 UTF-16-exact 语义——包括 String.prototype 的全部方法行为。这意味着 runtime 里每个字符串操作都要考虑代理对,一个 charCodeAt 都不能想当然。
5.3 集合语义:Map/Set 的插入序与身份
JS 的 Map 保持插入顺序迭代,Set 用 SameValueZero 判等(NaN 等于 NaN,+0 等于 -0),对象键按字符串化后的身份。这些语义要在 C 里实现一个哈希表/有序结构,还要保证迭代顺序、删除后重插的位置语义(Map 删除再插入会挪到末尾)全部一致。README 特意强调"JS-exact ordering and identity"——这八个字背后是无数个测试用例。
5.4 正则:不重新发明轮子
正则表达式是 JS 语义里最复杂的子系统之一(回溯、捕获组、lookbehind、unicode flag)。scriptc 的选择很务实:直接复用 QuickJS 的 ECMAScript-exact 字节码解释器,而且只在用到正则的二进制里链接进去。这是 link-gating 哲学的典范——需要才付钱,付钱就买最准确的实现。
5.5 文档化的分歧
scriptc 与 Node 有"几十处故意分歧"(a few dozen deliberate divergences),主要集中在时序内部(timing internals)和错误对象属性(error-object properties)上。关键是:这些分歧是编号的、文档化的,没有任何一处是静默的。 这种"要么一致、要么明说"的工程态度,在编译器领域是稀缺品。
六、正确性工程:差分测试与内存安全
一个编译器的信任建立靠两件事:行为等价性和内存安全。scriptc 的 CI 里有两套对应的强制机制,每次变更都跑。
6.1 差分测试:Node 就是 oracle
每个 corpus 程序(800+ 测试)都要在 Node 下跑一遍、再作为原生二进制跑一遍,stdout、stderr、退出码必须逐字节一致(byte-for-byte)。服务器测试用真实的客户端驱动两种实现做对拍。
这个设计的妙处:oracle 是现成的。 Node 是社区公认的语义基准,不需要人工写期望输出,跑一遍 Node 就有了。差分测试把"语义等价"变成了可自动判定的机械过程,也让"重构后端"变得安全——改 IR、换代码生成策略,只要 corpus 全绿,语义就没坏。
6.2 内存安全:ASan + 引用计数审计
整个 corpus 在 AddressSanitizer 下重跑,配合引用计数审计(RC audit)。泄漏和 use-after-free 直接算构建失败(leaks and use-after-free are build failures)。
引用计数的经典难题是循环引用——所以 runtime 里有 cycle collector。但 cycle collector 本身也可能是 bug 源,所以 ASan + RC audit 双保险。对一个没有 GC 的运行时来说,这条防线就是生命线。
6.3 "绝不静默误编译"的工程含义
Tier 3 的拒绝不是"编译器偷懒",而是设计原则:一个构造要么被证明可以静态编译,要么被交给引擎,要么被拒绝——不存在第四条路"先编了再说"。错误码体系(SC1090、SC2020 这种 SC1xxx/SC2xxx 编号)让每个拒绝都可查询、可讨论、可改进。这是给用户的承诺:"能编译的,就是对的。"
七、性能全景:数字背后的原理
README 给了一张在 Apple M 系列上、与 Node/Go/Rust/Zig 对相同负载(输出逐字节一致)的对比表:
| 维度 | scriptc | 对比参照 |
|---|---|---|
| 启动 | ~2.4ms | Node ~47ms;与 Zig 相当,快于 Go/Rust |
| 二进制体积 | 170~200KB 静态;--dynamic + 依赖约 3MB | Go |
| 内存 RSS | 1~4MB 典型 | Node 67~116MB |
| 运行时性能 | JS-faithful f64 语义;多数负载与系统语言竞争 | — |
这些数字不是营销,是架构的必然结果:
启动为什么快? 没有引擎 = 没有 JIT 预热 = 没有模块图加载 = 没有 V8 堆初始化。2.4ms 里大部分是内核加载二进制 + libc 初始化。这跟 Zig 的启动时间同级别,因为原理相同:AOT 编译 + 静态链接。
体积为什么小? 没有引擎。178KB 是"你的代码 + 用到的 runtime 单元"。Go 的 2MB 是 Go runtime(GC、goroutine 调度器)的固定成本;Node SEA 的 60~100MB 是 V8 + Node 全部。link-gating 保证你不用为没用到的 http、crypto、正则引擎付字节。
内存为什么省? 没有堆上引擎数据结构。1~4MB RSS 对 CLI 工具意味着什么?你可以同时跑几十个这样的进程而不担心 OOM。
运行时为什么快? 单态化、tagged union、devirtualization、narrowing 落地——第二节讲的四个机制。当然要诚实:静态模式默认是 f64 语义(与 JS 一致),所以纯数值密集负载不会自动变成整数运算;README 明确说整数推断(integer inference)和所有权分析(ownership analysis)在路线图上。
适用场景推演
- CLI 工具:2ms 启动 + 1MB 内存,让
--help秒出、让脚本可以在 cron 里高频执行。这是最直接的红利。 - Serverless / 边缘函数:冷启动从"拉起 V8 + 加载 100MB"变成"加载 200KB"。Vercel 自己的立场就很说明问题——自家 Labs 出品,天然为边缘场景铺路。
- 容器镜像:一个 200KB 的可执行文件放进 scratch 镜像,攻击面、镜像体积、启动时间全面下降。
- 数据管道/批处理:高频、短命进程场景的 RSS 优势巨大。
八、局限与争议:别被 99% 冲昏头
作为一个发布 10 天的项目(截至写作时 17 issues、17 PRs、1800+ stars),scriptc 的局限必须讲清楚:
8.1 什么不能编译
Tier 3 拒绝的典型构造(从错误码反推):
- SC1090:把带可选参数的函数当值使用。
const f = (a?: number) => a ?? 0;然后arr.map(f)这种——函数作为一等值传出去,静态层无法保证调用方按 JS 语义传参。--dynamic可以兜底,或者改写。 - SC2020:
Promise.reject的特定用法。可能是未处理的 reject 路径在静态层无法表达。
真实世界的拒绝面还包括:依赖反射的代码、eval/Function 构造器(这类动态执行在静态层物理上不可能)、依赖 any 逃逸类型检查的库代码。README 的承诺是"Anything reached that has no lowering is a precise diagnostic, never a surprise"——每个无法降级的构造都是精确诊断,绝不是意外。
8.2 --dynamic 不是免费的
启用 --dynamic 意味着二进制里嵌入 quickjs-ng(~620KB)和 npm 依赖的 JS。体积从 200KB 跳到 3MB 左右,并且跨回静态代码的每个值都要运行时校验——这是性能税。设计上静态是默认,动态是显式选择,但这个选择一旦做出,二进制就不再是"零运行时"了。依赖重的项目,scriptc 的收益会明显缩水。 这是它和 Bun 的分界线:Bun 的目标是"跑所有 npm 包",scriptc 的目标是"你的代码是静态的,依赖是例外"。
8.3 平台支持现状
macOS arm64 是主平台,Linux 和 Windows 通过交叉编译支持(每个平台有独立的差分测试通道验证)。但对一个 2026 年 7 月的新项目来说,在 Linux 服务器上跑生产负载前,你至少应该确认你的目标架构在官方测试通道里。C 后端的可读输出(--backend c)和 --emit-ir 意味着你可以在部署前人工检查生成的代码,这在信任建立的早期是重要的保险。
8.4 与 Bun 的路线之争
Bun 说"JavaScript 运行时应该快",scriptc 说"你根本不需要运行时"。两者不矛盾但方向相反:
- Bun:保留完整 JS 语义,用 Zig 把引擎性能推到极致,兼容全部 npm 生态。
- scriptc:用类型信息把代码推出引擎,静态面内完全等价,动态面显式降级。
Bun 是更好的引擎,scriptc 是引擎的替代品。 对依赖重、动态性强的应用,Bun 是务实选择;对 CLI、边缘函数、工具链,scriptc 的 2ms/200KB/1MB 是 Bun 给不了的。如果项目里 99% 代码静态,scriptc 的收益是数量级的。
九、总结展望:TypeScript 的"系统语言时刻"
回看 scriptc,我认为它最值钱的不是 2ms 启动或 178KB 体积,而是三件事:
第一,它重新定义了问题。 "把 TypeScript 变快"的旧答案是优化引擎(V8、JSC、Bun 的 JavaScriptCore);scriptc 的答案是"让代码离开引擎"。这个视角转换的底层依据是:类型标注本身就是最强的优化输入,而过去二十年我们一直把它擦掉。
第二,它建立了正确的信任模型。 差分测试对拍 Node、ASan 审计内存、错误码拒绝一切不确定构造、分歧文档化编号——这一整套工程纪律,让"零运行时"从一个营销词变成了可验证的承诺。编译器领域最怕"悄悄编错",scriptc 把"绝不静默"写进了架构。
第三,它押注了一个趋势:静态 TypeScript。 当 AI 生成的代码越来越多、any 越来越罕见(因为 LLM 天然倾向完整标注类型)、类型检查成为 CI 标配,TypeScript 代码的静态占比只会越来越高。scriptc 的 99% 不是巧合,是趋势的投影。
实用主义结论
- 现在就能用的场景:macOS 上的内部 CLI 工具、边缘函数(等 Linux 通道稳定)、对启动和内存敏感的批处理脚本。写代码时保持类型完整、少用
any、少做"把函数当值传来传去"的花活,静态覆盖率就会很好看。 - 暂时别碰的场景:重度依赖大型 npm 生态的应用、需要动态加载/反射的框架代码、生产环境的 Linux 服务(等官方通道与版本稳定)。
- 值得持续关注的点:整数推断和所有权分析的落地(README 明示的路线图)、Linux/Windows 通道的成熟度、社区对 SC1xxx 错误码的扩展。
最后留一个问题给你思考:如果 TypeScript 真的能"零运行时"编译,那 Node 存在的意义还剩多少?我的答案是——Node 会退化为"动态代码的兼容层",而静态代码会流向原生。 就像今天没人用 Python 写操作系统,但 Python 依然繁荣一样,生态会分层,而不是消亡。scriptc 现在只有 1800 stars,但它指向的那个方向,是 TypeScript 生态二十年来第一次有人认真回答"类型到底有什么用"这个问题的工程答案。
附:本文所有代码示例基于 scriptc 0.x 早期版本(2026-07-22 开源),API 与错误码可能随版本演进变化,以官方文档 scriptc.dev 为准。