Node.js项目打包成二进制可执行程序:从原理到pkg与Bun实战
2026/9/8 0:57:56 网站建设 项目流程

我经常被问到同一个问题:“我写了个 Node.js 小工具,但对方电脑上根本没有 Node 环境,怎么让人家用起来?”把 Node.js 项目打包成二进制文件——也就是打成单个可执行文件,就是为了解决这类尴尬。你写了个批量重命名脚本、一个内部数据迁移小服务、或者一个给同事用的命令行工具,总不能要求人家先装 Node、再装 npm、跑 npm install,再面对一堆依赖报错。这篇文章我从选型讲到底层原理,再到 pkg、bun 两种主流路线的完整实操,最后把我在打包现场踩过的一些坑全部列出来,照着做基本能跑通。

1. 为什么要折腾"打包成二进制"这件事

1.1 三个最典型的使用场景

第一个场景最普遍:交付给没有 Node 环境的机器。比如你给客户写了个数据校验工具,客户的电脑是 Windows,上面只装了办公软件,你总不能扔给对方一个源码包让他自己解决运行环境。把项目打成tool.exe,双击或者命令行里一行命令就能跑,这是最省事的交付形态。

第二个场景是部署简化。公司内部的定时任务、运维脚本、CI 里面临时跑的小服务,如果每个环节都要先准备 Node 运行时,那维护成本就上去了。打成二进制后,服务器上只多了一个文件,运行时就一个进程,日志、退出码、标准输入输出都跟普通程序一样,配合系统自带的计划任务或者守护进程管理工具非常顺。

第三个场景是源码保护,但这里我必须说清楚:二进制打包不是加密,只是把 JavaScript 代码编译进二进制里,让普通人不那么方便直接打开看源码。strings命令加上一点耐心还是能把源码片段掏出来的。所以它的定位是“提高查看门槛”,不是“绝对安全”。如果有人问你能不能靠这个保护商业核心算法,我会回答“能挡君子,挡不住有心人”。

1.2 二进制包能做什么、做不到什么

先说能做的。打包后的程序自带 Node.js 运行时,所以目标机器不需要安装 Node、不需要npm install、不需要配环境变量。程序对操作系统来说就是一个普通的可执行文件,Windows 上是.exe,Linux 上是 ELF,macOS 上是 Mach-O。用户可以把它放到任意目录下运行,甚至放到 U 盘里带走。

做不到的也很明确。第一,体积大。一个最简单的console.log打出来也有 40MB 以上,因为里面绑定了整个 Node.js 运行时。第二,不能跨平台随便拿去用。在 Windows 上打出来的包拿到 Linux 上跑不了,必须为目标平台单独打包。第三,动态加载的原生模块、用了一些依赖 C/C++ 编译的库,处理起来会比较麻烦,这点后面我会详细讲。

理解了边界之后,你就能判断一个项目到底适不适合走打包路线。纯 JavaScript、没有特殊原生模块、没有运行时从磁盘加载额外代码的小工具,打包体验最好。

2. 打包工具选型:pkg、nexe、bun 怎么选

2.1 四款主流方案一览

目前 Node.js 生态里做二进制打包,主流方案主要是四个:pkg@yao-pkg/pkgnexebun build --compile。另外Deno compile也能做,但那是 Deno 生态的,主要服务 Deno 项目,这里不展开。

我直接给你一张对比表,方便快速筛选:

工具维护状态支持 Node 版本产物特点适合场景
pkg(Vercel 原版)基本停更Node 8-18 左右单文件,含虚拟文件系统老项目、存量项目
@yao-pkg/pkg社区活跃维护Node 18/20/22 等新版本单文件,兼容 pkg 配置新项目、需要新 Node 特性
nexe维护一般Node 版本列表受限单文件,需要下载内核对 pkg 不兼容时的备选
bun build --compile持续迭代内置 Bun 运行时单文件,体积稍小简单 CLI 工具、TypeScript 项目

这里特别说一下pkg。原版 pkg 是 Vercel 团队出的,好用但已经很久不更新了,默认最高支持到 Node 18 附近,如果你在targets里写node20node22这种目标,很多版本会直接报错。社区后来出了一个 fork 叫@yao-pkg/pkg,补上了新版本 Node 的支持,配置和用法基本兼容原版,实测下来是目前最稳的选择。

2.2 我的选择逻辑与对比结论

我的判断标准很简单:先看你项目里用了什么。如果项目是纯 JavaScript + npm 依赖,没有牵扯到特殊原生模块,那@yao-pkg/pkg是首选,因为它对 Node.js 生态的兼容性最好,assetsscripts这两个配置项能解决大部分资源文件问题,而且踩坑的人多、社区答案多。

