编程 Vercel scriptc 深度拆解:TypeScript 直译成 Native 二进制,跳过 Node.js 直接飞

2026-07-29 14:19:24 +0800 CST views 10

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 / EmscriptenTS → 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 做前端的解析和类型检查。这带来了几个关键优势:

  1. 零学习成本:你的 tsconfig.json@types/node 完全复用
  2. 类型系统精准:scriptc 看到的类型与开发者在 IDE 中看到的一致
  3. 错误信息友好:复用 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 提供了两种代码生成后端:

  1. LLVM 后端(默认):生成优化过的机器码
  2. 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:UDP
  • dns:域名解析
  • 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 APIfs(同步+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 之间所有已知的不一致之处,每个都有编号。这些分歧主要是:

  1. 时序内部细节:如 setTimeout 的最小延迟精度差异
  2. 错误对象属性:如 error.stack 的格式细微差异
  3. 文件系统事件顺序fs.watch 的触发顺序

关键原则:Nothing diverges silently(没有任何分歧是静默的)。测试套件确保分歧不会扩大,已记录的分歧不会在升级中悄悄消失。


六、性能真相:数字不会说谎

6.1 横向对比数据

维度scriptcNode.js SEAGoRustZig
启动时间~2.4ms~47ms~8ms~3ms~2ms
二进制大小170-200KB(静态)~3MB(+QuickJS)60-100MB~2MB~1MB~200KB
内存 RSS1-4MB67-116MB8-20MB5-15MB3-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 边界场景

  1. 强依赖 DOM/Browser API:任何 document.getElementByIdwindow.fetch(除非用 shim)等浏览器特有 API,scriptc 无法处理(这是 TypeScript 编译为 Node.js 运行时也存在的问题)

  2. TC39 最前沿特性:最新 ES 提案如果 scriptc runtime 尚未实现,代码会进入 rejection 而非静默降级

  3. 大型 npm 依赖树(>20 个包):QuickJS 嵌入 + 所有依赖 JS 会让二进制膨胀,此时 pkg/nexe 可能更合适

  4. 需要 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 可以推测的方向:

  1. 整数推断优化:将 number 类型的值域分析提前到编译期,局部变量如果是小整数就用 i32/i64,不用 f64,减少装箱开销
  2. 所有权分析(Ownership Analysis):精确分析闭包对变量的引用范围,减少不必要的堆分配
  3. WebAssembly 后端:除了 LLVM/C,WebAssembly 作为第三代码生成目标,打通浏览器直接运行
  4. 包管理器集成深化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。

推荐文章

程序员茄子在线接单