大家好,我是何三,独立开发者

170KB。

对,你没看错,170KB。一个 TypeScript 项目编译出来的原生二进制,大小只有 170KB。Node.js SEA(Single Executable Application)打出来的包是 60 到 100MB。差了差不多 500 倍。

而且启动只要 2.4 毫秒。Node 呢?47 毫秒。内存?scriptc 跑起来 1 到 4MB,Node 随随便便六七十兆起步。

Vercel 刚开源的 scriptc,全称是 TypeScript-to-Native Compiler,把 TypeScript 直接编译成本地可执行文件——零运行时、零依赖、连 JavaScript 引擎都不往二进制里塞。

scriptc 编译流程

你说 Node.js 这么多年了,大家不也用的好好的吗?

是,但你要真部署过 Node 服务就知道那体验有多酸爽。一个 Hello World 级别的 Docker 镜像,base image 一拉就是一百多兆,装完依赖再打包,随随便便奔着 300MB 去。启动还要等 V8 暖个炉、解析一波代码,冷启动能让你怀疑人生。

我有个朋友在搞 Serverless,每次冷启动都被客户投诉。后来他把一些轻量服务用 Rust 重写了,但问题是团队里没人愿意学 Rust。为了部署一个简单的 CRUD 接口去学 Rust,这合理吗?

scriptc 的逻辑很简单:你写的 TypeScript 其实大部分都是静态的——类型在编译时就确定了,根本不需要在运行时带一个完整的 JS 引擎。

那 Vercel 干了件什么事呢?他们把 tsc 类型检查的结果拿过来,逐个判断:这个函数能不能静态编译?这个变量能不能确定类型?能,就编译成 C 代码,再用 clang 编译成原生二进制。不能——比如用到 any、动态加载 npm 包——就退回到一个内嵌的 QuickJS 引擎(很小,只有 620KB)去动态执行。

大白话就是:能确定的东西编译期全搞定,确定不了的才在运行时兜底。

这就好比——算了,让我跑个题。

我之前刷到一个视频,说任天堂的游戏卡带为什么加载那么快,因为卡带里放的是只读存储器,处理器直接寻址读取,不需要把数据先拷到内存里。相当于游戏机"原生运行"卡带上的代码。scriptc 的思路其实有点像——你的 TypeScript 代码被"烧录"成原生指令了,不需要 Node 这个"模拟器"来跑。

好,回到正题。

体验一下有多简单:

npm install -g scriptc

随便写个 fib.ts:

function fib(n: number): number {
  return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
console.log(fib(30));

然后:

scriptc build fib.ts

你会看到一个 fib 文件出现在目录里,178KB。跑一下:

./fib
# 832040

如果你在 Node 里跑同样的代码,启动时间先耗掉你 47 毫秒,进程常驻内存 67MB 起步。scriptc 编译出来的二进制,2.4 毫秒启动,内存 4MB 封顶。

同样的代码,同样的输出,体积差了 500 倍。 这东西怎么说呢,就是……就是那种我第一次看到的时候愣了三秒没说话。

这还只是斐波那契。scriptc 支持的东西比你想的要全得多:

  • async/await、类、闭包、泛型、解构——语言层面几乎全覆盖
  • fs、path、process、crypto、http、net、fetch——Node 标准库移植了大半
  • fs.watchchild_process 带管道流、dgram UDP 这些都有

作者甚至拿它编译了一个反向代理服务器——真的能跑。

原理大概是这样,细节可能有出入——有懂的大佬欢迎指正。反正据我看到的,它的正确性验证挺变态的:800 多个测试用例,每个都在 Node 和原生二进制里各跑一遍,stdout、stderr、退出码必须逐字节一致。还有一个 AddressSanitizer 的内存安全检测通道,内存泄漏和野指针直接构建失败。

但也不是没坑。

首先,它目前只原生支持 macOS arm64。Linux 和 Windows 靠交叉编译,每个平台都有自己的差异测试通道。

其次,虽然你在 TypeScript 里写的代码大部分都可以静态编译,但总有一些跑不了。比如用 any 类型的变量、某些 npm 依赖包里的 JS 代码。scriptc 会给你一个覆盖率报告:

scriptc coverage app.ts

  statements analyzed   4481
  compile statically    4451  (99%)

  blockers:
      ×2  functions with optional parameters as values   SC1090
      ×1  Promise.reject                                 SC2020

99% 的代码能静态编译,剩下的 1% 它会告诉你哪里出了问题,并且有标准的错误码和改写提示。从不静默错误编译——这是原话。

为什么这么设计?别问我,问作者去。

哦对了,它还有一个叫 comptime 的东西——在编译期跑 TypeScript 代码,把结果直接打包进二进制。有点像 Zig 的 comptime,但用 TypeScript 写。

还有原生 FFI(--ffi 参数),可以让你直接调 C ABI 的动态库。这个功能——说实话,这块我也没完全搞懂怎么用,但看着挺猛的。

同类的东西其实不是没有。

Bun 算一个,它用 JavaScriptCore 引擎替代 V8,启动快、体积小,但本质上还是个 JS 运行时。scriptc 完全不是同一物种——它编译出来的是原生二进制,连引擎都不要。

还有个叫 ts-node 的,那就更不用比了,它只是个 TypeScript 的即时编译器,跑的时候还是 Node 那一套。

最有可比性的是 Node SEA(Single Executable Applications)。Node 官方搞的这个功能可以把你的应用打包成一个单文件,但打包出来的体积从 60MB 到 100MB 不等,因为里面塞了一整个 V8 引擎加 Node 运行时。scriptc 的 170KB 二进制,相当于是在说:你的代码本身就这么大,运行时?不需要。

如果你想了解更多类似的"极致瘦身"方案,我上次还写过一篇关于压缩 Docker 镜像体积的文章,关注后回复「容器」就能看。

回到 scriptc。

这个项目才上线 6 天就快 2,000 Star 了。Vercel 出品的项目,品控一向在线,但实话实说——目前还在早期阶段,生产环境慎用。如果你只是想给 Node 项目瘦个身、试试水,拿它编译个 CLI 工具或者内部小服务倒是完全 OK。

装不装都行,看你自己。但说真的——

170KB 的 TypeScript 原生二进制,你真的不想试试吗?

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

本文使用 MGO 编辑并发布

关注"何三笔记",回复"mgo" 免费下载使用