看书和学习是思想的经常营养,是思想的无穷发展。——冈察洛夫

ScriptC:把 TypeScript 和 JavaScript 编译成真正的原生产物

项目地址:https://github.com/vercel-labs/scriptc

当我们谈到 JavaScript 和 TypeScript,最熟悉的画面通常是运行在 Node.js、浏览器,或者某种打包好的前端环境里。

ScriptC 走的是另一条更激进的路:它试图把 TypeScript 和 JavaScript 编译成 typed IR、可读的 C、文本形式的 LLVM IR、本地汇编和对象文件、本地可执行文件,以及 WebAssembly 模块。

它不是在已有运行时之上再包一层,而是把源代码直接推进到更底层的编译结果里。对它来说,脚本语言不一定只属于解释执行,也可以进入原生编译的轨道。

一个把脚本语言往原生世界推进的编译器

ScriptC 的定位很直接:TypeScript-to-Native Compiler。

README 里给出的说明表明,它使用 TypeScript compiler 作为前端,先把程序分析成中间表示,再继续向下生成多种目标产物。你可以停在 IR,也可以走到 C、LLVM IR、汇编、对象文件、可执行文件,甚至 WebAssembly。

这意味着,ScriptC 并不只是一个“把 TS 转一下格式”的工具。它的目标是把程序真正送进原生工具链,让代码能够以不同层级的形式存在。

对开发者来说,这种路径最直观的吸引力在于:同一份 TypeScript 或 JavaScript,可以根据需要落在不同目标上,而不只是停留在 Node 运行时中。

一次编译,多种落点

ScriptC 支持的输出类型很丰富。

它可以生成:

  • typed IR
  • readable C
  • textual LLVM IR
  • native assembly
  • objects
  • native executables
  • WebAssembly modules

这让编译过程像一条可停靠的长轨道。

如果你只是想看看程序被编译器内部处理成了什么样,可以停在 IR。
如果你希望和 C 工具链交互,可以输出 C。
如果你要和 LLVM 世界打交道,可以输出 LLVM IR。
如果你想更贴近机器,汇编和对象文件也能生成。
如果你想直接运行,ScriptC 可以构建出本地可执行文件。
如果你的目标是跨环境执行,WASM 也是它支持的方向之一。

一个编译器能输出什么,往往决定了它能进入什么样的工作流。ScriptC 在这方面给得很开。

静态构建与动态回退的边界

ScriptC 的一个重要特点,是它非常重视“静态能编译多少”。

README 说明,静态构建包含一个小型原生运行时,但不包含 Node 或 JavaScript 引擎。凡是无法静态编译的代码,都会被报告为诊断信息。

这意味着,它并不把所有代码都硬推到原生产物里。遇到无法静态处理的内容,系统会明确告诉你,而不是悄悄绕过去。

对于 npm 包或某些 any 类型代码,可以使用 --dynamic。这会把 npm 包的 JavaScript 嵌入可执行文件中,使得运行时不再依赖 node_modules。

ScriptC 在这里做了一个明显的选择:
能静态的尽量静态;
不能静态的,明确标出;
需要动态能力的,提供显式开关。

这让编译结果的边界更清楚,也让用户知道自己手里的可执行物到底处于哪一种状态。

scriptc run:从源码到运行,只要一步

README 提供了一个很直观的例子。

先写一个 hello.ts:

1
2
const who = process.argv.length > 2 ? process.argv[2] : "world";
console.log(`hello, ${who}`);

然后直接运行:

1
scriptc run hello.ts

输出会是:

1
hello, world

这说明 ScriptC 不只是一个单向的编译器,它也支持从源码到运行的一步式体验。

对于试验脚本、验证构建链路、检查某个程序是否能够被原生路径处理,这种命令会很顺手。它把“写完、编译、运行”压缩成了一条更短的路径。

也可以直接构建成可执行文件

如果你希望输出独立程序,也可以直接构建:

1
2
scriptc build hello.ts -o hello
./hello ctate

程序会打印出:

1
hello, ctate

这说明构建结果不是只能在某个抽象层里存在,而是可以成为真正可执行的二进制产物。

对于一些希望把脚本语言代码纳入原生部署流程的场景,这样的输出方式很有吸引力。它让 TypeScript 和 JavaScript 更接近传统编译型语言的发布方式。

编译产物可以逐层查看

ScriptC 允许你停留在不同的产物层级。

README 展示了多个 --emit 例子:

  • --emit=ir
  • --emit=c
  • --emit=llvm
  • --emit=asm
  • --emit=obj

每一种都会把对应产物写入 .scriptc/ 目录,并且不同类型的输出会累积保留。重新构建某一类时,会更新它自己的文件。

这很像给编译链路装上了多个观察窗口。你不必每次都直接跳到最终可执行文件,而可以沿着编译路径逐层检查结果。

有时你需要看的不是程序能不能跑,而是它是怎么被降级、转换和组织的。ScriptC 在这方面给了开发者足够的可见性。

coverage:看看有多少代码能静态编译

ScriptC 还提供了 coverage 命令,用于检查程序有多少部分可以静态编译。

它会显示分析过的语句数量,并对每一个动态或不支持的位置给出编码后的诊断。

