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 启动成本。为了避免错误结论,还应做几项调整:
- 先运行几轮预热,但不要把预热数据计入结果;
- 在空闲机器上多次执行,比较中位数和 P95,而不是只看单次结果;
- 增大循环次数,再测一次稳态计算速度;
- 确保 Node 版本、编译优化级别和目标架构都被记录;
- 分别记录产物大小和峰值内存,不要只比较耗时。
在 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 引擎执行的语言”向“可以选择不同后端的前端语言”推进了一步。现阶段,它的优势集中在启动与内存,代价则可能是执行速度、兼容性和工具链复杂度。只有把这些指标放进真实工作负载中测量,才能判断这个实验性编译器是否真正适合生产环境。