Vercel scriptc 深度拆解:TypeScript 直译成 Native 二进制,跳过 Node.js 直接飞
2026年7月22日,Vercel 实验室悄然开源了一个让整个 JavaScript 生态为之侧目的项目——scriptc。它只有 1,834 颗星(彼时),却解决了一个困扰 TypeScript 开发者多年的根本性问题:
你的 TypeScript 代码明明是"静态类型"的,为什么编译完还要拖着一个几百兆的 Node.js 运行时?
一句话概括 scriptc 的核心价值:把普通 TypeScript 代码直接编译成不含任何 JavaScript 引擎的原生可执行文件,178KB 起步,2ms 启动,不需要 Node,不需要 V8。
这不是玄学,不是玩具,而是一个经过 800+ 测试用例字节级验证、生产就绪的编译系统。本文将深度拆解 scriptc 的设计哲学、架构实现、技术细节与性能真相。
一、为什么我们被困在 Node.js 里?
1.1 TypeScript 的「静态」与「动态」之困
TypeScript 自诞生之日起就面临一个根本矛盾:它是一门静态类型语言,但它的编译产物(JavaScript)是动态解释执行的。
// 这段代码在编译时 TSc 可以精确告诉你 n 是 number
// 运行时,V8 需要做完整的 JS 语义解析才能执行
function fibonacci(n: number): number {
return n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2);
}
console.log(fibonacci(30));
这段代码执行的是纯计算,没有 DOM、没有浏览器 API、没有 Node.js 特有功能,理论上完全可以直接编译成机器码。但 tsc 只能把它变成 JavaScript,JavaScript 依然需要 Node.js 运行时(V8 引擎)才能跑。
现实很残酷:
- Node.js 二进制:约 60-100MB(含 V8 JIT 编译器、libuv 事件循环、内建对象)
- Go 编译同样逻辑:约 2MB(静态链接)
- scriptc 编译同样逻辑:约 178KB(纯原生二进制)
差距是数量级的。
1.2 现有解法的局限性
| 方案 | 思路 | 致命缺陷 |
|---|---|---|
| wasm-pack / Emscripten | TS → C++ → WebAssembly | 额外工具链复杂、WASM 生态割裂、调试困难 |
| pkg / Nexe | 将 Node.js 打包进二进制 | 本质是打包整个 V8,二进制巨大(60MB+起步) |
| bun | 从头实现 JS 运行时 | 兼容性差距、成熟度不足 |
| Go/Rust 重写 | 用系统语言重写核心逻辑 | 丧失 TypeScript 类型系统、无法复用已有代码 |
| Deno Deploy | 运行时上云 | 需要网络、无法本地独立运行 |
这些方案要么改了语言,要么改了运行时,要么付出了巨大的二进制体积代价。没有人真正回答:能不能不改一行 TypeScript 代码,让它原生运行?
1.3 scriptc 的核心洞察
scriptc 团队的洞察简单却深刻:大多数 TypeScript 代码,其实远比生态系统假设的更加静态。
实际分析结果显示,在真实项目中:
- 99%+ 的语句可以静态编译成原生代码
- 被归类为"动态"、需要 JavaScript 引擎的部分,往往来自 npm 依赖或
any类型
如果能精确识别"哪些可以静态编译、哪些必须动态执行",就可以把"能静态化的部分"变成原生机器码,"必须动态执行的部分"嵌入一个轻量级 JS 引擎(QuickJS,仅 620KB),而不是拖着整个 Node.js。
这就是 scriptc 的设计哲学:静态编译优先,动态执行为辅,绝不静默降级。
二、快速上手:178KB 的斐波那契
先感受一下整体体验,再深入架构:
# 安装(依赖 clang,macOS 自带,Linux 装一下)
npm install -g scriptc
# 写一个普通 TypeScript 文件,不需要任何注解或特殊语法
cat > fib.ts << 'EOF'
function fib(n: number): number {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
console.log(fib(30));
EOF
# 方式一:直接运行(scriptc run)
scriptc run fib.ts
# 输出: 832040
# 方式二:编译成原生二进制
scriptc build fib.ts && ls -la fib
# 输出: -rwxr-xr-x 178K fib
# 直接执行二进制
./fib
# 输出: 832040
关键点:你没有修改任何代码。没有注解,没有特殊语法,就是你平时在 Node.js 上跑的那段 TypeScript。scriptc 在幕后完成了类型检查、IR 生成、C 代码生成、clang 编译的全部流程。
检查覆盖率:scriptc coverage
这是 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
输出清晰分为三类:
1. 静态编译(Compiled statically)— 原生机器码,无引擎
2. 动态运行(Runs dynamically, --dynamic)— QuickJS 引擎兜底
3. 拒绝编译(Rejected)— 失败,报精确错误码+代码帧+重写建议
三层分级的好处:没有任何代码会被静默错误编译。如果一段代码有问题,scriptc 会明确告诉你 SC1090 是什么、SC2020 是什么,并给出重写建议。
三、架构深度拆解:三舱架构
scriptc 采用经典的三舱分离架构,每个包职责单一、边界清晰:
┌─────────────────────────────────────────────────────┐
│ scriptc monorepo │
│ │
│ ┌──────────────┐ ┌─────────────┐ ┌────────────┐ │
│ │ @scriptc/cli │ │@scriptc/ │ │@scriptc/ │ │
│ │ │ │ compiler │ │ runtime │ │
│ │ build │ │ │ │ │ │
│ │ run │ │ Frontend │ │ C runtime │ │
│ │ coverage │ │ (tsc→IR) │ │ │ │
│ │ │→ │ IR │→ │ RefCount GC │ │
│ │ │ │ Validator │ │ Fibers+Loop│ │
│ │ │ │ LLVM/C Back │ │ HTTP/Net │ │
│ └──────────────┘ └─────────────┘ └────────────┘ │
│ │
└─────────────────────────────────────────────────────┘
3.1 编译管道(Compiler):TypeScript → IR → C → 机器码
第一站:tsc 前端(parse + typecheck)
// scriptc 内部调用的编译器前端
// 使用 TypeScript 编译器 API 对源代码进行完整的
// 词法分析 + 语法分析 + 语义分析 + 类型检查
// tsconfig.json 完全兼容,项目已有的严格检查规则全部生效
scriptc 并不从头写 TypeScript 解析器。它直接复用 TypeScript 官方的 tsc API 做前端的解析和类型检查。这带来了几个关键优势:
- 零学习成本:你的
tsconfig.json、@types/node完全复用 - 类型系统精准:scriptc 看到的类型与开发者在 IDE 中看到的一致
- 错误信息友好:复用 tsc 的诊断系统,错误信息经过多年打磨
第二站:中间表示(IR)
经过 tsc 验证的代码,被翻译成 scriptc 自研的类型化 IR(Typed Intermediate Representation)。
// 一个简单函数的 IR 示例(简化版)
{
"kind": "FunctionDef",
"name": "fib",
"params": [{ "name": "n", "type": "number" }],
"returnType": "number",
"body": {
"kind": "If",
"cond": { "kind": "BinaryOp", "op": "<",
"left": { "kind": "Ident", "name": "n" },
"right": { "kind": "Literal", "value": 2 } },
"then": { "kind": "Ident", "name": "n" },
"else": {
"kind": "BinaryOp", "op": "+",
"left": { "kind": "Call", "func": "fib",
"args": [{ "kind": "BinaryOp", "op": "-",
"left": { "kind": "Ident", "name": "n" },
"right": { "kind": "Literal", "value": 1 } }] },
"right": { "kind": "Call", "func": "fib",
"args": [{ "kind": "BinaryOp", "op": "-",
"left": { "kind": "Ident", "name": "n" },
"right": { "kind": "Literal", "value": 2 } }] }
}
}
}
IR 是整个编译器的核心契约:
- 上层(Frontend):只需要把 TypeScript AST 翻译成这个 IR,不需要关心目标代码生成
- 下层(Backend):只需要把这个 IR 翻译成目标代码,不需要关心上游是什么语言
- IR 本身是类型化的:类型信息在 IR 层保留,消除了 C 生成阶段丢失类型信息的传统难题
IR 还有配套的验证器(Validator)和序列化器(Serializer),确保 IR 在各阶段之间流转时的一致性。
第三站:C 代码生成(参考后端)
scriptc 提供了两种代码生成后端:
- LLVM 后端(默认):生成优化过的机器码
- C 后端(
--backend c,参考后端):生成人类可读的 C 代码,永远维护
C 后端的代码输出让人可以真正理解"TypeScript 在底层变成了什么":
// fib.ts 对应的 C 代码(scriptc build fib.ts --emit-ir 生成)
// 生成的 C 代码是供 clang 编译的中间产物
#include "scriptc/runtime.h" // scriptc 运行时头文件
// 斐波那契函数的 C 实现
i64 fib(i64 n) {
if (n < 2) {
return n;
}
return fib(n - 1) + fib(n - 2);
}
int main(int argc, char** argv) {
// console.log → stdout
// 类型自动推导,i64 = 64位有符号整数
printf("%lld\n", (long long)fib(30));
return 0;
}
生成的 C 代码有源代码行注释(--emit-ir 保留),方便调试和理解。
第四站:clang 编译(LLVM)
# 完整编译流程(scriptc 内部执行)
$ scriptc build fib.ts --emit-ir
# 等价于手动执行:
# 1. TypeScript → IR
# 2. IR → C
# 3. C → 优化过的 LLVM IR
# 4. LLVM IR → 机器码 (via clang)
# 5. 链接 scriptc 运行时库
# 查看生成的中间产物
$ ls -la .scriptc/
fib.c # 生成的 C 代码
fib.ir.json # IR 序列化文件
fib.bc # LLVM 位码文件
fib # 最终原生二进制
3.2 运行时(Runtime):C 实现的 JavaScript 语义层
这是 scriptc 最有技术含量的部分。JavaScript 的语义与 C 差异极大——JS 有对象(原型链)、闭包(捕获语义)、事件循环、异步、异常层次结构、Unicode 16 位字符串(UTF-16)…… scriptc 的运行时用 C 重实现了这些语义,而不是把 V8 嵌进来。
引用计数 + 循环回收
JS 对象有循环引用(obj.self = obj),纯引用计数会泄漏。scriptc 的 GC 策略:
// scriptc 运行时中的值对象(简化版)
typedef struct {
sc_refcount_t rc; // 引用计数
sc_type_t type; // 类型标签
union {
double number; // number 类型
struct { // 对象类型
sc_prop_t* props;
size_t n_props;
sc_proto_t* proto;
} object;
struct { // 函数/闭包
void* code_ptr;
sc_env_t* closure_env; // 闭包环境
} function;
// ... 其他类型
} data;
} sc_value_t;
// 引用计数增减
sc_value_t* sc_retain(sc_value_t* v) {
__atomic_add_fetch(&v->rc, 1, __ATOMIC_SEQ_CST);
return v;
}
void sc_release(sc_value_t* v) {
if (__atomic_sub_fetch(&v->rc, 1, __ATOMIC_SEQ_CST) == 0) {
sc_finalize(v); // 触发析构(可能形成循环 → 标记-清除兜底)
sc_free(v);
}
}
link-gated 特性门控:每个功能(字符串、数组、HTTP、FS)都是独立的链接单元。如果你的程序没有用 HTTP,mbedTLS 就不会被链接进二进制。按需链接,只为你用到的部分付账。
栈式协程(Fibers):async/await 的 C 实现
// 简化版栈式协程实现(scriptc 内部结构)
typedef struct {
void* stack_base; // 栈底
void* stack_ptr; // 当前栈指针
sc_value_t* locals[16]; // 本地变量快照
sc_await_queue_t* queue; // await 队列
jmp_buf ctx; // 跳转缓冲区(setjmp/longjmp)
} sc_fiber_t;
// async 函数的执行:创建 fiber,调度器按序运行
sc_fiber_t* sc_fiber_create(sc_function_t* fn, sc_value_t** args) {
sc_fiber_t* f = sc_malloc(sizeof(sc_fiber_t));
f->stack_base = sc_malloc(STACK_SIZE);
f->stack_ptr = f->stack_base + STACK_SIZE;
// ... 初始化
return f;
}
// 事件循环调度(基于 kqueue/epoll,无外部依赖)
void sc_event_loop_run(sc_event_loop_t* loop) {
while (loop->active_count > 0) {
int n = kqueue(loop->kq, loop->changes, loop->n_changes,
NULL, 0, loop->timeout);
// 处理 I/O 就绪的 fiber
for (int i = 0; i < n; i++) {
sc_fiber_t* f = loop->events[i].udata;
sc_fiber_resume(f);
}
}
}
关键实现点:
- 栈式协程:每个
async函数有独立栈区,await时整个栈帧被切出(context switch),恢复时精确回到挂起点 - JS-exact 调度语义:事件循环 tick、microtask 队列顺序与 Node.js 完全一致(通过 800+ 对比测试保证)
- 零外部依赖:事件循环直接调用
kqueue(macOS)、epoll(Linux)、IOCP(Windows),不依赖 libuv
服务器栈:从 HTTP 到 TLS 全链路
// 这段 TypeScript 代码可以直接编译成原生 HTTP 服务器
// 不需要 Node.js,不需要 npm install http
const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello from scriptc!\n');
});
server.listen(3000, () => {
console.log('Server running on port 3000');
});
scriptc build server.ts && ./server
# 输出:Server running on port 3000
# 二进制大小:约 350KB(因为包含 HTTP 服务器栈)
scriptc 的服务器栈包括:
net:TCP Socket(基于 kqueue/epoll)http/https:HTTP/1.1 服务器与客户端(vendor 了 mbedTLS)tls:TLS 1.3 加密(mbedTLS,轻量级替代 OpenSSL)dgram:UDPdns:域名解析fs.watch:文件系统监听(inotify/FSEvents)child_process:子进程管理
这不是玩具服务器。README 明确说:Real proxy servers compile.(真正的代理服务器可以直接编译。)
数字格式化:JS-exact f64 语义
这是技术细节中最"强迫症"的部分。JavaScript 的数字格式(toString()、Number.toFixed() 等)在 IEEE 754 double 上有微妙的"最短往返"(shortest-roundtrip)舍入行为,Go/Rust/C 的标准库格式化与此不完全一致。
scriptc 对 100 万个随机 double 做了模糊测试,与 Node.js(V8)的输出逐字节对比,找出所有分歧点并手动对齐。这解释了为什么二进制大小是 170-200KB 而不是更小——这些对齐代码是客观存在的工程量。
3.3 CLI:build / run / coverage 三剑客
# scriptc build:编译成原生二进制
scriptc build <file.ts> [options]
--emit-ir # 生成 .c 和 .ir.json 中间产物
--backend <c|llvm> # 选择后端(默认 llvm)
--dynamic # 嵌入 QuickJS 引擎(支持 npm 依赖)
--ffi # 启用原生 FFI(调用 C 库)
-o <output> # 输出文件名
# scriptc run:直接运行(编译+执行,缓存结果)
scriptc run <file.ts> [args...]
# scriptc coverage:分析覆盖率
scriptc coverage <file.ts> [options]
--dynamic # 包含动态路径的覆盖率
# 输出:statement 级别覆盖率 + 具体 blockers 列表
四、核心特性:从语言到生产环境
4.1 静态编译覆盖的完整语言特性
scriptc 静态编译层支持的 TypeScript 特性集相当完整:
| 类别 | 支持特性 |
|---|---|
| 类型系统 | 基础类型、泛型(单态化/monomorphized)、联合类型、类型守卫、模板字面量类型、infer |
| 面向对象 | 单继承类、接口、抽象类、方法重写、getter/setter、private/public/protected |
| 函数 | 闭包(JS-exact 捕获语义)、箭头函数、rest 参数、默认参数、可选参数、函数重载 |
| 控制流 | if/else、for/while/do-while、switch、try/catch/finally、break/continue、label |
| 数据结构 | 数组、Map、Set、WeakMap、WeakSet、TypedArray、Buffer、DataView |
| 字符串 | UTF-16 精确语义、模板字符串、正则表达式(QuickJS ECMAScript 字节码引擎) |
| 异步 | async/await(栈式 fiber + kqueue 事件循环)、Promise、Generator/Iterator |
| 标准库 | JSON(带类型验证的 as 强制转换)、Math、Error 层次结构 |
| Node API | fs(同步+Promise)、path、process、os、crypto(原生 OpenSSL/libcrypto)、timers、signal |
**泛型的单态化(Monomorphization)**值得单独说明。TypeScript 的泛型在运行时没有类型擦除——Array<number> 和 Array<string> 在运行时是不同的数据结构。scriptc 对每个具体类型参数组合生成专门的机器码:
// TypeScript
function identity<T>(x: T): T { return x; }
// scriptc 编译后:为 number 和 string 各生成专门代码
// identity<number> → 专门路径(无装箱/拆箱)
// identity<string> → 专门路径
// 运行时类型分发零开销(编译器内联+特化)
4.2 动态模式(--dynamic):npm 依赖的正确处理方式
当你使用了 npm 包时,静态编译会停止,但 scriptc 有优雅的降级方案:
# 检查带 npm 依赖的覆盖率
scriptc coverage app.ts --dynamic
statements analyzed 6230
compile statically 5987 (96%)
run dynamically 243 (embedded QuickJS)
blockers:
×4 npm package: lodash-es internal (JS-only)
×2 any-typed parameter SC2010
动态模式的执行机制:
┌─────────────────────────────────────────┐
│ 原生二进制主体 │
│ │
│ [静态编译的 TS 代码] ←→ JSCall 边界 │
│ ↕ 交叉验证 │
│ [QuickJS 引擎] (约620KB) │
│ ↕ │
│ → lodash-es (npm 包,嵌入) │
│ → axios (npm 包,嵌入) │
│ → 任何纯 JS/npm 依赖 │
└─────────────────────────────────────────┘
关键保障:跨静态/动态边界的每个值都会在运行时验证。如果你用 any 类型撒谎(声称某值是 number,实际上传了字符串),scriptc 会在边界处抛出可捕获的 TypeError,而不是让内存损坏静默发生。
// 静态区域的类型断言,scriptc 会实际验证
const config = JSON.parse(response) as Config;
// 如果 config.port 实际是 string 而非 number
// 访问时会抛出 TypeError,指向精确的错误路径
// "expected number at $.port, got string"
4.3 Native FFI:TypeScript 调用任意 C 库
// 用 TypeScript 声明你想调用的 C 函数签名
// 然后像普通 TS 函数一样调用它们
// 完全类型安全,编译时验证
// @scriptc/ffi: libcurl.a
interface CURL {
curl_easy_init(): pointer;
curl_easy_setopt(handle: pointer, option: number, param: pointer): number;
curl_easy_perform(handle: pointer): number;
curl_easy_cleanup(handle: pointer): void;
}
// 使用
const curl = dlopen('libcurl.so.4');
const easy_init = dlsym(curl, 'curl_easy_init');
// ... 完整的类型安全 FFI 调用
签名是类型安全的——如果你声明 curl_easy_setopt 接收 number,传 string 会在编译时报错(通过 C 类型检查)。长度界定(length-delimited)边界意味着不可能有缓冲区溢出。
4.4 编译时计算(comptime)
import { comptime } from 'scriptc';
// 在编译时计算,结果直接 bake 进二进制
// 不需要运行时代价
const CONFIG = comptime(() => {
// 这段代码在编译时执行,结果直接写入 .data 段
const env = process.env;
return {
version: JSON.parse(env.BUILD_META || '{}').version,
timestamp: new Date().toISOString(),
};
});
comptime 在编译器内部的隔离 VM 中运行 TS 代码,与二进制运行时的 JS 语义完全隔离——编译时是编译时,运行时是运行时。
五、正确性工程:800+ 测试的字节级验证
这是 scriptc 最令人印象深刻的地方:正确性不是"感觉对",是逐字节证明。
5.1 差异测试(Differential Testing)
┌─────────────────────────────────────────────────────────┐
│ 800+ 测试用例 │
│ │
│ 每个测试在两条路径上同时运行: │
│ │
│ [Node.js] ←────── oracle ──────→ [scriptc 原生二进制] │
│ ↓ ↓ │
│ stdout stdout │
│ stderr stderr │
│ exit code exit code │
│ ↓ ↓ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ DIFF: stdout ✗ stderr ✗ exit_code ✗ → FAIL │ │
│ │ DIFF: all match ✓ → PASS │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
测试语料库涵盖:
- TC39 规范一致性测试:数值的最短往返格式化(100万次模糊测试)
- Node.js API 兼容性测试:各 API 的边界情况
- HTTP 服务器压力测试:客户端同时连接两条路径,对比行为
- 异常与错误对象测试:错误码、错误信息、错误类型的精确匹配
README 明确说:Number formatting is JS-exact (shortest-roundtrip, fuzz-verified against Node on a million doubles)。这不是宣传语,是工程实践的描述。
5.2 内存安全车道(Memory Safety Lane)
# 在每次 CI 中,800+ 测试用例在 ASan 下重新运行
$ SCRIPTC_SAN=1 pnpm test
# AddressSanitizer 检测项:
# - 堆内存越界写入/读取
# - use-after-free
# - 双重释放
# - 引用计数泄漏(RC 审计:每 100ms 扫描一次 GC root,报告泄漏对象)
#
# 任意一项违规 → build failure 🚨
scriptc 的 C 运行时虽然用 C 编写(手动内存管理),但通过 ASan + RC 审计两道关卡确保没有内存安全问题。Rust 做这件事有所有权系统保驾护航;scriptc 用测试覆盖做到了同等保证。
5.3 刻意记录的分歧(Known Divergences)
README 坦诚列出了 scriptc 与 Node.js 之间所有已知的不一致之处,每个都有编号。这些分歧主要是:
- 时序内部细节:如
setTimeout的最小延迟精度差异 - 错误对象属性:如
error.stack的格式细微差异 - 文件系统事件顺序:
fs.watch的触发顺序
关键原则:Nothing diverges silently(没有任何分歧是静默的)。测试套件确保分歧不会扩大,已记录的分歧不会在升级中悄悄消失。
六、性能真相:数字不会说谎
6.1 横向对比数据
| 维度 | scriptc | Node.js SEA | Go | Rust | Zig |
|---|---|---|---|---|---|
| 启动时间 | ~2.4ms | ~47ms | ~8ms | ~3ms | ~2ms |
| 二进制大小 | 170-200KB(静态)~3MB(+QuickJS) | 60-100MB | ~2MB | ~1MB | ~200KB |
| 内存 RSS | 1-4MB | 67-116MB | 8-20MB | 5-15MB | 3-10MB |
| 类型安全 | ✅ 编译时+运行时双重 | ❌ | ❌ | ✅ | ✅ |
| npm 生态 | ✅(via --dynamic) | ✅ | ❌ | ❌ | ❌ |
| JS 精确语义 | ✅ 字节级 | ✅ | ❌ | ❌ | ❌ |
数据来源:scriptc README,对比基准为 Apple M 系列芯片,相同算法实现的字节级输出一致性验证。
6.2 关键性能分析
启动时间:这是 scriptc 最突出的优势。~2ms 的启动时间意味着什么?
- Node.js 的 47ms 主要消耗在 V8 初始化、JIT 编译warm-up上
- scriptc 的二进制是预编译的机器码,直接由 CPU 执行,OS 加载后立即进入
main() - 对于短时 CLI 工具、Lambda 函数、冷启动频繁的 Serverless 场景,2ms vs 47ms 是质变
二进制大小:178KB 的斐波那契是怎么来的?
$ scriptc build fib.ts && ls -la fib && otool -L fib
-rwxr-xr-x fib # 178KB
# 依赖分析(otool -L)
fib:
/usr/lib/libSystem.B.dylib # macOS 系统库,无额外依赖
# 无 libc++、无 libuv、无任何运行时 .dylib
scriptc 的运行时是静态链接进二进制的,不依赖外部动态库(macOS 的 libSystem.B.dylib 是系统内核接口,任何程序都要用)。这意味着:
- ✅ 部署时零依赖
- ✅ 不会出现"在本地跑得通,在服务器上报动态库缺失"的经典问题
- ✅ 镜像构建时不需要安装 Node.js 运行时
内存占用:1-4MB vs 67-116MB
- Node.js 的内存主要消耗在 V8 的堆管理、JIT 编译缓存、内建对象初始化
- scriptc 的运行时用 C 手写,没有 V8 的复杂性,按需分配
- 对于微服务/容器化场景,内存节省直接影响成本
6.3 scriptc 的性能局限(诚实分析)
// 场景1:CPU-bound 计算密集型
// scriptc ✅ 优秀,与 Rust/Go 持平
// 场景2:需要 JIT 热的长时间运行服务
// scriptc ⚠️ 无 JIT,同一段热点代码在 V8 下会渐进优化
// scriptc 没有渐进优化,但避免了 V8 的预热开销
// 场景3:大型 npm 依赖树(--dynamic)
// scriptc ⚠️ QuickJS (~620KB) + 依赖 JS 大小
// 比 Node.js 小很多,但比纯静态编译大
// 场景4:需要最新 TC39 特性的前沿代码
// scriptc ⚠️ 需要等待 runtime 实现
// 目前支持 ES2025 子集,非全部特性
// 场景5:强类型断言误用(JSON.parse as Config)
// scriptc ✅ 运行时验证,更安全
// 性能代价很小(结构体字段校验)
七、实用场景:什么情况下值得用 scriptc?
7.1 最佳拍档场景
CLI 工具和构建脚本
// build.ts — 一个 TypeScript 编写的构建脚本
// 以前:node build.ts(依赖 Node.js 环境)
// 现在:scriptc build build.ts && ./build(原生二进制,零依赖)
import { readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import { createHash } from 'crypto';
function computeHash(file: string): string {
const content = readFileSync(file);
return createHash('sha256').update(content).digest('hex');
}
const files = process.argv.slice(2);
for (const file of files) {
console.log(`${file}: ${computeHash(file)}`);
}
# 分发:以前需要目标机器安装 Node.js
# 现在:直接分发二进制,任何 macOS/Linux 机器直接运行
scriptc build build.ts -o hash-tool && ./hash-tool src/**/*.ts
Serverless 函数冷启动优化
// handler.ts — AWS Lambda / Vercel Functions
export async function handler(event: APIGatewayEvent) {
const body = JSON.parse(event.body || '{}');
// 处理逻辑...
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
}
在 Serverless 环境中,冷启动时间是直接影响用户体验和计费成本的关键指标。用 scriptc 编译的函数冷启动约 2ms,对比 Node.js 的 ~50ms,延迟降低 96%,成本节省显著。
嵌入式和 IoT
二进制 170KB、体积小、无运行时依赖——这对树莓派、嵌入式 Linux、容器镜像构建都是极大优势。
7.2 过渡策略
如果你有现有的 Node.js/TypeScript 项目想迁移到 scriptc:
# 第一步:检查覆盖率
scriptc coverage src/index.ts
# 第二步:按 blocker 逐个修复
# SC1090: 可选参数作为值传递 → 用闭包包裹
# SC2020: 动态 API → 找静态替代或降级到 --dynamic
# 第三步:启用动态模式(npm 依赖)
scriptc coverage src/index.ts --dynamic
# 如果剩余 < 5% 动态语句,可以接受
# 第四步:最终构建
scriptc build src/index.ts --dynamic -o my-app
八、源码级实现:自己动手写一个简化版编译器
理解了 scriptc 的架构后,我们来动手实现一个概念验证级别的简化版脚本编译器,帮助加深对整个管道的理解:
// step1_tokenizer.ts — 手写词法分析器(简化版)
// 这段代码本身就可以用 scriptc 编译成原生二进制运行!
type TokenKind =
| 'NUMBER' | 'IDENT' | 'PLUS' | 'STAR' | 'LPAREN' | 'RPAREN'
| 'EOF';
interface Token {
kind: TokenKind;
value: string;
pos: number;
}
function tokenize(source: string): Token[] {
const tokens: Token[] = [];
let pos = 0;
while (pos < source.length) {
const ch = source[pos];
if (/\s/.test(ch)) { pos++; continue; }
if (/\d/.test(ch)) {
let num = '';
while (pos < source.length && /\d/.test(source[pos])) {
num += source[pos++];
}
tokens.push({ kind: 'NUMBER', value: num, pos });
}
if (/[a-zA-Z_]/.test(ch)) {
let ident = '';
while (pos < source.length && /[a-zA-Z0-9_]/.test(source[pos])) {
ident += source[pos++];
}
tokens.push({ kind: 'IDENT', value: ident, pos });
}
if (ch === '+') tokens.push({ kind: 'PLUS', value: '+', pos++ });
else if (ch === '*') tokens.push({ kind: 'STAR', value: '*', pos++ });
else if (ch === '(') tokens.push({ kind: 'LPAREN', value: '(', pos++ });
else if (ch === ')') tokens.push({ kind: 'RPAREN', value: ')', pos++ });
}
tokens.push({ kind: 'EOF', value: '', pos });
return tokens;
}
// 一个使用 tokenizer 的表达式解释器
function evalExpr(source: string): number {
const tokens = tokenize(source);
let i = 0;
function peek() { return tokens[i]; }
function consume() { return tokens[i++]; }
function parseExpr(): number {
let left = parseTerm();
while (peek().kind === 'PLUS') {
consume(); // 跳过 +
left = left + parseTerm();
}
return left;
}
function parseTerm(): number {
let left = parseFactor();
while (peek().kind === 'STAR') {
consume(); // 跳过 *
left = left * parseFactor();
}
return left;
}
function parseFactor(): number {
const tok = peek();
if (tok.kind === 'NUMBER') {
consume();
return parseInt(tok.value, 10);
}
if (tok.kind === 'LPAREN') {
consume(); // 跳过 (
const val = parseExpr();
consume(); // 跳过 )
return val;
}
throw new Error(`Unexpected token: ${tok.kind}`);
}
return parseExpr();
}
// 测试
console.log(evalExpr('2 + 3 * 4')); // 14
console.log(evalExpr('(2 + 3) * 4')); // 20
// step2_typecheck.ts — 使用 TypeScript API 做类型检查
// scriptc 的编译器前端正是这样复用 tsc API 的
import * as ts from 'typescript';
function checkTypes(fileContent: string, fileName: string) {
// 创建 LanguageService(完整类型检查能力)
const compilerOptions: ts.CompilerOptions = {
target: ts.ScriptTarget.ES2025,
strict: true,
noEmit: true, // 不生成 JS,只做检查
};
const host = ts.createCompilerHost(compilerOptions);
const originalGetSourceFile = host.getSourceFile;
host.getSourceFile = (name, ver) => {
if (name === fileName) {
return ts.createSourceFile(name, fileContent, ver);
}
return originalGetSourceFile.call(host, name, ver);
};
const program = ts.createProgram([fileName], compilerOptions, host);
const diagnostics = ts.getPreEmitDiagnostics(program);
if (diagnostics.length > 0) {
diagnostics.forEach(d => {
const msg = ts.flattenDiagnosticMessageText(d.messageText, '\n');
console.error(`[${d.file?.fileName}] ${msg}`);
});
return false;
}
return true;
}
// 使用
const code = `
function fib(n: number): number {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
const result: number = fib(30);
`;
const ok = checkTypes(code, 'fib.ts');
console.log(ok ? '✅ 类型检查通过' : '❌ 类型错误');
// step3_codegen.c — 简化版 AST → C 代码生成
// 这是 scriptc 后端要做的事情的简化演示
#include <stdio.h>
#include <stdlib.h>
typedef struct {
int type; // 0=number, 1=binop, 2=call
union {
double num;
struct { int op; void* left; void* right; } binop;
struct { char* name; void** args; int n_args; } call;
} u;
} ASTNode;
// 简化版:直接将表达式翻译成 C
void emit_c(ASTNode* node, FILE* out) {
if (node->type == 0) { // number literal
fprintf(out, "%.1f", node->u.num);
} else if (node->type == 1) { // binary op
fprintf(out, "(");
emit_c(node->u.binop.left, out);
char* op = node->u.binop.op == '+' ? "+" : "*";
fprintf(out, " %s ", op);
emit_c(node->u.binop.right, out);
fprintf(out, ")");
} else if (node->type == 2) { // function call
// 特殊处理内置函数
if (strcmp(node->u.call.name, "fib") == 0) {
fprintf(out, "fib(");
for (int i = 0; i < node->u.call.n_args; i++) {
if (i > 0) fprintf(out, ", ");
emit_c(node->u.call.args[i], out);
}
fprintf(out, ")");
}
}
}
int main() {
// 生成 C 源码
FILE* f = fopen("generated.c", "w");
fprintf(f, "#include <stdio.h>\n\n");
fprintf(f, "double fib(double n) {\n");
fprintf(f, " return n < 2 ? n : fib(n - 1) + fib(n - 2);\n");
fprintf(f, "}\n\n");
fprintf(f, "int main() {\n");
fprintf(f, " printf(\"%%.0f\\n\", fib(30));\n");
fprintf(f, " return 0;\n");
fprintf(f, "}\n");
fclose(f);
// 编译 C → 原生二进制
system("clang generated.c -o fib -O2");
printf("✅ 编译完成,生成 fib 二进制\n");
return 0;
}
# 完整演示:编译一个 fib.ts 到原生二进制
$ scriptc build fib.ts && ./fib
832040
# 查看中间产物
$ ls .scriptc/
fib.c # 生成的 C 代码(~80行)
fib.ir.json # IR 序列化
fib # 原生二进制(178KB)
# 对比原始 TypeScript
$ wc -l fib.ts
7 fib.ts
# 对比生成的 C 代码
$ wc -l .scriptc/fib.c
83 .scriptc/fib.c
九、技术边界与冷静思考
scriptc 并不是银弹。以下几个场景,scriptc 目前不适合:
9.1 边界场景
强依赖 DOM/Browser API:任何
document.getElementById、window.fetch(除非用 shim)等浏览器特有 API,scriptc 无法处理(这是 TypeScript 编译为 Node.js 运行时也存在的问题)TC39 最前沿特性:最新 ES 提案如果 scriptc runtime 尚未实现,代码会进入 rejection 而非静默降级
大型 npm 依赖树(>20 个包):QuickJS 嵌入 + 所有依赖 JS 会让二进制膨胀,此时 pkg/nexe 可能更合适
需要 JIT 优化的长期运行热服务:V8 的 JIT 编译器对长时间运行的热点代码有渐进优化,scriptc 是 AOT 编译,无 JIT
9.2 当前局限性
从 README 中提炼的已知局限:
- 整数推断:还未实现(number 全部当作 f64),对大整数有精度损失风险
- 所有权分析:还在 roadmap,闭包的精确生命周期分析尚未完成
- Windows 调试体验:支持编译,但 debug 工具链不如 macOS/Linux 完善
- 多线程:当前版本无共享内存多线程支持(Go、Rust 在这方面领先)
9.3 竞品对比选型指南
我的场景 推荐方案
─────────────────────────────────────────────────────
CLI 工具(需要 Node.js API) scriptc ✅
Serverless 函数 scriptc ✅
短时脚本 scriptc ✅
浏览器应用 原生 TypeScript/JS ✅
强依赖 npm 生态的 Node 服务 Node.js / Bun ✅
对二进制体积有极致要求 Rust / Zig ✅
长期运行的 CPU 密集型服务 Go / Rust ✅
需要 JIT 优化的热路径 Node.js / Bun ✅
团队 TypeScript 技术栈转型 scriptc ✅
十、未来展望:TypeScript 原生化的下一步
scriptc 的出现,揭示了一个更大的趋势:JavaScript/TypeScript 的"静态化"正在成为一条独立的技术路线。
10.1 即将到来(Roadmap)
从项目结构和 CHANGELOG 可以推测的方向:
- 整数推断优化:将
number类型的值域分析提前到编译期,局部变量如果是小整数就用 i32/i64,不用 f64,减少装箱开销 - 所有权分析(Ownership Analysis):精确分析闭包对变量的引用范围,减少不必要的堆分配
- WebAssembly 后端:除了 LLVM/C,WebAssembly 作为第三代码生成目标,打通浏览器直接运行
- 包管理器集成深化:
tsconfig中的"scriptc": true选项,一行配置让 tsc 自动调用 scriptc
10.2 更大的图景
Vercel 发布 scriptc 的战略意图很清晰:
Vercel 平台
│
├─ 前端:Next.js(React 编译优化)
├─ 边缘:Edge Runtime(V8 Isolates,2ms 冷启动)
└─ CLI/工具链:scriptc(TypeScript 原生二进制)
"从代码到执行"的全链路优化:
- 前端:服务端渲染 + 流式传输(Next.js)
- 工具:零运行时 CLI(scriptc)
- 部署:冷启动极快的边缘函数(Edge Runtime)
- 构建:极速的 Turbopack
TypeScript 开发者不再需要"编译成 JS → 依赖 Node.js 运行时"。Vercel 的愿景是让 TypeScript 从源码到原生,每一层都没有 JS 引擎这个"债务"。
10.3 对 TypeScript 生态的影响
scriptc 的出现可能催生一个新的生态位:"Native TypeScript" 赛道。
- 会出现专门为 scriptc 静态编译优化的 TypeScript 代码风格规范
@types/node之外会出现@scriptc/types专门标注 scriptc runtime 支持度- CI/CD 流水线会多一个
scriptc build步骤 - 开发者心态从"这段代码在 Node 上跑"变为"这段代码原生跑不了吗?"
结语:编译器艺术的文艺复兴
scriptc 的本质,是把 TypeScript 编译器前端(成熟的、工业级的)和 C 运行时(手写的、精确的)连接起来,中间插一个类型化的 IR 和 LLVM。工程上它并没有发明新东西,但它把"不可能"组合在了一起:TypeScript 的类型安全 + C 的运行效率 + JS 的精确语义。
178KB 的斐波那契,不是一个营销数字。它是对"这段 TypeScript 到底有多静态"这个问题的诚实回答。99% 的代码静态编译,1% 动态兜底,每一行代码的去向都有精确的日志和诊断。
这不是 JavaScript 生态的终结,而是**JavaScript 作为"可移植中间表示"**这个角色的一次进化。当 TypeScript 可以直接被编译成原生代码,它就不再只是"给 JS 加上类型",而是一等公民的系统编程语言——而 scriptc,是这个转变的第一个工业级实现。
项目地址:vercel-labs/scriptc
官方文档:scriptc.dev
本文测试环境:macOS arm64,Apple Silicon M系列芯片。Linux/Windows 行为略有差异(linker、kqueue vs epoll),详见官方 CI 的 differential test lanes。