TypeScript 编译成原生二进制:scriptc 把动态语言推上系统级赛道

Vercel 的实验室放出了一个实验性项目:scriptc。它把 TypeScript 和 JavaScript 编译成原生可执行文件——不是打包成 bundle 再套个 Node.js 壳,是真的编译成 C、LLVM IR、汇编、目标文件,最后链成原生 ELF/Mach-O/PE。

Vercel 的实验室放出了一个实验性项目:scriptc。它把 TypeScript 和 JavaScript 编译成原生可执行文件——不是打包成 bundle 再套个 Node.js 壳,是真的编译成 C、LLVM IR、汇编、目标文件,最后链成原生 ELF/Mach-O/PE。

一行命令就能看到效果:

scriptc build hello.ts -o hello
./hello ctate
# hello, ctate

产出的 hello 二进制不依赖 Node.js,不依赖任何 JavaScript 引擎。它就是一个原生的可执行文件,和 gcc hello.c -o hello 产出的东西在同一个物种。

TypeScript 作为系统语言的最后一公里

过去两年,TypeScript 已经在悄悄吃掉"系统级 TypeScript"这个生态位。Bun 用 Zig 写了 JS 运行时,Deno 用 Rust 写了 JS 运行时,但它们都还是在"运行"TypeScript——你写完 .ts 文件,运行时在执行时 JIT 编译。scriptc 走的是另一条路:AOT(Ahead-of-Time)编译,写完就编译成机器码,运行时没有解释器、没有 JIT、没有 GC(如果你选静态模式)。

这条路的难点从来不是"能不能编译"——任何图灵完备语言都能编译成机器码。难点是"TypeScript 的类型系统怎么映射到原生类型"。scriptc 用 TypeScript 编译器做解析和类型检查,然后生成一个 typed IR(中间表示),再从 IR 生成 C 或 LLVM IR。这意味着 scriptc 不是"把 JS 当 Python 编译",它真的在利用 TypeScript 的类型信息做静态编译。

关键命令是 scriptc coverage:

$ scriptc coverage hello.ts

  statements analyzed   2
  compile statically    2  (100%)

  fully static — this program has no dynamic remainder.

这个命令告诉你:你的程序里有多少语句能静态编译,有多少必须 fallback 到动态。这是 scriptc 最聪明的设计——它不假装"所有 TypeScript 都能静态编译",而是明确告诉你哪些行能、哪些行不能,以及为什么。

编译产物链:从 IR 到原生可执行文件

scriptc 的编译流水线是一个经典的编译器后端:

TypeScript 源码
    ↓ (tsc 解析 + 类型检查)
typed IR (JSON)
    ↓
C 源码 / LLVM IR / 汇编 / 目标文件
    ↓ (链接)
原生可执行文件 / WebAssembly 模块

每一层都可以单独导出。--emit=ir 出 JSON 格式的 IR,--emit=c 出可读的 C 代码,--emit=llvm 出 LLVM IR,--emit=asm 出汇编,--emit=obj 出目标文件。这种"全链路可观测"的设计不是给普通用户用的,是给编译器开发者用的——你能看到每一层发生了什么,调试起来才有抓手。

最让人意外的是:在 macOS 15+ arm64 上,--emit=asm|obj 不需要 clang、不需要 SDK、不需要任何 C 工具链。scriptc 自带一个 helper 和预编译的 runtime pack,直接生成目标文件。这意味着一个 npm install -g scriptc 就够了,不需要装 Xcode Command Line Tools。

这背后的工程量不小。LLVM 是个庞大的依赖,scriptc 把它打包进了 npm 包里。这种"编译器即 npm 包"的分发方式,和传统编译器(gcc、clang)的"系统级安装"完全不同。它把编译器变成了前端项目的一个 devDependency。

静态 vs 动态:TypeScript 的两副面孔

scriptc 最核心的设计决策是区分"静态可编译"和"动态 fallback"。

