编程 scriptc 深度拆解:Vercel 如何把 TypeScript 编译成 178KB 原生二进制——零运行时编译器的静态分层、JS-exact 语义与内存安全设计

2026-08-01 14:43:18 +0800 CST views 7

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 的定位可以精确概括为三句话:

  1. 零运行时:编译产物是自包含的原生可执行文件,不携带 Node、V8、quickjs 或任何 JavaScript 引擎(静态模式下)。没有 GC,没有 JIT,没有解释器。
  2. 零方言:你写的是"普通 TypeScript"(ordinary TypeScript),用真正的 tsc 做类型检查,你的 tsconfig.json 决定检查严格度。不需要注解,不需要改写,不需要学习新语法。
  3. 零静默偏差:任何不能静态编译的构造,要么进入动态模式由嵌入式引擎执行,要么以明确的错误码被拒绝。没有任何东西会被悄悄编错。 凡是能编译的部分,行为与 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、带运行时校验强制转换的 JSONMath、TypedArray、Buffer、带类型化 catchError 层级
  • Node API 面fs(同步 + Promise)、path(字节级精确移植)、process、带管道流的 child_processoscryptourl/URLzlib、无依赖事件循环上的 timers 与信号处理,以及服务器栈 net/http/https/tls(内嵌 mbedTLS)、dgramdnsfs.watchreadline
  • fetch + WHATWG web 子集(streams、HeadersAbortSignal),跑在同一套原生 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]

拆开来看是五步:

  1. 前端:直接用 tsc 的 API 做 parse + typecheck。这一步保证了"零方言"——检查你的代码的就是你日常用的那个 TypeScript 编译器,es2025 lib、@types/nodetsconfig.json 的 strict 级别全部生效。
  2. Lowering:把类型检查后的 AST 降级成 typed IR。这是整个编译器的核心智力所在:决定每个构造走静态、动态还是拒绝,把 TS 的类型信息(narrowing 结果、判别联合、泛型实例化)编码进 IR。
  3. typed IR:IR 是前后端的唯一接口(the only interface between the ends)。它有 validator 和 serializer,可以 --emit-ir 导出成 x.ir.json 检查。这个设计意味着后端可以整体替换而不影响前端。
  4. 代码生成:LLVM 是默认后端,C 是永远存在的参考后端(reference backend,用 --backend c 输出可读的、带源码行注释的 C 代码)。LLVM 覆盖不了的 tier 有透明回退。
  5. 链接: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.jsonport 是字符串 "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.4msNode ~47ms;与 Zig 相当,快于 Go/Rust
二进制体积170~200KB 静态;--dynamic + 依赖约 3MBGo 2MB;Node SEA 60100MB
内存 RSS1~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 保证你不用为没用到的 httpcrypto、正则引擎付字节。

内存为什么省? 没有堆上引擎数据结构。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 为准。

推荐文章

Vue3中如何使用计算属性?
2024-11-18 10:18:12 +0800 CST
宝塔面板 Nginx 服务管理命令
2024-11-18 17:26:26 +0800 CST
程序员茄子在线接单