12k
All articles

使用 scriptc 将 TypeScript 编译为原生二进制文件

scriptc将TypeScript编译为原生二进制,支持静态构建、620KB动态引擎、coverage检查和清晰诊断,定位阻塞代码。

OpenReplay Team
OpenReplay Team
使用 scriptc 将 TypeScript 编译为原生二进制文件

scriptc 可以将普通的 TypeScript 编译成自包含的原生可执行文件。静态构建产物中不包含任何 JavaScript 引擎——除非你的代码用到了正则表达式,此时才会链接一个正则解释器。真正的 TypeScript 编译器负责对程序进行类型检查,scriptc 将其降级为带类型的中间表示(IR),最终从另一端输出原生代码。

如果你用 TypeScript 写过 CLI 工具,你一定熟悉这种权衡:工具本身只有 40KB 的逻辑,但交付机制却是一个 100MB 的运行时、一个安装步骤,以及用户能明显感知到的启动开销。

有意思的地方并不在于”能产出二进制文件”这件事本身。其他工具也能通过把运行时打包进去来生成二进制文件。scriptc 的不同之处在于:它在可能的情况下把引擎完全剔除,并且会明确告诉你,程序中哪些部分它能处理、哪些不能。本文将介绍任意语法结构可能落入的三种归宿,以及那条能告诉你自己的代码属于哪一类的命令。

核心要点

  • scriptc 编译的就是你已经在写的 TypeScript。没有需要学习的方言,不需要额外标注,也没有替代性的标准库,类型检查由真正的 TypeScript 编译器完成。
  • 静态编译是默认模式,也是唯一模式,除非你传入 --dynamic,那会将约 620KB 的 quickjs-ng 嵌入二进制文件。
  • 既不属于静态层、也无法走动态层的代码会直接中止构建。你会得到一个 SC 错误码、出问题的具体行号,通常还有一条改写建议,而不是一个存在隐蔽错误的二进制文件。
  • 运行 scriptc coverage 会给出逐语句的判定结果:哪些部分进入静态层、哪些部分会引入引擎,以及为每个阻塞点标注的诊断代码。
  • 大多数 npm 包发布的是纯 JavaScript 加上独立的声明文件,这意味着静态层拿不到带类型的源码,因此真实的依赖树会把嵌入式引擎重新拉回二进制文件中。

scriptc 是什么,编译流水线如何运行?

scriptc 接收一个 .ts 入口文件,用 TypeScript 编译器进行类型检查,将检查通过的程序降级为带类型的 IR,并由此发出原生代码。scriptc README 将 LLVM 作为默认代码生成器,同时把 C 保留为一个永久可读的参考后端(通过 --backend c 选用),所以”TypeScript 到 C 再到 clang”只描述了两条路径中的一条。你喂给它的源码,就是你已经在 Node 上运行的源码。

安装就是一次全局 npm 安装,而可执行文件构建需要宿主机上有链接器驱动:

npm install -g scriptc

Quickstart 要求编译器运行在 Node 24 或更高版本上。可执行文件构建还需要平台链接器以及匹配的 SDK 或 sysroot,Platform Support 对其余细节有明确说明:在受支持的 macOS、Linux 和 Windows 宿主机上,LLVM 层会链接一个预编译的运行时包,因此只有在显式的 C 构建、LLVM 回退路径和 --sanitize 场景下才需要 C 编译器。通过 --emit=ir|c|llvm 选择的源码输出则只需要 Node。

一个最小程序,以及两条关键命令:

// slug.ts
function slug(title: string): string {
  return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
}
console.log(slug("Compile TypeScript to a Native Binary"));
scriptc run slug.ts
scriptc build slug.ts -o slug && ./slug

scriptc run 一步完成编译和执行,这正是你在 watch 循环中需要的。scriptc build -o 则产出你实际要交付的构件。

第一层:静态编译,默认模式

静态编译是 scriptc 的默认模式,除非你显式选择退出,否则你得到的就只有这一种模式。在 scriptc 首页 上,第一层被呈现为日常的 TypeScript:类与闭包、async/await、标准库,以及大多数程序会用到的那部分 Node,比如 fs、path、process 和 http。所有这些都会转换为原生代码,二进制文件中不含任何引擎。

实际支持面比这份概括清单所暗示的更广。介绍页面 将其分为三类。语言层面包括:带动态派发的单继承类、以 JavaScript 方式捕获变量的闭包、通过单态化(monomorphization)解析的泛型函数声明、借助 TypeScript 自身类型收窄处理的可辨识联合、与 JavaScript 调度方式完全一致的 async/await、带 finally 的异常、解构、展开、访问器、迭代器和模板字面量。标准库一类涵盖字符串、数组、Map 与 Set、JSON、Math、类型化数组以及 Error 继承体系。Node 一类则覆盖了 fs 的同步和 Promise 两种形式,外加 path、process、child_process、os、crypto、url/URL、zlib 和 timers,并且包含完整的服务端栈:net、http、https、tls、dgram、dns 和 readline。