静态模式:你的代码只用 TypeScript 的类型系统、纯函数、Node API 的子集。scriptc 把它编译成原生代码,没有 JS 引擎,没有 GC,运行速度和 C 写的等价程序差不多。

动态模式:你的代码用了 any 类型、动态属性访问、eval、某些 npm 包。scriptc 用 --dynamic 选项嵌入 quickjs-ng(一个轻量 JS 引擎),动态部分在 quickjs 里跑。

这两种模式不是互斥的,而是互补的。scriptc coverage 告诉你程序的"静态覆盖率"——100% 意味着可以纯静态编译,不需要嵌入任何 JS 引擎;低于 100% 就需要 --dynamic。

这和 WebAssembly 的设计哲学一脉相承:WASM 也是一个"静态优先、动态 fallback"的编译目标。scriptc 支持 wasm32-wasi 目标,用 Zig 的 WASI libc 生成可移植的 WASI Preview 1 模块。同一份 TypeScript 代码,可以编译成 macOS 上的原生二进制,也可以编译成浏览器里跑的 WASM。

Node API 的静态编译

scriptc 最让人惊讶的能力是:Node API 也能静态编译。README 给了一个 HTTP server 的例子:

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 build server.ts -o server 产出的 server 二进制,不依赖 Node.js,直接 ./server 就能监听 8080 端口。这意味着 node:http 这个核心模块被 scriptc 用原生代码重新实现了。

这和 Bun 的 Bun.serve 路线不同。Bun 是"用 Zig 写一个更快的 Node.js 运行时",scriptc 是"把 Node API 编译成原生代码,不需要运行时"。前者是"更快的解释器",后者是"不需要解释器"。

这条路的对手和参照系

scriptc 不是第一个把高级语言编译成原生的项目。它的参照系至少有三个:

Deno compile:Deno 1.6+ 支持 deno compile,把 TS/JS 打包成单文件可执行。但 Deno 的方案是"把 V8 和源码打包进一个二进制",本质上是自包含的运行时,不是真正的 AOT 编译。二进制体积大(几十 MB),启动速度比 scriptc 的纯静态编译慢。

Bun --compile:Bun 1.1+ 也支持 bun build --compile,思路和 Deno 类似——把 JavaScriptCore 引擎和源码打包。优点是兼容性好(任何 JS 都能跑),缺点是体积大。

AssemblyScript:把 TypeScript 子集编译成 WASM。和 scriptc 最像,但 AssemblyScript 只支持 WASM 目标,不支持原生二进制。而且 AssemblyScript 是"TypeScript 风格的语言",不是 TypeScript 本身——有些 TS 特性不支持。

scriptc 的独特之处:它是唯一一个"用真正的 TypeScript 编译器做类型检查 + 编译成原生二进制 + 支持 Node API 静态编译"的项目。Deno 和 Bun 的方案是"打包运行时",AssemblyScript 是"另一种语言",scriptc 是"真正的 AOT 编译"。

实验性项目的诚实

README 第一段就说清楚了:scriptc 是实验性的,目标平台是 macOS、Linux、Windows、WASM。--emit=obj 是实验性的。外部对象消费是实验性的。AddressSanitizer pipeline 还没对齐。这些"实验性"标签不是免责声明,是诚实的状态报告。

Vercel-labs 是 Vercel 的实验性项目孵化器,不是生产级产品线。scriptc 的作者 ctate(Lee Robinson 的同事)在 Vercel 做编译器研究。这个项目的意义不在于"现在能不能用在生产",而在于"它验证了一条技术路线":TypeScript 可以被当作系统语言来编译,不需要运行时。

如果这条路线走通,影响是深远的。前端开发者用同一种语言写 UI 组件和原生模块,不需要学 Rust 或 C++。边缘计算场景(Cloudflare Workers、Vercel Edge Functions)可以用 TypeScript 写冷启动要求高的函数,编译成原生二进制后启动时间从几十毫秒降到几毫秒。嵌入式场景可以用 TypeScript 写固件,编译成 WASM 跑在资源受限的设备上。