如果你的项目本身就不大,入口就一个文件,依赖也很少,那bun build --compile会更爽,命令短、速度快、支持直接编译 TypeScript,不需要额外配置。但要注意,Bun 的运行环境不是完整 Node.js,有些 Node API 的细节行为有差异,依赖复杂时容易踩兼容性坑。

nexe我把它定位成备选方案。它需要下载对应 Node 版本的预编译内核,网络不好时容易失败,而且对新版 Node 的支持经常滞后。除非 pkg 那套方案在你的项目上有解决不了的问题,否则我不太推荐一开始就用它。

3. 实操:用 pkg 把 Node.js 项目打成单文件可执行程序

3.1 环境准备与项目结构

我建议直接用@yao-pkg/pkg,下面命令都以它为准。先创建一个测试项目,结构尽量贴近真实业务:

my-tool/ ├── package.json ├── src/ │ ├── index.js # 入口文件 │ ├── helper.js # 被引用的模块 │ └── templates/ │ └── report.html # 需要作为资源打包的模板 └── assets/ └── config.json # 运行时需要读取的配置

安装打包工具,不需要写进项目依赖:

npm install -g @yao-pkg/pkg # 或者用 npx,不污染全局 npx @yao-pkg/pkg --version

先写一个最简单的入口文件:

// src/index.js const { readFileSync } = require('fs'); const path = require('path'); console.log('工具启动,当前进程路径:', process.execPath); // 尝试读取打包进来的资源 const configPath = path.join(__dirname, '../assets/config.json'); try { const config = JSON.parse(readFileSync(configPath, 'utf8')); console.log('配置读取成功:', config.name); } catch (err) { console.error('配置读取失败:', err.message); }

这一步先别急着打包,直接node src/index.js跑一遍,确认代码本身没问题。打包后遇到奇怪问题,第一步永远是“先确认源码跑得通”。

3.2 package.json 关键配置

pkg 的配置都写在package.json里,核心是binpkg两个字段。bin告诉 pkg 哪个文件是入口,pkg字段下面是资源、脚本、目标平台等配置:

{ "name": "my-tool", "version": "1.0.0", "bin": "./src/index.js", "pkg": { "assets": [ "assets/**/*", "src/templates/**/*" ], "targets": [ "node18-win-x64", "node18-linux-x64" ], "outputPath": "dist" } }

然后执行打包命令:

pkg .

这里有个关键点:pkg .会读取当前目录的package.json。入口文件不一定要叫index.js,但bin字段必须指向正确位置。打包完成后,dist目录下会出现两个文件,分别是 Windows 和 Linux 的可执行程序。

如果只想打当前平台的包,也可以命令行直接传参,不写targets

pkg . --targets node18-win-x64 --output my-tool.exe

target 的格式是node版本-平台-架构。常见组合:

平台写法产物后缀
Windows x64node18-win-x64.exe
Linux x64node18-linux-x64无后缀
macOS x64node18-macos-x64无后缀
macOS ARM64node18-macos-arm64无后缀

3.3 assets 和 scripts 的资源打包

大部分项目不只是单一 JS 文件,还会读取模板、配置文件、静态资源。pkg 默认不会把这些文件自动塞进二进制里,必须通过assets字段显式声明。

assets支持 glob 模式。比如把assets目录下所有文件都打包进去,就写"assets/**/*"。我实际推荐把资源文件统一放一个目录,配置起来不容易漏,排查问题时也方便从二进制里找文件。

再看scripts字段。pkg 对代码的静态分析能力有限,尤其是下面这种情况:

const moduleName = isProd ? 'prod-helper' : 'dev-helper'; const mod = require(moduleName);

require的参数是变量时,pkg 没法在编译阶段确定到底要包含哪个文件,结果就是运行时告诉你Cannot find module。解决办法就是把动态加载的模块手动加进scripts

{ "pkg": { "scripts": [ "src/**/*.js" ] } }

scripts字段的作用是告诉 pkg“这些 JS 文件必须进二进制”。我见过不少项目打包后启动就报模块找不到,排查到最后就是动态require的问题,把相关目录加进scripts就好了。

3.4 多平台交叉编译与体积控制

pkg 支持在一个平台上出多个平台的包,这叫交叉编译。比如你在 Windows 上可以同时打出 Linux 和 macOS 的二进制,不需要真的准备三台机器。但有个前提:如果你的项目里用了原生模块,就不是简单交叉编译能解决的了,原生模块需要到对应平台上去构建,否则即使打出来也跑不起来。

交叉编译的命令:

pkg . --targets node18-win-x64,node18-linux-x64,node18-macos-x64 --output dist/my-tool

