scriptc:让 TypeScript 脱离 Node 与 V8,直接编译成原生可执行文件

2026-09-25 25 预计阅读时间: 1 分钟
来源: infoq.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:10 分钟

Vercel Labs 推出的实验性编译器 scriptc,尝试改变 TypeScript 程序的交付方式:代码仍然使用 TypeScript 编写,并借助 TypeScript 编译器完成类型检查,但最终产物可以是 C、WebAssembly,或不依赖 Node、V8 等 JavaScript 引擎的原生可执行文件。

这条路线的吸引力并不在于“让 TypeScript 全面取代 C”,而在于解决一类更具体的问题:当程序生命周期很短、需要频繁冷启动,或者部署环境不希望携带完整 JavaScript 运行时时,原生二进制可能比 Node 应用更轻。初步基准测试显示,scriptc 在启动时间和内存占用方面占优,但持续执行速度目前更慢,这决定了它更适合被视为一种有明确边界的部署工具,而不是 Node 的直接替代品。

编译链真正改变了什么

传统 TypeScript 应用通常经历下面这条路径:

TypeScript → JavaScript → Node/V8 执行

scriptc 走的是另一条路径:

TypeScript → 类型检查 → C / WebAssembly → 原生或目标平台产物

这意味着部署单元可以从“JavaScript 文件、依赖目录和运行时”变成一个小型可执行文件。对于命令行工具、构建流水线中的辅助程序以及短生命周期任务,这种变化会直接影响冷启动、容器镜像大小和运行时内存。

不过,“不依赖 JavaScript 引擎”也意味着编译器必须处理 JavaScript 与 TypeScript 的动态语义。实际采用前,需要确认项目使用的语言特性和库是否受支持,尤其是:

  • 动态属性访问、原型链和运行时反射;
  • eval、动态导入及依赖运行时代码生成的功能;
  • Node 内置模块,例如 fs、net、worker_threads;
  • 依赖 V8 行为、原生扩展或 Node-API 的第三方包;
  • 异步 I/O、异常、垃圾回收和标准库在目标平台上的具体实现。

来源摘要没有给出这些兼容性细节,因此不能假设现有 npm 项目可以原样编译。更稳妥的做法是从纯计算、少依赖、边界清晰的小程序开始验证。

为什么启动更快,长时间运行却可能更慢

Node 启动程序时需要初始化运行时和 JavaScript 引擎。V8 还会在执行过程中收集信息,并通过即时编译优化热点代码。这些工作带来了启动成本,却可能让长时间运行的热点路径获得更高吞吐量。

原生编译路线省去了 JavaScript 引擎初始化,因此短命令的首屏输出或首次响应可能更快,基础内存占用也可能更低。但 scriptc 目前的初步结果显示,它在持续执行速度上落后于 Node。这并不矛盾:启动延迟、峰值内存和稳态吞吐量是三个不同指标。

因此,评估时应按工作负载分类:

工作负载 更值得关注的指标 scriptc 的潜在价值
CLI 子命令 启动时间、二进制大小 较高
Serverless 短任务 冷启动、内存、部署体积 较高,但要验证平台兼容性
CI 辅助工具 启动时间、分发便利性 较高
常驻 API 服务 吞吐量、尾延迟、生态兼容性 需要谨慎测试
CPU 密集型长任务 稳态执行速度 当前未必占优

WebAssembly 输出还需要单独理解:Wasm 可以带来可移植性和沙箱边界,但通常仍需要浏览器、WASI 运行时或宿主程序加载。它不一定等同于可直接执行的独立原生文件。

做一个可重复的对照实验

下面是一套可以改造的最小实验。它不假设 scriptc 的确切命令行参数,因为来源摘要没有提供 CLI 细节;请根据项目实际文档,把 scriptc 编译步骤替换成对应命令。

先创建一个不依赖 Node API 的 TypeScript 程序:

mkdir scriptc-lab
cd scriptc-lab

cat > app.ts <<'EOF'
function sumSquares(limit: number): number {
  let total = 0;
  for (let i = 0; i < limit; i++) {
    total += i * i;
  }
  return total;
}