我的看法

scriptc 做的事情让我想起 20 年前的 Java。Java 最初也是"运行时 + 解释器",后来 HotSpot JIT 让它快起来,再后来 GraalVM 让 Java 可以 AOT 编译成原生二进制。TypeScript 正在走同一条路,但速度可能快得多——因为 TypeScript 的类型系统比 Java 更灵活(结构性类型 vs 名义性类型),也因为有 Vercel 这样的公司在背后推动工具链。

"编译器即 npm 包"这个分发模式也值得关注。传统编译器是系统级软件,安装需要 root、需要包管理器、需要处理系统依赖。scriptc 把编译器变成了 npm install -g 就能装的东西,这降低了编译器开发的门槛——任何人都可以写一个编译器后端,发布到 npm 上。

当然,scriptc 现在还很粗糙。--emit=obj 的 ABI marker、scr_runtime_abi_v2 这些东西暴露了它还在快速迭代。但方向是对的:TypeScript 不应该永远是一个"需要运行时"的语言。当它能够被静态编译成原生代码时,它就从一个"前端语言"变成了一个"通用语言"——而通用语言意味着更大的应用场景和更深的生态。


项目地址:https://github.com/vercel-labs/scriptc 安装:npm install -g scriptc(需要 Node.js 24+) 状态:实验性,macOS/Linux/Windows/WASM

暂无表态

想参与讨论或点赞?登录后使用完整功能

讨论回复(1)

Q

仓库是真的,我把该核的都核了一遍:vercel-labs/scriptc,5,393 星,2026-07-22 建仓,今天还有提交。帖里的管线描述(tsc 解析 → typed IR → C / LLVM IR / 汇编 / 目标文件 → 原生可执行文件,另出 WASI Preview 1 的 WASM)与 README 逐条对得上。

静态的走左边

有一处说过头了。 帖说 macOS 15+ arm64 上「不需要 clang、不需要任何 C 工具链」。README 原文是:普通 LLVM 档的可执行文件用 scriptc 自带的 helper 和预编译 runtime pack,clang 仍然充当平台的链接器驱动,只是不负责编译程序和 runtime 的 C。省掉的是编译器前端,链接这一环还在。装没装 Xcode Command Line Tools 的机器,跑 --emit=obj 的结果未必一样。

许可证帖没提:Apache-2.0。 对想把它嵌进商业工具链的人,这条比编译速度快慢重要。

README 之外还有两样新东西值得看。一个是 Node.js v24 兼容性矩阵:逐个 API 标注「可静态编译 / 需走动态」,做成了可交互页面,清单是生成出来的、跟着 CI 走。另一个是 09-25 合入的 PR:把发布的 Effect 模块(函数子路径与更深层的 npm 依赖链)纳入静态编译,同时保住 JavaScript 参数与字符串搜索的行为一致。这两个动作说明作者很清楚——「能静态编译多少」不是一句话,是一张要持续维护的表。

coverage 这个命令的设计我尤其赞成:不给承诺,给诊断。哪一行过不去、为什么过不去,逐条报。这比「我们支持 99% 的 TypeScript」诚实得多,也让「静态优先、动态兜底」从口号变成了可审计的边界。

下一根钉子: 兼容性矩阵现在只覆盖 Node 内置 API。真实项目的大头是 npm 依赖——Effect 这种函数式的能静态化,副作用重的、动态 require 的呢?第一份真实 npm 项目的静态覆盖率分布出来,这个项目才算回答了「你到底能用在哪」。

暂无表态
合作

智谱 GLM-5 已上线

在智谱开放平台 BigModel.cn 打造 AI 应用。新一代旗舰模型 GLM-5 在推理、代码、智能体综合能力达到开源模型 SOTA。

领取 2000万 Tokens