本文对 Node.js 的二进制打包工具 pkg 进行介绍和解析。
最终希望能达成以下目的:
- 读者可以大致了解这类二进制打包框架的原理,做到心中有数。
- 可以有一定的能力在需要的情况下进行部分逻辑的二次开发。
使用方式
pkg 是一个用于 Node.js 的打包工具,它可以将 Node.js 的代码打包成一个可以直接运行的二进制文件,并且支持跨平台打包,可以用在如下场景:
- 由于打包后自带一个 Node.js 运行时,支持在 Windows/macOS/Linux 等操作系统直接运行,可以用于我们发布一个跨平台的命令行工具。
- 由于打包过程会将代码编译成字节码,之后变成二进制,反解出源代码的难度极大,所以可以用于在保护源代码的场景下进行产品发布的场景,比如私有化部署。
上手使用也非常简单,一个最简单的使用方法:
1 | pnpm i pkg -g |
同时,它也支持在命令行参数或者 package.json 中指定各类配置:包括但不限于 Node 版本、目标平台、产物名称等一些常用配置。
原理解析
接下来,我们对 pkg 的原理进行解析,并尝试回答以下几个问题:
- pkg 打包的可执行二进制文件的结构是怎么样的?它是如何让 js 代码变的直接“可运行”的,这可能是大多前端程序员初次了解这个仓库时的疑问。
- pkg 目前的局限性有哪些?什么情况下不应该用 pkg 进行打包?
这里主要涉及如下两个仓库:
简单来说,pkg 将用户代码和 Node.js 环境打包到一起,实际上用户代码是二进制文件中的一段 Buffer,并通过修改 Node.js 的自启动方式,让它来直接执行这段 Buffer。
pkg 打包的大致流程如下:
- 二进制预处理:查找通过 pkg-fetch 打包好的对应二进制文件,根据配置和缓存不同,来源可能是云端下载、本地缓存文件或者重新编译一个。
- pkg 自身提供一个 prelude,因为事实上所有的代码文件都是 Buffer 中的一段,并不存在对应的文件系统,prelude 通过覆盖 fs 等相关方法,让我们得以在实际运行中可以正常使用 fs 相关的方法。
- 用户代码处理:根据我们提供的入口文件,遍历读取所有依赖项目,通过
vm.Script
来编译成字节码 - 将 2、3 部分的代码结果整体插入到预打包的二进制文件中对应的位置,产生一个新的可以自运行的二进制文件
接下来,我们对二进制预处理和用户代码处理的部分进行详细展开。
二进制预处理
这里面比较重要和精巧的部分,就是 pkg-fetch 对 Node.js 的预处理。
我们可以从 这里 看到,实际上它对 Node 进行了若干的 patch,修改了 Node.js 的启动方式,我们以最新的 v18.13.0 为例。
首先,这里修改了启动的这一行代码:
1 | - return node::Start(argc, argv); |
我们继续分析 reorder
这个函数:
1 | +int reorder(int argc, char** argv) { |
这里我们省略了一些逻辑,实际上它经过一定的参数处理,参数处理对理解整体过程影响不大,因此我们部分进行省略,最终还是调用到了 node::Start
。
另外值得注意的是,我们在 patch 文件中还可以看到它新增了一个 node/lib/internal/bootstrap/pkg.js
文件
1 | +++ node/lib/internal/bootstrap/pkg.js |
这个文件中,PAYLOAD 和 PRELUDE 相关的内容会在 pkg 编译二进制的时候替换为实际的内容,这个文件的主要作用就是通过 vm.Script
执行了 pkg 打包好的代码。
另外,这部分代码需要添加一个 StartExecution
1 | + StartExecution(env, "internal/bootstrap/pkg"); |
StartExecution
执行的时机较早,在执行用户代码之前即执行,相关的关键流程为::
1 | // 入口: |
在 StartExecution
中其实会对 process.argv
进行修改,改成当前的实际入口文件,这部分我们参考下文的 用户代码处理
部分。
用户代码处理
用户代码处理是 pkg 这个仓库的主要工作,它的主要目的是对相关业务代码进行打包,整合成一个大的 Buffer,这部分有点类似 webpack 或者 vite 这类打包工具所做的事情,对开发人员的理解来说心智负担较小,我们对其主要流程进行介绍。
最终来说,我们打包的所有代码会被整合成下面这段,其中 VIRTUAL_FILESYSTEM
可以理解为所有代码文件的文件路径和字节码(经由 vm.Script
处理过的)。
1 | const prelude = |
被执行的 bootstrapText
的关键代码:
1 | if (process.env.PKG_EXECPATH === EXECPATH) { |
根据上文的描述,这里的代码执行在启动初始化早期阶段,因此可以通过直接修改 process.argv[1]
来修改入口文件。
总结
- pkg 把用户的所有代码打包按照
文件名:字节码
变成一个大的对象 - pkg 增加一个
prelude
代码,它会覆盖 fs 等模块,构造一个虚拟文件系统,并修改process.argv[1]
为虚拟文件系统中可执行文件路径 - 通过修改 Node 的代码,在启动初始化早期阶段,增加一个
node/lib/internal/bootstrap/pkg.js
,执行这段代码之后,就会对 fs 等模块进行 patch,同时修改了启动文件的路径。 - 之后实际开始解析执行用户代码逻辑的时候,已经变成了虚拟文件系统,入口文件也变成了虚拟文件系统中的入口文件,直接执行即生效。
局限性
pkg 目前也存在着一些局限性,其中主要的局限性就是不支持 ESM 模块规范,这里有相关的 issue 进行了讨论。
从相关 issue 可以看出,pkg 当前完整支持 ESM 规范会有一定的问题,后续支持的困难也比较多,现阶段可以采用的解决方案包括:
- 可以使用 webpack 或 vite 等构建工具打包成 commonjs 的单个文件然后再用。
- 可以借助 https://github.com/vercel/ncc 工具进行打包,这个工具同样来自 vercel。
- 还有网友提出了可以使用 caxa 协助打包。