TypeScript 编译成原生二进制:难的不是编译,是拒绝编译
Vercel Labs 开源 scriptc,把 TypeScript 编译成不含 V8 的原生二进制。这条路 2016 年就有人走了。
- 不是新叙事:从 ts2c 到 Porffor 已经走了十年
- 分水岭在 三 档:静态编译 / 嵌引擎 / 拒绝编译并报错码
- 静态产物:170–200 KB,启动 2.4 ms,内存 1–4 MB
- 对照 Node SEA:60–100 MB,47 ms,67–116 MB
- Perry 早半年:押的是 Node 兼容性和 11 个平台
- 真正的争议:不是谁抄谁,是两边都由 AI 大规模生成
- 结论:CLI 和 Serverless 可以试,别现在卸载 Node
这条路已经有人走了十年
scriptc 不是第一个把 TypeScript 编译成原生代码的项目,甚至排不进前五。
| 项目 | 起始 | 做的事 |
|---|---|---|
| Static TypeScript (MakeCode) | 2016-01 | TS 子集 → ARM Thumb,跑在 micro:bit 上 |
| ts2c | 2016-07 | JS/TS → 可读的 C89 |
| AssemblyScript | 2017-09 | TS 风格子集 → WebAssembly |
| TypeScriptToLua | 2017-12 | TS → Lua |
| Porffor | 2023-06 | AOT 编译 JS/TS → C / 原生 |
| Perry | 2026-01 | SWC + 自研 HIR + LLVM → 原生 |
| scriptc | 2026-07 | tsc + typed IR + LLVM → 原生 |
所以"TypeScript 能编译成可执行文件了"这个标题,十年里已经出现过很多次。每一次都是真的,每一次也都没能改变大部分人的工作流。
原因不在编译技术。把 number 变成机器指令是编译器课程作业的难度,LLVM 帮你做完了大半。难的是还原 JavaScript 的行为语义:字符串要保持 UTF-16 语义,Map / Set 要遵守插入顺序和身份规则,闭包捕获、异常、async/await、事件循环调度,每一条都不能只做个"差不多能跑"的版本。
前辈们的失败方式高度一致——要么只支持一个语言子集,要么在遇到搞不定的代码时静默降级。前者你得重写代码,后者你得到一个跑起来行为和 Node 不一样的二进制,而且不知道是哪一行出的问题。
真正值得学的是三档分级
scriptc 最有价值的设计不是它能编译什么,而是它明确划出了拒绝编译什么。
三档的边界是显式的,不会静默滑动:
| 档位 | 产物 | 触发方式 |
|---|---|---|
| 静态编译 | 不含任何 JS 引擎,170–200 KB | 默认 |
| 动态执行 | 内嵌 quickjs-ng(约 620 KB),跨边界的值做运行时校验 | 必须显式加 --dynamic |
| 拒绝编译 | 没有产物,给出错误码、代码帧和改写建议 | 兜底 |
比如可选参数当作值传递会报 SC1090,Promise.reject 会报 SC2020。你还能跑 scriptc coverage app.ts 看到有多少语句进了静态档、多少被哪个错误码挡住了。
这个设计的价值在于 它把"不确定"变成了可决策的信息。传统打包工具(pkg、Node.js SEA)给你的是一个塞了整个运行时的大文件,你分不清哪部分真的被编译了;只支持子集的编译器给你的是一份要重写的代码,但不告诉你重写到什么程度才够。scriptc 给的是一份清单。
配套的验证手段也是同一个思路:800 多个语料程序同时在 Node 和原生二进制下跑,stdout、stderr、退出码必须逐字节一致;数字格式化按 JS 的最短往返规则实现,拿一百万个 double 对着 Node 模糊测试过;几十处和 Node 的故意差异全部编号列出来。
一句话——能编译什么是营销,拒绝编译什么才是工程。
Perry 押的是另一个注
Perry 比 scriptc 早半年,做的是同一件事,但技术选型几乎处处相反。
| 维度 | scriptc | Perry |
|---|---|---|
| 编译器实现语言 | TypeScript + C 运行时 | Rust |
| 前端 | 官方 tsc,复用类型收窄结果 | SWC 解析 + 自研类型系统 / HIR |
| 内存管理 | 引用计数 + 循环回收 | 分代 GC |
| 兜底策略 | 内嵌 quickjs-ng(--dynamic) | 无兜底,靠自研 runtime 覆盖 |
| 目标平台 | macOS arm64 为主,Linux/Windows 交叉编译 | 11 个,含 iOS/Android/watchOS/TV/WASM |
| 附带 UI | 无 | SwiftUI 风格 API,编译到平台原生控件 |
| 许可证 | Apache-2.0 | MIT |
两者的赌注不一样。scriptc 赌的是 边界诚实——覆盖面小一点没关系,但编译出来的东西必须和 Node 行为一致,且你知道哪些没编译。Perry 赌的是 兼容性广度——53 个 node:* 模块、Node 官方测试套件约 97% 通过率、hello world 约 330 KB,再加一整套跨平台 UI。
我更看好 scriptc 的路子。原因很 简单:这类项目的历史失败点从来不是"支持得不够多",而是"你不知道它什么时候会不一样"。97% 通过率听着漂亮,但剩下那 3% 落在哪、会不会正好是你依赖的那行,没人告诉你。
谁抄谁是个伪命题
社区里流传着"scriptc 抄 Perry"的说法。查了一圈,这个指控站不住:
- Perry 仓库建于 2026-01-19,scriptc 的 npm 包 2026-07-13、GitHub 仓库 2026-07-22 —— Perry 确实早半年,先行者的关注度它该拿
- 但两个仓库 贡献者零重叠,实现语言、编译器前端、内存管理策略全部不同
- 两个项目的 GitHub issue、PR、Hacker News 讨论里,找不到任何一条 Perry 作者的抄袭指控,也找不到 scriptc 维护者的回应
"做了同类产品"和"抄了代码"不是一回事。走 解析 → 类型表示 → lowering → 代码生成 → 链接 这套流程、用 LLVM 做后端,是所有原生编译器的通用骨架,架构相似证明不了任何东西。
真正在社区里发酵的争议是另一件事:这两个项目的代码,很大程度上是 AI 写的。
scriptc 一周内落地了 91.8 万行代码,Perry 半年 6175 次提交。Hacker News 上对 Perry 的质疑是"提交节奏快得诡异",作者本人回应得很坦然——Perry 确实是重度 AI 辅助的;对 scriptc 的质疑同样集中在"Vercel 怎么可能这么快"。
这才是值得讨论的问题。一个编译器的价值几乎全部押在正确性上,而正确性来自长期的边界打磨和真实项目的踩坑。AI 能在一周内生成 91.8 万行看起来合理的代码,但它没法替你生成那几万小时的生产环境验证。scriptc 的差分测试和错误码体系是个好的应对,但它现在只有 0.0.17 版本。
现在该用吗
scriptc 当前版本 0.0.17,主力平台只有 macOS arm64,any 类型的代码必须开 --dynamic 才能跑。Perry 覆盖面更广,但离完整兼容 Node 生态同样很远。生产环境的主服务不要碰。
值得现在就试的场景,共同点是 冷启动敏感、依赖简单、类型信息完整:
- 内部 CLI 工具 —— 单文件分发,用户不用装 Node,2.4 ms 启动对比 Node 的 47 ms 是体感级差异
- Serverless / 边缘函数 —— 常驻内存 1–4 MB 对比 Node 的 67–116 MB,直接换算成成本
- 需要闭源分发的小工具 —— 编译产物不含 JS 源码
不值得试的:任何重度依赖 npm 生态的服务端应用。开了 --dynamic 之后你其实又背回了一个 JS 引擎,只是从 V8 换成了 quickjs-ng,那不如直接用 Bun。
scriptc 最值得欢迎的地方,是 Vercel Labs 的入场把这条走 了十年的小众路线推到了更多人面前。至于它能不能走通,答案不在 GitHub star 数里,在两年后还有多少人在维护它。
References
- vercel-labs/scriptc —— Vercel Labs, Apache-2.0
- PerryTS/perry —— Ralph Küpper 等, MIT
- scriptc by Vercel —— Hacker News 讨论, 2026-07-26
- Perry —— Hacker News 讨论, 2026-05-30
- microsoft/pxt —— Static TypeScript 的开源实现, 2016
- CanadaHonk/porffor —— AOT JS/TS 编译器, 2023
- TS 能编译成可执行文件了! —— 微信公众号「全栈修仙之路」, 2026-07-28