Skip to main content

TypeScript 编译成原生二进制:难的不是编译,是拒绝编译

· 8 min read

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-01TS 子集 → ARM Thumb,跑在 micro:bit
ts2c2016-07JS/TS → 可读的 C89
AssemblyScript2017-09TS 风格子集 → WebAssembly
TypeScriptToLua2017-12TS → Lua
Porffor2023-06AOT 编译 JS/TS → C / 原生
Perry2026-01SWC + 自研 HIR + LLVM → 原生
scriptc2026-07tsc + typed IR + LLVM → 原生

所以"TypeScript 能编译成可执行文件了"这个标题,十年里已经出现过很多次。每一次都是真的,每一次也都没能改变大部分人的工作流。

原因不在编译技术。把 number 变成机器指令是编译器课程作业的难度,LLVM 帮你做完了大半。难的是还原 JavaScript 的行为语义:字符串要保持 UTF-16 语义,Map / Set 要遵守插入顺序和身份规则,闭包捕获、异常、async/await、事件循环调度,每一条都不能只做个"差不多能跑"的版本。

前辈们的失败方式高度一致——要么只支持一个语言子集,要么在遇到搞不定的代码时静默降级。前者你得重写代码,后者你得到一个跑起来行为和 Node 不一样的二进制,而且不知道是哪一行出的问题。

真正值得学的是三档分级

scriptc 最有价值的设计不是它能编译什么,而是它明确划出了拒绝编译什么。

三档的边界是显式的,不会静默滑动:

档位产物触发方式
静态编译不含任何 JS 引擎,170–200 KB默认
动态执行内嵌 quickjs-ng(约 620 KB),跨边界的值做运行时校验必须显式加 --dynamic
拒绝编译没有产物,给出错误码、代码帧和改写建议兜底

比如可选参数当作值传递会报 SC1090Promise.reject 会报 SC2020。你还能跑 scriptc coverage app.ts 看到有多少语句进了静态档、多少被哪个错误码挡住了。

这个设计的价值在于 它把"不确定"变成了可决策的信息。传统打包工具(pkg、Node.js SEA)给你的是一个塞了整个运行时的大文件,你分不清哪部分真的被编译了;只支持子集的编译器给你的是一份要重写的代码,但不告诉你重写到什么程度才够。scriptc 给的是一份清单。

配套的验证手段也是同一个思路:800 多个语料程序同时在 Node 和原生二进制下跑,stdout、stderr、退出码必须逐字节一致;数字格式化按 JS 的最短往返规则实现,拿一百万个 double 对着 Node 模糊测试过;几十处和 Node 的故意差异全部编号列出来。

一句话——能编译什么是营销,拒绝编译什么才是工程

Perry 押的是另一个注

Perry 比 scriptc 早半年,做的是同一件事,但技术选型几乎处处相反。

维度scriptcPerry
编译器实现语言TypeScript + C 运行时Rust
前端官方 tsc,复用类型收窄结果SWC 解析 + 自研类型系统 / HIR
内存管理引用计数 + 循环回收分代 GC
兜底策略内嵌 quickjs-ng(--dynamic无兜底,靠自研 runtime 覆盖
目标平台macOS arm64 为主,Linux/Windows 交叉编译11 个,含 iOS/Android/watchOS/TV/WASM
附带 UISwiftUI 风格 API,编译到平台原生控件
许可证Apache-2.0MIT

两者的赌注不一样。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 版本。

现在该用吗

别急着卸载 Node

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

  1. vercel-labs/scriptc —— Vercel Labs, Apache-2.0
  2. PerryTS/perry —— Ralph Küpper 等, MIT
  3. scriptc by Vercel —— Hacker News 讨论, 2026-07-26
  4. Perry —— Hacker News 讨论, 2026-05-30
  5. microsoft/pxt —— Static TypeScript 的开源实现, 2016
  6. CanadaHonk/porffor —— AOT JS/TS 编译器, 2023
  7. TS 能编译成可执行文件了! —— 微信公众号「全栈修仙之路」, 2026-07-28