编程 scriptc 深度拆解:Vercel 把 TypeScript 编译成 178KB 原生二进制——没有 Node,没有 V8,冷启动 2.4ms

2026-07-31 03:46:03 +0800 CST views 8

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 都能静态编译。anyeval、动态 import、npm 包里那些十年前的 CommonJS……总有编译不了的东西。

大多数同类项目在这里选择了两条错路:

  1. 定义方言(AssemblyScript 路线):只支持 TypeScript 的一个子集,用 i32/u64 这样的特殊类型,写出来的东西不是 TypeScript,是"长得像 TypeScript 的另一门语言"。你不能拿现有代码直接跑。
  2. 静默降级:编译不了的部分偷偷塞进解释器,用户不知道自己的热点函数根本没被编译。等到性能不达标才开始猜到底哪里出了问题。

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

三档,每一档都有明确契约:

档位触发条件行为代价
静态编译默认档,类型可证明编译成原生代码,二进制里没有引擎
动态执行--dynamicnpm 依赖的 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 as is 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。

好处巨大:

  1. 语义 100% 对齐官方。TypeScript 的类型系统有多少边角?条件类型、映射类型、模板字面量类型、infer、变体标注、控制流收窄……自己实现一遍等于重写一个 tsc,注定跟不上版本。
  2. tsconfig.json 直接生效。你的 strictnoUncheckedIndexedAccesspaths 全部照旧。
  3. 类型收窄(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 的对象模型有两个特点让原生编译异常困难:

  1. 任意对象图,可以有环(a.b = b; b.a = a
  2. 闭包捕获,变量生命周期不跟随作用域

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 runscriptc 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),直接变成静态调用。
  • 模板字面量、padEndtoFixed → 标准库,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 路径那堆恶心的边角)
  • processoscryptourl/URLzlib
  • child_process(带管道流)
  • timers 和信号处理器,跑在无外部依赖的事件循环上(macOS 是 kqueue)
  • 服务器栈:nethttphttpstls(内置 mbedTLS)、dgramdnsfs.watchreadline

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、HeadersAbortSignal)跑在同一套原生 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.4msNode: ~47ms;与 Zig 持平,快于 Go/Rust
二进制体积170–200KB 静态~3MB(--dynamic + 嵌入依赖)Go: ~2MB;Node SEA: 60–100MB
内存 RSS1–4MBNode: 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 生态
scriptcTS → typed IR → 原生170KB–3MB~2.4ms--dynamic 内嵌
Node SEA打包引擎60–100MB~47ms完整
Bun compile打包 JSC~50–90MB~10ms 量级完整
Deno compile打包 V8~60–80MB~20ms 量级大部分
AssemblyScriptTS 方言 → WASM几十 KB极快是(方言)
PorfforJS AOT → WASM/原生极有限
Go / Rust另一门语言2MB / 300KB+是(重写)

scriptc 占的是一个此前空缺的生态位:不换语言、不换方言、不打包引擎

跟 AssemblyScript 比:AS 用的是 "TypeScript-like" 语法,你得写 i32StaticArray<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.Agenthttps.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 我的判断

抛开成熟度,这个项目在架构选型上几乎没有走错的地方

  1. 复用 tsc 而不是重写类型检查器 —— 保证了语义正确性的上限
  2. typed IR 作为唯一接口 + 双后端 —— 保证了可调试性和交叉验证
  3. 三档分级 + coverage 工具 —— 把"能编译多少"从玄学变成可测量的工程指标
  4. 差分测试 + ASan 门禁 —— 正确性有硬约束,不靠人品
  5. 静态是默认,动态要 opt-in —— 避免了性能悄悄退化
  6. 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 为准。观点部分为个人判断,欢迎拍砖。

推荐文章

Go 开发中的热加载指南
2024-11-18 23:01:27 +0800 CST
Vue3中如何处理SEO优化?
2024-11-17 08:01:47 +0800 CST
对多个数组或多维数组进行排序
2024-11-17 05:10:28 +0800 CST
JavaScript 上传文件的几种方式
2024-11-18 21:11:59 +0800 CST
程序员茄子在线接单