最近准备把一个 Node.js 项目打包成一个单一的可执行文件,因为对于没有编程经验的人来说,纯 Node.js 项目很难部署成功,我也顺便学习一下打包单应用的方法。
先说结论:最终我决定改为使用 Electron。现在,我来分享一下打包单应用的过程以及最后为什么做这个决定。
选择工具
Node.js 官方已经实验性的支持了打包单应用的功能:https://nodejs.org/api/single-executable-applications.html
但是考虑到这个功能还是实验性的,所以我还是准备先用第三方工具来尝试。
打包 Node.js 应用的工具在以前有两个:pkg 和 nexe。其中 pkg 已经不再维护了,于是我准备试一下 nexe。尝试之前,我看到有人在 issues 里抱怨,说最简单的 hello world 都跑不起来,不过我还是试了一下,结果真的失败了。
最后我还是选择了 Node.js 官方的工具(后文称之为 SEA),至少 hello world 是能成功打包的。
第一个坑:可执行文件的大小
将 console.log('hello world') 打包成可执行文件后,生成的文件有 88.4 MB,这让我一下子想到了一个有类似情况的项目————Electron。
SEA 的原理其实是“注入”,即在 Node.js 的执行文件里注入要默认执行的代码,这样就不需要用户安装 Node.js 了,但这也意味着打包出来的可执行文件会包含一个完整的 Node.js 运行时,也就是体积至少有 88 MB。
算了,比 Electron 的 120 MB 强一点。
第二个坑:模块格式
由于文档上写着不支持 ESM,我开发的时候使用了 CommonJS 的模块格式,但是在开发阶段就报错了,原因是依赖的 node_modules 里有 ESM 的模块。
我选择先将这个 ESM 模块的版本降级到它支持 CommonJS 的那个版本,然后再打包,但到了运行的时候又报错了———因为在 SEA 的环境里面,不能引用 node_modules 里的模块,只能引用打包进去的模块。
这意味着我无论如何都逃不开 Webpack 这个工具,我需要将我的代码以及依赖的模块转变成 CommonJS 然后全都打包进一个文件,最后再把这个文件用 SEA 打包成一个可执行文件。
第三个坑:静态资源
我的设想是使用网页来填写配置项,所以我有一个 index.html 文件需要打包进去,SEA 是支持把静态资源打包进去的,类似于这样:
import express from 'express'
import { getAsset, isSea } from 'node:sea'
const app = express()
app.get('/', (req, res) => {
// 在 SEA 运行环境则使用 getAsset
if (isSea()) {
const htmlString = getAsset('index.html', 'utf-8')
res.set('Content-Type', 'text/html')
res.send(htmlString)
} else {
// 开发环境则读取本地文件
res.sendFile(path.join(__dirname, 'index.html'))
}
})
但这里有个问题:我自己写的时候确实可以考虑到 SEA 的情况,但是别的模块就不一定了,比如有的模块会从 node_modules/external-module/ 文件夹下读取一些配置文件。
不过,不同于模块,静态资源不是必须要打包进去的。我们可以把静态资源跟可执行文件放在同一个目录下,然后在运行时用 fs 模块读取,大概是这样:
./
├── node_modules/external-module/
│ ├── config.xml
│ └── ...
└── sea-darwin-arm64
然后再次尝试了一下,还是不行。检查了一下,发现是 prisma 这个模块造成的。
@prisma/client 会在 postinstall 阶段下载一个二进制文件保存在 node_modules/.prisma 文件夹,里面有操作数据库需要的二进制文件。在经过一番调查后,我发现 prisma 可能还会在别的阶段下载二进制文件,见 Engines | Prisma Documentation。
这意味着我还需要额外将这些二进制文件包含进去,那么目录大概会是这样:
./
├── node_modules/
├──── external-module/
│ ├── config.xml
│ └── ...
├──── .prisma/
│ └── client/
│ ├── libmerge_engine-darwin-arm64.dylib.node
│ └── libquery_engine-darwin-arm64.dylib.node
└── sea-darwin-arm64
想到这里,我决定改用 Electron 了。
为什么改用 Electron?
我认为的单应用的优势:
- 文件数量只有一个,更方便分发
- 比 Electron 体积小
但是这个项目走到了这一步,怎么看都更适合用 Electron,因为:
- 由于需要额外的二进制文件,打包出来的已经不再是单一文件了,且 Node.js 加上额外的二进制文件后,体积也不小了
- 比起仍处于实验性阶段的 SEA,Electron 更加成熟稳定
所以最终我决定改用 Electron。