例如:

1
scriptc coverage hello.ts

可能得到类似结果:

1
2
3
4
statements analyzed   2
compile statically 2 (100%)

fully static — this program has no dynamic remainder.

这个命令很有意思。

它让“能否完全静态编译”变成一个可以量化、可以检查的指标,而不是模糊的感觉。对想要推进更纯粹原生构建的项目来说,这种可视化很有帮助。

Node API 也可以进入原生运行时

ScriptC 并不只面向纯脚本或简单示例。

README 中还展示了对部分 Node APIs 的支持。例如下面的 HTTP 服务代码:

1
2
3
4
5
6
7
8
9
10
import { createServer } from "node:http";

const server = createServer((req, res) => {
res.setHeader("content-type", "application/json");
res.end(JSON.stringify({ path: req.url }));
});

server.listen(8080, () => {
console.log("listening on http://localhost:8080");
});

这段代码也可以通过 ScriptC 构建成本地可执行文件:

1
2
scriptc build server.ts -o server
./server

这说明 ScriptC 的运行时并不是只能处理最基础的脚本,而是试图把部分 Node 生态中的能力也纳入原生目标。

它的态度并不是“离开 Node 就什么都不能做”,而是尽量把可支持的 Node API 迁移到它的原生运行时中。

面向 WebAssembly 的路径

ScriptC 也支持构建 WebAssembly 模块。

README 说明,WASI 和其他跨目标构建需要 Zig,并使用其 bundled WASI libc 通过生产级 LLVM 后端生成 WASI Preview 1 模块。

构建示例大致如下:

1
SCRIPTC_CC=zigcc SCRIPTC_TARGET=wasm32-wasi scriptc build hello.ts --no-keep-c -o hello.wasm

再运行:

1
SCRIPTC_CC=zigcc SCRIPTC_TARGET=wasm32-wasi scriptc run hello.ts

输出同样可以是:

1
hello, world

这个方向很重要,因为它让 ScriptC 不只面向本地原生平台,也面向 WebAssembly 这类更可移植的执行环境。

对需要把 TypeScript 代码推向沙箱、嵌入式执行环境或跨平台模块分发的场景来说,WASM 提供了另一种落点。

npm 包也能嵌入可执行文件

ScriptC 支持 --dynamic,可以把 npm 包的 JavaScript 嵌入到可执行文件中。

README 给出的例子使用了 picocolors:

1
2
3
import pc from "picocolors";

console.log(pc.green("hello from scriptc"));

先安装依赖,再构建:

1
2
3
npm install picocolors
scriptc build cli.ts --dynamic -o cli
./cli

程序运行后仍能工作,而不需要在运行时读取 node_modules。

这让构建结果更像一个封装好的成品,而不是必须依赖外部 npm 目录才能活下去的脚本壳子。

安装与运行环境要求

ScriptC 的安装说明也写得很明确。

编译器要求 Node.js 24 或更高版本。
--emit=ir|c|llvm 只需要 Node。
--emit=asm|obj 则需要与 ScriptC 一起安装的、对应平台的可选 helper。

安装方式如下:

1
npm install -g scriptc

这个要求说明,ScriptC 仍然依赖 Node 作为工具链入口,但它希望最终产物尽量摆脱对 Node 的依赖。

也就是说,Node 更像编译阶段的工作台,而不是运行阶段的必需品。

文档与开发流程

README 提到了完整文档入口,包括 quickstart、CLI reference 以及依赖项说明。

开发方面,仓库提供了自己的构建与测试流程:

1
2
3
pnpm install && pnpm -r build
vercel link && vercel env pull
pnpm test:sandbox

对于本地开发环境,普通 workspace build 不需要本地 LLVM 安装。若要重建原生 helper 或 runtime pack,则需要安装 CMake、Ninja 和固定版本的 LLVM 22 开发包,再运行对应平台的构建脚本。

测试方面,ScriptC 还强调会在 Node 和编译后的原生二进制下分别运行测试用例,然后逐字节比较 stdout、stderr 和退出码。完整门禁还会覆盖 AddressSanitizer 与运行时引用计数审计。

这说明它并不是只在“能编译”这件事上停留,而是在尽量验证编译后产物与源程序行为的一致性。

一个偏实验性的、但方向非常明确的编译器

README 反复强调,ScriptC 目前仍是实验性质。

它面向 macOS、Linux、Windows,以及通过 WASI Preview 1 的 WebAssembly 环境。它有明确的静态编译边界,也有对动态内容的显式处理方式。它支持多种输出层级,也允许开发者通过 coverage、--emit、--dynamic 和 --print=native-link-info 观察编译链路。

从这份说明可以看出,ScriptC 想做的并不只是一个“让 TypeScript 跑得更快”的工具。

它更像是在尝试回答一个更大的问题:
如果 TypeScript 和 JavaScript 也能像传统语言那样进入原生编译世界,那么它们可以被构建成什么?

ScriptC 给出的答案是:可以是 C,可以是 LLVM IR,可以是汇编,可以是对象文件,可以是原生可执行文件,也可以是 WebAssembly。它不是只给脚本语言留一个运行时位置,而是想把它们带到编译器和原生工具链的中心。