体积方面,一个基础的 Node 18 二进制包大概 50MB 到 80MB。如果你对这个体积敏感,可以试一下upx压缩。注意 UPX 压缩后的程序首次启动会自解压,启动时间会变长,而且某些杀毒软件对 UPX 加壳的程序误报率更高,要不要用看你的场景。

我个人的做法是:内部工具不压缩,因为硬盘不缺那几十兆,稳定最重要;对外交付的 CLI 工具如果要求单文件体积小,再考虑 UPX,但一定要在目标机器上实测一遍。

4. 备选方案实操:bun 与 nexe

4.1 bun 的极简编译体验

如果你的项目比较简单,bun build --compile是一条捷径。Bun 是一个 JavaScript 运行时,内置了打包器和编译器,一条命令就能把 JS/TS 编译成可执行文件。

先安装 Bun:

npm install -g bun # 或者官方脚本 curl -fsSL https://bun.sh/install | bash

然后编译:

bun build ./src/index.ts --compile --outfile my-tool

注意--outfile后面不要加.exe,Bun 会自动根据当前平台生成对应后缀。如果你只想支持单个平台,直接在当前环境编译即可。

Bun 的好处是快,而且直接支持 TypeScript,不需要先tsc转换一遍。缺点也很明显:它是自己的一套运行时,虽然兼容大部分 Node API,但如果你用了clusterworker_threads这类偏底层的能力,或者某些 npm 包依赖 Node 原生行为,Bun 环境下的表现可能跟 Node 不一致。我实测过一些内部工具,80% 的情况没问题,但遇到数据库驱动、加密库之类的依赖时,我会优先选择 pkg 而不是 Bun。

Bun 也支持指定目标平台:

bun build ./src/index.ts --compile --target=bun-linux-x64 --outfile my-tool

支持的 target 主要有bun-linux-x64bun-linux-arm64bun-windows-x64bun-darwin-x64等。但跨平台编译同样受原生模块限制,规则跟 pkg 一样。

4.2 nexe 的兼容性补充

nexe是另一个老牌打包工具,它的原理是下载对应 Node 版本的内核,再把项目文件附加进去。用法上跟 pkg 有些类似:

npm install -g nexe nexe ./src/index.js --target windows-x64-18.20.4 --output my-tool.exe

--target的格式是平台-架构-Node版本,这里可以精确到具体的 Node 小版本,所以如果你对 Node 版本有强需求,nexe 可能更灵活。但它有两个明显的坑:一是首次编译需要从 GitHub 下载对应 Node 内核,网络不好的时候很容易卡在下载步骤;二是target版本库里没有你要的版本时,会直接报错,而且错误提示比较隐晦。

我在什么时候会用 nexe?一般是 pkg 对某个 Node 太新的语法支持不完整、项目又必须用新特性时,换 nexe 试一下。但如果你没有这种特殊需求,我还是建议直接走 pkg 路线,省心。

5. 打包现场常见问题与排查

5.1 版本报错 is not yet released 的处理思路

有时候你会遇到这类报错:

error installing 24.20.0: node.js v24.20.0 is not yet released or is not available

字面意思很好理解:你指定的 Node 版本号不存在,或者还没发布。这个问题在 pkg、nexe 里都出现过,本质是目标版本超出了工具内置的版本列表。处理思路很直接:

  1. 检查package.json和打包命令里写的 Node 版本,不要随手写一个最新大版本号,先看当前node -v实际版本。
  2. 确认你用的打包工具是否支持该版本。@yao-pkg/pkg对新版本支持较好,但也不要指望它能同步支持每一个刚发布的小版本。
  3. 把 target 降到已确认可用的版本,比如node18node20,而不是去追最新版。

我见过一个同事把node24写成node24.20,结果报错说他写了一个不存在的版本。这类问题九成都是版本号拼写或者工具版本列表跟不上的问题,别慌,换成可用版本即可。

5.2 动态 require 导致模块找不到

这个前面提到过,但值得再展开。pkg 底层会扫描你的代码,收集requireimport的模块。如果require的参数是变量,它无法静态分析出结果,运行时就会找不到模块。

举个真实的例子。我的一个工具里写了:

const commands = ['init', 'build', 'deploy']; const command = commands[process.argv[2]]; require(`./commands/${command}`);

打包后一运行就报模块不存在。解决方式就是把src/commands目录加进pkg.scripts。改完配置再跑一次:

pkg . --config package.json

另外要注意,node_modules里的依赖如果也使用了动态加载,有些情况下也需要在scripts里声明。实在搞不清就让 pkg 多试试,报错信息里会明确告诉你是哪个模块加载失败。

