scriptc
看书和学习是思想的经常营养,是思想的无穷发展。——冈察洛夫
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 | const who = process.argv.length > 2 ? process.argv[2] : "world"; |
然后直接运行:
1 | scriptc run hello.ts |
输出会是:
1 | hello, world |
这说明 ScriptC 不只是一个单向的编译器,它也支持从源码到运行的一步式体验。
对于试验脚本、验证构建链路、检查某个程序是否能够被原生路径处理,这种命令会很顺手。它把“写完、编译、运行”压缩成了一条更短的路径。
也可以直接构建成可执行文件
如果你希望输出独立程序,也可以直接构建:
1 | scriptc build hello.ts -o hello |
程序会打印出:
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 | statements analyzed 2 |
这个命令很有意思。
它让“能否完全静态编译”变成一个可以量化、可以检查的指标,而不是模糊的感觉。对想要推进更纯粹原生构建的项目来说,这种可视化很有帮助。
Node API 也可以进入原生运行时
ScriptC 并不只面向纯脚本或简单示例。
README 中还展示了对部分 Node APIs 的支持。例如下面的 HTTP 服务代码:
1 | import { createServer } from "node:http"; |
这段代码也可以通过 ScriptC 构建成本地可执行文件:
1 | scriptc build server.ts -o 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 | import pc from "picocolors"; |
先安装依赖,再构建:
1 | npm install picocolors |
程序运行后仍能工作,而不需要在运行时读取 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 | pnpm install && pnpm -r build |
对于本地开发环境,普通 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。它不是只给脚本语言留一个运行时位置,而是想把它们带到编译器和原生工具链的中心。