这意味着能编译的不只是纯函数,一个 HTTP 服务同样可以:

// server.ts
import http from "node:http";

http.createServer((req, res) => {
  res.writeHead(200, { "content-type": "application/json" });
  res.end(JSON.stringify({ path: req.url }));
}).listen(3000);
scriptc build server.ts -o server && ./server

任何对静态支持面的枚举都会随着编译器的演进而过时。changelog 逐个版本地跟踪这些变化,而且每个版本都会附带一份机器可读的 surface-manifest.json,列出该版本静态层所支持的语言与标准库支持面,并为每个条目提供稳定的 id,便于工具对两个版本做差异比对。这个文件比任何文字清单都更长寿,包括本文这一份。

第二层:动态层及其 620KB 引擎

传入 --dynamic 会把一个 JavaScript 引擎嵌入二进制文件,除此之外没有任何其他方式会这么做。npm 依赖指南 把这个结果称为动态孤岛(dynamic island):一个约 620KB 的嵌入式引擎,用来运行那些无法静态化的部分,在实践中主要是 npm 包发布的 JavaScript,以及任何被类型检查器判定为 any 的内容。值在跨回静态代码时会被校验。该引擎是 quickjs-ng。

npm install picocolors
scriptc build cli.ts --dynamic -o cli

这里的设计要点在于”显式选择加入”。scriptc 生成的二进制文件绝不会悄无声息地长出一个引擎;那 620KB 永远是你主动要求的。由此带来两个结果。其一,包的 JavaScript 会在构建时被写入可执行文件,因此最终的二进制文件是自包含的,运行时没有理由去读取 node_modules。其二,边界是经过校验的而非被信任的:一个声明文件承诺返回 string 却实际返回了对象时,会抛出一个可捕获的 TypeError,而不是让那段基于相反假设编译出的原生代码去破坏内存。

第三层:编译期拒绝

scriptc 既无法静态编译、也无法路由到动态层的代码会导致构建失败。首页对这一层的承诺是:失败必须是可读的——一个具体的错误码、出问题的行,以及在大多数情况下一条关于如何改写的提示。不会有任何东西被悄悄替换成”差不多等价”的实现。诊断代码带有 SC 前缀,其中 SC3002 是你在 WASI 目标 上会遇到的那个:socket 与 fetch、子进程、信号 API 以及 fs.watch 都会在链接步骤之前中止构建,因为 Preview 1 没有给 guest 提供任何实现它们的途径。

这种三分法正是其余设计值得认真对待的原因。一个会悄悄把某个语法结构降级成”差不多等价”实现的编译器,会让它所有关于性能和语义的声明都变成有条件的。带着行号和改写建议拒绝生成代码,才使得静态层的承诺变得可验证。

scriptc coverage 如何告诉你代码是否合格?

scriptc coverage 让你无需迁移任何代码就能回答”我的代码能编译吗”。它逐条语句遍历程序,报告哪些进入静态层、哪些需要引擎、以及剩下的部分被什么阻塞了,并为每个阻塞点附上诊断代码。请在你真实的入口文件上运行它,而不是一个玩具文件。

Quickstart 用一个两条语句的 hello.ts 演示了该命令:分析了 2 条语句,2 条静态编译,100%,并附一行结论说明该程序没有动态残余。而 README 的示例则是一个真实项目,报告 4481 条语句中有 4451 条静态编译,即 99%。现实中的项目会打印出更低的百分比和一串具名站点。可以分三遍来读:标题百分比告诉你这个项目是否具备迁移的可能性;逐站点诊断告诉你阻塞在哪里;而每个阻塞项的具体身份则告诉你该用哪种修复方式。

阻塞项可以清晰地分为两类。一个无类型的 npm 导入不是你要去改写的东西,而是你要接受的现实,它意味着构建时需要加 --dynamic。而你自己代码中的宽松类型通常是可修复的:

// 强制进入动态层:payload 类型为 any
function port(config: any): number {
  return config.port + 1;
}

声明出具体形状,同一个函数就能静态编译:

interface Config { port: number }

function port(config: Config): number {
  return config.port + 1;
}

当分析因为类型错误或导入屏障而提前终止时,changelog 记录了 coverage 现在会打印与失败构建相同的诊断信息,包括代码片段框,而不是只给一行干巴巴的摘要。给该命令加上 --dynamic 则更进一步,会告诉你哪些站点最终将由嵌入式引擎来运行。

项目公布了哪些数据?

