
在实际 JavaScript 和 TypeScript 项目中我们早已习惯了 Node.js 运行时。无论是开发工具链、构建脚本还是后端服务Node.js 都扮演着执行引擎的角色。然而这种依赖也带来了部署上的复杂性你需要确保目标服务器安装了正确版本的 Node.js处理node_modules的依赖安装并应对潜在的版本冲突和性能开销。有没有一种可能让我们能将 TypeScript 代码直接编译成一个独立的、无需 Node.js 环境的原生可执行文件这听起来像是将 JavaScript 世界与 C/Go 这类编译型语言的部署体验相结合。本文将带你实测一种绕过 Node.js 运行时将 TypeScript 编译为原生机器码的方案探讨其原理、实现步骤、性能表现以及在实际工程中的适用边界。1. 理解“抛弃 Node.js 运行时”的技术背景与挑战当我们谈论“抛弃 Node.js 运行时”时并非指完全不用 Node.js 生态而是指在最终的分发和运行阶段不再需要一个独立的 Node.js 解释器node命令来执行 JavaScript 代码。传统的 TypeScript 开发流程是编写.ts文件 - 使用tsc编译为.js文件 - 在 Node.js 环境中运行.js文件。Node.js 在这里负责解释执行 JavaScript 字节码V8 引擎的即时编译产物并提供了文件系统、网络等原生模块绑定。要实现“直接编译为原生机器码”意味着我们需要一个工具链它能将 TypeScript或 JavaScript源代码连同其所有依赖包括那些原本需要 Node.js 原生绑定的模块一起编译成一个自包含的、针对特定操作系统和 CPU 架构的二进制可执行文件如 Linux 上的 ELF 文件macOS 上的 Mach-O 文件Windows 上的 PE 文件。这带来了几个核心挑战JavaScript 的动态特性JavaScript 是动态类型语言支持运行时类型检查、eval、Function构造函数等这些特性在静态编译阶段很难完全分析和优化。Node.js API 的替换代码中使用的fs、http、path等 Node.js 核心模块以及无数的 npm 包其中许多内部依赖这些核心模块都需要有对应的、能在原生环境中工作的实现。打包与树摇Tree Shaking需要将成千上万个模块打包进一个二进制文件同时尽可能剔除未使用的代码以控制最终文件大小。启动与执行引擎需要一个轻量级的、能够执行编译后代码的运行时引擎这个引擎本身可能是用 C 或 Rust 编写并链接到最终二进制文件中。目前社区中解决这些挑战的代表性工具有Bun自带打包与编译能力、Deno提供deno compile子命令以及一些专门的打包器如pkg。但本文聚焦的是一种更激进、更底层的思路使用像ScriptC或类似原理的工具尝试将代码编译为真正的、不依赖任何外部 JavaScript 引擎的机器码。需要明确的是由于 JavaScript 的动态特性完全的“AOTAhead-of-Time编译为机器码”极其困难当前大多数方案实质上是将 JavaScript 引擎如 V8 的精简版和你的代码一起打包形成一个“自包含的运行时”外观上是一个原生二进制文件。2. 环境准备与工具链选择在开始实测之前我们需要搭建一个基础的 TypeScript 开发环境并选择一个声称能将 TypeScript/JavaScript 编译为原生二进制的工具。根据输入材料中提到的“ScriptC”和“Perry”这很可能指向某个特定的实验性项目或文章案例。由于这些并非广泛使用的成熟工具如 pkg、nexe为了构建一个具有普适参考价值的教程我们将以vercel/ncc结合pkg的方案作为主线进行演示。这是一个在生产中经过更多检验的、能够生成独立可执行文件的组合方案。同时我们也会探讨纯编译型方案如使用 AssemblyScript 或通过 Emscripten 链的异同。首先确保你的开发机上已经安装了 Node.js ironic但我们的构建过程暂时还需要它和 npm。我们将使用它们来安装构建工具。# 检查 Node.js 和 npm 版本 node --version npm --version接下来创建一个新的项目目录并初始化一个 TypeScript 项目。mkdir ts-to-binary-demo cd ts-to-binary-demo npm init -y安装 TypeScript 和必要的类型定义。npm install --save-dev typescript types/node npx tsc --init编辑生成的tsconfig.json文件确保输出目录和模块系统设置合理。一个简单的配置示例如下{ compilerOptions: { target: ES2020, module: commonjs, outDir: ./dist, rootDir: ./src, strict: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true }, include: [src/**/*], exclude: [node_modules] }创建源代码目录和入口文件。mkdir src touch src/index.ts在src/index.ts中我们编写一个简单的 TypeScript 程序它包含一些基本的逻辑和 Node.js API 调用以便后续测试打包后的二进制文件是否还能正常工作。// src/index.ts import * as fs from fs/promises; import * as path from path; async function main() { console.log(Hello from TypeScript compiled to (pseudo) native binary!); // 测试文件系统操作 const currentDir process.cwd(); console.log(Current directory: ${currentDir}); try { const files await fs.readdir(currentDir); console.log(Files in current directory: ${files.length} items); if (files.length 0) { console.log(First file: ${files[0]}); } } catch (err) { console.error(Error reading directory:, err); } // 测试环境变量和参数 console.log(Node.js version (from process): ${process.version}); console.log(Command line arguments: ${process.argv.slice(2).join(, )}); } if (require.main module) { main().catch(console.error); }现在我们可以用传统方式运行它确保代码本身没有问题。npx ts-node src/index.ts # 或者先编译再运行 npx tsc node dist/index.js你应该能看到控制台输出目录信息和文件列表。至此一个标准的 TypeScript Node.js 项目准备完毕。接下来我们将引入工具链将其转换为独立二进制文件。3. 使用 ncc 与 pkg 生成独立可执行文件虽然pkg本身可以直接打包 JavaScript 文件但对于 TypeScript 项目一个更流畅的流程是先用vercel/ncc将 TypeScript 及其所有依赖编译并打包成一个单一的.js文件然后再用pkg将这个单文件封装成二进制。ncc能很好地处理模块依赖分析和树摇。首先安装vercel/ncc和pkg。npm install --save-dev vercel/ncc pkg使用ncc构建我们的入口文件。我们可以在package.json中添加一个脚本。{ scripts: { build:ncc: ncc build src/index.ts -o dist-single, build:pkg: pkg dist-single/index.js --targets node18-linux-x64,node18-macos-x64,node18-win-x64 --output dist-binary/myapp, build: npm run build:ncc npm run build:pkg } }解释一下这些命令和参数ncc build src/index.ts -o dist-single: 将src/index.ts及其所有依赖打包成一个文件输出到dist-single目录。默认输出文件名为index.js。pkg dist-single/index.js --targets node18-linux-x64,node18-macos-x64,node18-win-x64 --output dist-binary/myapp: 使用pkg打包dist-single/index.js。--targets: 指定目标平台。这里我们为 Linux, macOS, Windows 的 64 位系统生成二进制文件并基于 Node.js 18 的运行时。你可以根据需要调整版本和平台。--output: 指定输出路径和文件名前缀。pkg会根据目标平台自动添加后缀如.exe。运行构建命令npm run build执行后你会在dist-binary目录下找到三个文件取决于你的构建主机系统可能只生成当前系统的myapp-linux(Linux 可执行文件)myapp-macos(macOS 可执行文件)myapp-win.exe(Windows 可执行文件)现在你可以在没有安装 Node.js的系统上运行这个二进制文件需要对应操作系统。例如在 Linux 上./dist-binary/myapp-linux你应该看到与之前node dist/index.js类似的输出证明了程序成功运行且文件系统、process等 API 正常工作。这个二进制文件包含了 Node.js 运行时的一个精简副本和你的应用程序代码。3.1 pkg 的工作原理与限制pkg并没有将 JavaScript 编译成机器码。它实际上是一个打包工具其核心操作是将你的源代码和node_modules中的依赖文件嵌入到一个虚拟文件系统中这个文件系统实现被包含在二进制内。将一个预编译好的 Node.js 运行时二进制针对不同平台与上述虚拟文件系统以及一个引导程序链接在一起。当用户运行生成的二进制文件时引导程序启动内嵌的 Node.js 运行时并从虚拟文件系统中加载你的应用程序代码来执行。因此它生成的仍然是需要“Node.js 运行时”的只不过这个运行时被静态链接并隐藏在了最终的二进制文件内部。这对于用户而言体验上就是“无需安装 Node.js 的原生应用”。但它并不是传统意义上的“将 TypeScript 编译为原生机器码”。4. 探索更底层的“编译”方案Bun、Deno 与原生绑定为了更接近“编译为机器码”的愿景我们需要看看其他方案。4.1 Bun 的打包与编译Bun 是一个全新的 JavaScript 运行时它自带了一个极快的打包器、转译器和 npm 客户端。Bun 的bun build命令有一个--compile标志可以将你的代码编译成一个独立的可执行文件。首先确保安装了 Bun参考官方文档。然后在项目根目录尝试# 假设我们的入口是 src/index.ts bun build --compile --targetbun-linux-x64 src/index.ts -o myapp-bunBun 的编译原理与pkg有相似之处也是将 Bun 运行时用 Zig 编写性能很高与你的代码打包。但 Bun 的运行时设计更现代启动速度更快且对 TypeScript 和 JSX 有原生支持无需tsc。生成的二进制文件同样不依赖外部 Node.js。4.2 Deno 的编译Deno 是另一个安全的 JavaScript/TypeScript 运行时。它的deno compile命令可以直接将 TypeScript 编译为独立可执行文件。# 需要先安装 Deno deno compile --allow-read --target x86_64-unknown-linux-gnu src/index.tsDeno 的编译会创建一个包含 Deno 运行时、你的代码以及所有明确声明的权限的可执行文件。Deno 使用 Rust 编写其编译产物的体积和性能也有不错的表现。4.3 真正的“原生扩展”与 WebAssembly如果我们追求极致的性能和对系统资源的直接控制真正的路径是使用原生插件Native Addons或WebAssemblyWASM。原生插件使用 C 编写通过 Node.js 的 N-API 暴露给 JavaScript 调用。这部分的代码是真正的机器码。但这要求开发者掌握 C 和复杂的绑定技术主要用于性能关键模块如加密、图像处理而非整个应用。WebAssembly可以将 C/C/Rust 等语言编译成 WASM 字节码在 JavaScript 环境中以接近原生的速度运行。通过WASIWebAssembly System InterfaceWASM 模块甚至可以有限度地直接访问文件系统等操作系统资源。你可以将部分核心算法用 Rust 编写编译为 WASM然后在你的 TypeScript 主程序中调用。工具链如wasm-pack可以简化这个过程。# 一个使用 Rust WASM 的简单示例结构 # 在项目内创建一个 wasm-lib 目录 cargo new --lib wasm-lib cd wasm-lib # 编辑 Cargo.toml 和 src/lib.rs然后编译为 WASM wasm-pack build --target nodejs然后在 TypeScript 中你可以像导入普通模块一样导入并使用编译好的 WASM 模块。这实现了部分逻辑的“原生性能”但整体应用架构仍然运行在 JavaScript 运行时之上。5. 性能对比与适用场景分析生成独立二进制文件的主要优势在于部署简化和启动性能。下面我们从几个维度进行对比分析。特性/方案传统 Node.js (node app.js)pkg/ncc 打包Bun 编译Deno 编译原生插件/WASM是否需要外部运行时是需安装 Node.js否运行时内嵌否运行时内嵌否运行时内嵌是主程序仍需运行时启动速度较慢需加载模块快模块已打包极快Bun 启动快快快WASM 初始化有开销执行性能取决于 V8 JIT同传统 Node.js高Bun 引擎优化高V8但权限检查有开销最高纯机器码文件体积小仅源码大包含整个运行时中等Bun 运行时较小中等小仅算法模块部署复杂度高需管理 Node.js 版本和node_modules低单个文件低单个文件低单个文件中需分发二进制主程序开发体验成熟生态丰富需额外构建步骤新工具链集成度高新安全默认严格复杂涉及多语言适用场景通用后端服务、工具链需要分发给终端用户的可执行工具、CLI追求极致启动速度的 CLI、工具、边缘函数注重安全性的脚本、工具计算密集型任务如图像处理、加密、游戏引擎如何选择如果你需要分发一个给最终用户使用的桌面 CLI 工具pkg、Bun --compile或deno compile都是优秀选择它们提供了真正的“开箱即用”体验。如果你在构建服务器端应用并且对服务器环境有控制权传统 Node.js 部署配合 Docker 或版本管理工具如 nvm可能更简单生态支持也最好。如果你的应用有明确的性能瓶颈模块考虑用 Rust/C 编写该模块并通过 N-API 或 WASM 集成到 Node.js/Deno/Bun 主程序中。“将 TypeScript 直接编译为原生机器码”对于大多数应用级开发而言目前并非最实用或最必要的路径。它更适用于底层基础库、特定领域的编译器或对启动延迟有极端要求的场景。社区中像ScriptC这样的项目往往是实验性质的用于探索 JavaScript 静态化的边界。6. 常见问题与排查指南在实际操作中你可能会遇到以下问题6.1 打包后文件体积过大现象使用pkg生成的二进制文件动辄几十 MB。原因pkg打包了完整的 Node.js 运行时。此外如果你的代码依赖了大型的 npm 包如puppeteer包含 Chromium体积会更大。解决方案检查并精简依赖。使用npm ls --depth0查看直接依赖。确保ncc的树摇生效。检查dist-single/index.js看是否包含未使用的代码。考虑使用pkg的--compress选项Brotli 压缩但这可能会增加启动时的解压时间。评估是否真的需要打包所有平台。只为目标平台生成二进制文件。对于超大型依赖考虑是否能在运行时动态下载或按需加载。6.2 打包后运行时错误如fs.readFile找不到文件现象在二进制文件中使用fs.readFileSync(./config.json)读取相对路径文件失败。原因pkg将你的源代码和依赖打包进了虚拟文件系统。相对路径的解析基准可能发生了变化。pkg提供了一些机制来处理静态资源。解决方案使用path.join(__dirname, config.json)代替./config.json。对于需要打包进二进制文件的静态资源如配置文件、模板在package.json中为pkg配置assets字段。{ pkg: { assets: [configs/**/*, views/**/*] } }对于用户运行时提供的文件不要尝试打包应使用绝对路径或相对于process.cwd()的路径。6.3 特定原生模块Native Addons无法工作现象代码依赖了如bcrypt、sqlite3等包含 C 扩展的模块打包后运行报错找不到模块。原因pkg默认只处理 JavaScript 文件。原生模块.node文件是平台相关的动态链接库需要特殊处理。解决方案pkg支持预编译的原生模块。确保你的node_modules中有对应目标平台如linux-x64的.node文件。pkg会尝试自动包含它们。如果自动包含失败可以在package.json的pkg配置中手动指定{ pkg: { scripts: [dist-single/**/*.js], assets: [node_modules/**/*.node] } }最可靠的方法是在目标平台上执行打包命令这样生成的二进制文件会包含正确的原生模块。6.4 Bun 或 Deno 编译时遇到模块解析错误现象使用bun build --compile或deno compile时提示找不到模块或语法错误。原因Bun 和 Deno 的模块解析算法与 Node.js 略有不同尤其是对node_modules和 CommonJS/ESM 的识别。解决方案对于 Bun确保你的代码和依赖主要使用 ES 模块语法。Bun 对 CommonJS 的支持在不断完善但 ES 模块是首选。检查是否有依赖使用了奇怪的动态require。对于 DenoDeno 默认使用 URL 导入不支持node_modules。如果你在编译一个 Node.js 项目需要使用--node-modules-dir标志并确保依赖兼容 Deno或者使用npm:说明符。这通常比打包 Node.js 项目更复杂。deno compile --allow-read --node-modules-dirauto src/index.ts查阅 Bun/Deno 官方文档中关于打包和编译的章节了解当前限制和最佳实践。7. 生产环境最佳实践与建议如果你决定采用生成独立二进制文件的方式进行生产部署请考虑以下建议版本管理与回滚即使是一个二进制文件也应该有明确的版本号。在发布时将二进制文件命名为包含版本号和平台信息的名称如myapp-v1.2.0-linux-x64并通过符号链接如myapp-current指向当前活跃版本。这便于快速回滚。持续集成/持续部署CI/CD在 CI 流水线中为每个提交或标签构建多个平台的二进制文件。可以使用 GitHub Actions、GitLab CI 等工具的矩阵构建功能。# GitHub Actions 示例片段 jobs: build: strategy: matrix: platform: [linux-x64, macos-x64, win-x64] steps: - uses: actions/checkoutv4 - uses: actions/setup-nodev4 - run: npm ci - run: npm run build - name: Upload artifact uses: actions/upload-artifactv4 with: name: myapp-${{ matrix.platform }} path: dist-binary/*安全考虑代码混淆二进制文件中的 JavaScript 代码并非完全不可读。使用工具如javascript-obfuscator在打包前对代码进行混淆可以增加逆向工程难度但无法绝对防止。权限最小化像 Deno 一样思考你的应用真正需要哪些系统权限。虽然pkg生成的二进制文件默认拥有 Node.js 的完整权限但在设计应用时应遵循最小权限原则。依赖扫描定期使用npm audit或第三方工具扫描你的依赖即使它们被打包进二进制文件其中的漏洞依然存在。监控与日志确保你的二进制应用有完善的日志输出机制写入文件或标准输出并集成到现有的日志收集系统如 ELK、Loki中。对于服务器端应用还需要暴露健康检查接口和监控指标。测试不仅要测试源代码还要测试生成的二进制文件。在 CI 中增加一个步骤在“干净”的环境如 Docker 容器不安装 Node.js中运行生成的二进制文件验证其功能是否正常。将 TypeScript 编译或打包成独立二进制文件本质上是在交付体验和开发复杂度之间寻找平衡。对于需要简化部署、保护源代码一定程度或追求极致启动速度的场景pkg、Bun 和 Deno 提供的编译功能是强大的工具。然而它们并没有改变 JavaScript 代码在运行时被解释或 JIT 编译的本质。真正的“编译为原生机器码”之路对于大多数应用开发者而言仍然会通过 WebAssembly 或原生扩展的形式在性能关键路径上局部实现。理解每种工具的原理和边界才能为你的项目做出最合适的技术选型。下次当你需要分发一个工具时不妨尝试一下npm run build后生成的独立可执行文件体验一下无需环境配置的清爽。