5.3 __dirname 路径跑偏的问题

这是 pkg 新手最容易踩的坑。在源码里写fs.readFileSync(path.join(__dirname, 'data.json')),开发时用node src/index.js跑得好好的,打包后一运行就找不到文件。

原因是 pkg 会把项目文件挂载到一个虚拟文件系统里,运行时__dirname指向的不是真实磁盘路径,而是类似/snapshot/my-tool/src这样的虚拟路径。你在这个路径下找外部文件,自然找不到。

解决方案有两种。第一种是把文件声明为assets,让文件进入虚拟文件系统,然后用 pkg 提供的路径访问能力去读。第二种是判断当前是否处于 pkg 环境,改用真实路径去读外部文件:

const { isPackage } = require('@yao-pkg/pkg'); const basePath = isPackage ? path.dirname(process.execPath) : __dirname;

注意旧版 pkg 的包名是pkg,用的 API 可能是require('pkg').isPackage。如果你不需要读取外部文件,最省事的做法就是全部走assets,让文件跟着程序走。我在实际项目里推荐优先用 assets,因为外部文件一旦缺失,用户那边报错你还要远程排查,很被动。

5.4 原生模块、杀毒软件误报和文件占用

原生模块是整个打包方案里最棘手的一环。像sharpbetter-sqlite3node-pty这类涉及 C/C++ 代码的模块,pkg 没法直接把它们“翻译”进二进制,需要额外处理。我的建议是:能用纯 JS 替代就把它们换掉,不能换就老老实实在目标平台上单独打包,别指望交叉编译。还有一点,原生模块的版本必须和打包时使用的 Node 版本匹配,否则加载时会报NODE_MODULE_VERSION不匹配。

杀毒软件误报是另一个高频问题。打包出来的程序没签名、又包含了运行时环境,行为上跟一些木马很像,Windows Defender 或者第三方杀软偶尔会直接删掉产物。处理方式:优先给 exe 做代码签名;没条件签名就加白名单;再不行就换一个打包工具试试,不同工具产物的特征不同,误报概率也会不一样。

Windows 上还有一个常见情况是文件被占用。程序还在运行的时候,你想覆盖旧的 exe,系统会提示“文件正在使用”。解决办法就是先结束进程再覆盖,这不是 bug,是操作系统的正常保护机制。

5.5 关于 Electron 打包 URL 的题外话

热词里有一条“我想使用 electron 把 url 打包进去,是否可行”,我在这里顺手说清楚。如果你是想把某个网页打包成桌面应用,那属于 Electron 的领域,跟“把 Node.js 项目打包成二进制”不是一回事。Electron 本身自带 Chromium 和 Node.js,打包体积通常一两百兆起,适合做 GUI 应用。而 pkg 产出的进程是命令行程序或者后台服务,没有窗口、没有浏览器渲染。先想清楚你要的是哪一种形态,再做技术选型,两条路线都可以用,但别混在一起。

6. 常见问题速查表

为了方便你在现场快速排查,我把上面提到的典型问题整理成一张表:

问题可能原因解决办法
提示 Node 版本未发布或不可用target 版本号写错,或工具版本列表未同步改成可用版本,如 node18/node20
运行时模块找不到动态 require 未被静态分析捕获把模块目录加入 pkg.scripts
__dirname 指向虚拟路径pkg 挂载了 snapshot 文件系统加入 assets,或用 process.execPath 判断真实路径
原生模块加载失败NODE_MODULE_VERSION 不匹配,或交叉编译导致在目标平台上重新打包,匹配 Node 版本
杀毒软件误报产物无签名、特征类似恶意程序代码签名、加白名单、换工具
Windows 提示文件被占用程序还在运行,exe 被锁先结束进程再覆盖
打包后体积太大内置了完整 Node 运行时接受体积,或考虑 UPX 压缩(有风险)
Bun 编译后某些 API 行为不一致Bun 运行时非完整 Node依赖复杂时换回 pkg

我实际用下来的体会是,打包这个事本身不难,难的是对运行机制的把握。__dirname变虚拟路径、动态require找不到模块、原生模块版本不匹配,这些问题理解了原理之后都是一眼看破的事。我第一次打包的时候也被问题清单砸得满头包,后来把 pkg 的虚拟文件系统机制搞明白,就再也没翻过车。

最后再分享一个小技巧:打包前先在package.json里把files字段配合.npmignore控制好哪些文件进入项目上下文,再把assets写全。宁可多打包一个没用的配置文件,也不要漏掉一个运行时才发现的资源。很多线上事故,都是因为在打包那一步少写了一个 glob 路径。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询