首页给出的 hello-world 二进制文件约为 320KB,启动时间约 4ms,唯一链接的库是 libSystem;相比之下,Node 运行时约 120MB,打印同一行文字大约需要 35ms。README 的基准测试表格对同一负载则更为乐观:170 到 200KB,启动约 2.4ms,而 Node 约为 47ms。这两处项目来源并不一致,因此有必要搞清楚某个数字出自哪里。无论如何,这些都是项目自己在其一等公民宿主平台 macOS 上针对 hello-world 得出的数据,而不是对你的应用的普遍性结论。

把它们当作下限,而不是预测。用 --dynamic 构建的二进制文件会携带引擎和嵌入的包 JavaScript,因此体积量级会发生变化。真正能干净地迁移到你自己估算中的数字是 620KB 的引擎成本,因为那是一项固定且有文档记载的增量,你要么承担、要么规避。

采用它的实际代价是什么?

scriptc 位于 vercel-labs 命名空间下,版本仍停留在 0.1.x。自 2026 年 7 月底发布以来,社区讨论的焦点正是这一状态:一个 Labs 项目是否能积累起”编译器身处你的构建流水线中”所要求的那种长期维护。仓库发布了带标签的 npm 版本和 Apache-2.0 许可证,但没有随附任何支持承诺或 SLA 声明。

更尖锐的现实限制在于生态。大多数 npm 包发布的是编译后的 JavaScript 加上独立的 .d.ts 声明,这让静态层拿不到可编译的带类型源码,于是那部分代码就在 --dynamic 下的嵌入式引擎中运行,引擎也随之进入你的二进制文件。完全没有声明文件的包则不会悄然降级:它们会在 类型检查关卡处失败,报出 TypeScript 标准的”缺少声明”错误,与在任何严格模式 TypeScript 项目中的表现完全一致。其他粗糙之处都有单独的文档记录,细到诸如 scriptc run 不会把额外的 CLI 参数转发给程序这样的细节,而 limitations 页面就是你在规划迁移之前值得通读的那份清单。

关于适配度的诚实判断是:一个类型严密、运行时依赖很少或没有的 CLI 或小型服务是强有力的候选;而一个依赖树很深的项目,则等于为其大部分代码买下了 620KB 的引擎外加嵌入的 JavaScript。安装 CLI,在你的入口文件上运行 scriptc coverage,让百分比和阻塞项列表来做决定,而不是那些标题数字。

常见问题

运行 scriptc 二进制文件的机器需要安装 Node.js 或 clang 吗?

不需要。scriptc 所需的一切都是构建期要求。编译器运行在 Node.js 24 上,可执行文件构建需要平台链接器驱动以及匹配的 SDK 或 sysroot。在受支持的 macOS、Linux 和 Windows 宿主机上,LLVM 层会链接一个预编译的运行时包而不是编译 C,因此只有在显式的 C 构建、LLVM 回退路径和 sanitizer 构建中才需要 clang 这类 C 编译器。可执行文件本身不需要 Node:静态构建携带一个小型原生运行时,不含 Node,也不含任何 JavaScript 引擎,唯一例外是当你的代码使用正则表达式时链接进来的正则解释器。使用 ir、c 和 llvm 这些 emit 目标的源码输出只需要 Node。

scriptc 能在 Mac 上构建 Linux 或 Windows 二进制文件吗?

可以。scriptc 面向 macOS、Linux、Windows 以及通过 WASI Preview 1 的 WebAssembly,其中 macOS arm64 是一等公民宿主平台。通过 zig 进行交叉编译是生成 Linux 和 Windows 二进制文件的一条路径,这两个目标也各自拥有原生辅助工具和运行时包,覆盖 Linux x64 与 arm64 以及 Windows x64。WASI 路径由 SCRIPTC_CC 和 SCRIPTC_TARGET 环境变量驱动,分别设为 zigcc 和 wasm32-wasi;而 Preview 1 中缺失的 API,比如 socket、子进程和文件系统监听,会在链接之前以 SC3002 失败。

当嵌入式引擎中运行的 npm 包修改了你传给它的对象时会发生什么?

静态侧永远看不到这个修改。在动态构建中,值是跨边界复制的而非共享的,因此由引擎执行的包所做的任何更改都不会影响静态侧的原始值,静态代码的任何更改也不会影响引擎侧的副本。scriptc 将此列为它有意偏离 JavaScript 的行为之一——在 JavaScript 中,两侧持有的会是同一个对象。

npm 依赖代码能否被静态编译,而不是在引擎中运行?

可以,通过实验性的 --npm-static 标志。你指定包名,或者传入 auto,编译器会尝试把它们从嵌入式引擎中抽离出来,并将其发布的 JavaScript 作为静态程序模块编译,类型由它们自己的声明文件提供。覆盖率较高但并不完整:静态编译器无法接纳的站点会被推迟处理并在报告中具名列出,而在预检中被拒绝的包会带着一条说明退回引擎,而不是让构建失败。运行 coverage 即可看到你的哪些包通过了这一关。

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.