console.log(sumSquares(100_000));
EOF

cat > tsconfig.json <<'EOF'
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "CommonJS",
    "strict": true,
    "outDir": "dist-js"
  },
  "include": ["app.ts"]
}
EOF

npm init -y
npm install --save-dev typescript
npx tsc --noEmit
npx tsc
node dist-js/app.js

npx tsc --noEmit 单独验证类型检查,后面的 npx tsc 则生成供 Node 对照运行的 JavaScript。接下来使用 scriptc 将同一个 app.ts 编译到 dist-native/app。下面仅表示目标形式,不代表其正式 CLI:

mkdir -p dist-native
# 按 scriptc 当前文档替换下一行的参数:
scriptc app.ts -o dist-native/app
chmod +x dist-native/app
./dist-native/app

如果实际 CLI 与示例不同,只需要保证最终原生程序位于 dist-native/app。两个程序应输出相同结果,然后再运行基准脚本:

cat > bench.py <<'EOF'
#!/usr/bin/env python3
import statistics
import subprocess
import time

COMMANDS = {
    "node": ["node", "dist-js/app.js"],
    "native": ["./dist-native/app"],
}
RUNS = 30

for name, command in COMMANDS.items():
    samples_ms = []
    expected_output = None

    for _ in range(RUNS):
        started = time.perf_counter_ns()
        result = subprocess.run(
            command,
            check=True,
            capture_output=True,
            text=True,
        )
        elapsed_ms = (time.perf_counter_ns() - started) / 1_000_000
        samples_ms.append(elapsed_ms)

        output = result.stdout.strip()
        if expected_output is None:
            expected_output = output
        elif output != expected_output:
            raise RuntimeError(f"{name} produced inconsistent output")

    ordered = sorted(samples_ms)
    p95_index = min(len(ordered) - 1, int(len(ordered) * 0.95))
    print(
        f"{name:>6}: median={statistics.median(samples_ms):8.3f} ms "
        f"p95={ordered[p95_index]:8.3f} ms output={expected_output}"
    )
EOF

python3 bench.py

这个脚本测量的是“创建进程到进程退出”的整体时间,因此适合观察 CLI 启动成本。为了避免错误结论,还应做几项调整:

  1. 先运行几轮预热,但不要把预热数据计入结果;
  2. 在空闲机器上多次执行,比较中位数和 P95,而不是只看单次结果;
  3. 增大循环次数,再测一次稳态计算速度;
  4. 确保 Node 版本、编译优化级别和目标架构都被记录;
  5. 分别记录产物大小和峰值内存,不要只比较耗时。

在 Linux 上还可以粗略查看峰值常驻内存:

/usr/bin/time -v node dist-js/app.js 2>&1 | grep 'Maximum resident set size'
/usr/bin/time -v ./dist-native/app 2>&1 | grep 'Maximum resident set size'

ls -lh dist-js/app.js dist-native/app

这些数字只适合同机对照。操作系统缓存、动态链接、调试符号和编译优化都会影响结果。

采用时不要只看一张基准图

scriptc 当前是实验性项目,适合用小范围试点回答具体问题,而不是直接迁移核心服务。一个稳妥的采用顺序可以是:

  • 选择一个无网络、无数据库、依赖极少的内部 CLI;
  • 建立 Node 与原生产物的输出一致性测试;
  • 测量启动延迟、P95、峰值内存、产物大小和持续吞吐量;
  • 检查 Linux、macOS 以及目标 CPU 架构的构建与分发流程;
  • 盘点 npm 依赖、Node API 和动态语言特性;
  • 保留 Node 版本作为回退路径;
  • 将编译器、C 工具链或 Wasm 运行时纳入供应链安全审查。

scriptc 最值得关注的地方,是它把 TypeScript 从“必须交给 JavaScript 引擎执行的语言”向“可以选择不同后端的前端语言”推进了一步。现阶段,它的优势集中在启动与内存,代价则可能是执行速度、兼容性和工具链复杂度。只有把这些指标放进真实工作负载中测量,才能判断这个实验性编译器是否真正适合生产环境。


相关推荐