本文对 Node.js 的二进制打包工具 pkg 进行介绍和解析。

最终希望能达成以下目的:

  1. 读者可以大致了解这类二进制打包框架的原理,做到心中有数。
  2. 可以有一定的能力在需要的情况下进行部分逻辑的二次开发。

使用方式

pkg 是一个用于 Node.js 的打包工具,它可以将 Node.js 的代码打包成一个可以直接运行的二进制文件,并且支持跨平台打包,可以用在如下场景:

  • 由于打包后自带一个 Node.js 运行时,支持在 Windows/macOS/Linux 等操作系统直接运行,可以用于我们发布一个跨平台的命令行工具。
  • 由于打包过程会将代码编译成字节码,之后变成二进制,反解出源代码的难度极大,所以可以用于在保护源代码的场景下进行产品发布的场景,比如私有化部署。

上手使用也非常简单,一个最简单的使用方法:

1
2
3
pnpm i pkg -g
# 项目中:
pkg .

同时,它也支持在命令行参数或者 package.json 中指定各类配置:包括但不限于 Node 版本、目标平台、产物名称等一些常用配置。

原理解析

接下来,我们对 pkg 的原理进行解析,并尝试回答以下几个问题:

  • pkg 打包的可执行二进制文件的结构是怎么样的?它是如何让 js 代码变的直接“可运行”的,这可能是大多前端程序员初次了解这个仓库时的疑问。
  • pkg 目前的局限性有哪些?什么情况下不应该用 pkg 进行打包?

这里主要涉及如下两个仓库:

  • pkg-fetch:对 Node.js 打 patch,生成一个 pkg 专用的二进制文件
  • pkg:对用户代码进行处理,“放入”pkc-fetch 编译好的二进制文件中。

简单来说,pkg 将用户代码和 Node.js 环境打包到一起,实际上用户代码是二进制文件中的一段 Buffer,并通过修改 Node.js 的自启动方式,让它来直接执行这段 Buffer。

pkg 打包的大致流程如下:

  1. 二进制预处理:查找通过 pkg-fetch 打包好的对应二进制文件,根据配置和缓存不同,来源可能是云端下载、本地缓存文件或者重新编译一个。
  2. pkg 自身提供一个 prelude,因为事实上所有的代码文件都是 Buffer 中的一段,并不存在对应的文件系统,prelude 通过覆盖 fs 等相关方法,让我们得以在实际运行中可以正常使用 fs 相关的方法。
  3. 用户代码处理:根据我们提供的入口文件,遍历读取所有依赖项目,通过 vm.Script 来编译成字节码
  4. 将 2、3 部分的代码结果整体插入到预打包的二进制文件中对应的位置,产生一个新的可以自运行的二进制文件

接下来,我们对二进制预处理和用户代码处理的部分进行详细展开。

二进制预处理

这里面比较重要和精巧的部分,就是 pkg-fetch 对 Node.js 的预处理。

我们可以从 这里 看到,实际上它对 Node 进行了若干的 patch,修改了 Node.js 的启动方式,我们以最新的 v18.13.0 为例。

首先,这里修改了启动的这一行代码:

1
2
-  return node::Start(argc, argv);
+ return reorder(argc, argv);

我们继续分析 reorder 这个函数:

1
2
3
4
5
6
7
8
9
+int reorder(int argc, char** argv) {
+ char** nargv = new char*[argc + 64];
+ // ...
+ return adjacent(c, nargv);
+}
+int adjacent(int argc, char** argv) {
+ // ...
+ return node::Start(argc, argv);
}

这里我们省略了一些逻辑,实际上它经过一定的参数处理,参数处理对理解整体过程影响不大,因此我们部分进行省略,最终还是调用到了 node::Start

另外值得注意的是,我们在 patch 文件中还可以看到它新增了一个 node/lib/internal/bootstrap/pkg.js 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
+++ node/lib/internal/bootstrap/pkg.js
+const {
+ prepareMainThreadExecution
+} = require('internal/bootstrap/pre_execution');
+prepareMainThreadExecution(true);
+(function () {
+ var __require__ = require;
+ var fs = __require__('fs');
+ var vm = __require__('vm');
+ function readPrelude (fd) {
+ var PAYLOAD_POSITION = '// PAYLOAD_POSITION //' | 0;
+ var PAYLOAD_SIZE = '// PAYLOAD_SIZE //' | 0;
+ var PRELUDE_POSITION = '// PRELUDE_POSITION //' | 0;
+ var PRELUDE_SIZE = '// PRELUDE_SIZE //' | 0;
+ if (!PRELUDE_POSITION) {
+ // no prelude - remove entrypoint from argv[1]
+ process.argv.splice(1, 1);
+ return { undoPatch: true };
+ }
+ var prelude = Buffer.alloc(PRELUDE_SIZE);
+ var read = fs.readSync(fd, prelude, 0, PRELUDE_SIZE, PRELUDE_POSITION);
+ // ...
+ var s = new vm.Script(prelude, { filename: 'pkg/prelude/bootstrap.js' });
+ var fn = s.runInThisContext();
+ return fn(process, __require__,
+ console, fd, PAYLOAD_POSITION, PAYLOAD_SIZE);
+ }
+ (function () {
+ var fd = fs.openSync(process.execPath, 'r');
+ var result = readPrelude(fd);
+ if (result && result.undoPatch) {
+ var bindingFs = process.binding('fs');
+ fs.internalModuleStat = bindingFs.internalModuleStat;
+ fs.internalModuleReadJSON = bindingFs.internalModuleReadJSON;
+ fs.closeSync(fd);
+ }
+ }());
+}());

这个文件中,PAYLOAD 和 PRELUDE 相关的内容会在 pkg 编译二进制的时候替换为实际的内容,这个文件的主要作用就是通过 vm.Script 执行了 pkg 打包好的代码。

另外,这部分代码需要添加一个 StartExecution

1
+  StartExecution(env, "internal/bootstrap/pkg");

StartExecution 执行的时机较早,在执行用户代码之前即执行,相关的关键流程为::

1
2
3
4
5
6
7
8
9
10
// 入口:
int Start(int argc, char** argv)
// Start 内部:
return LoadSnapshotDataAndRun(&snapshot_data, result.get());
// LoadSnapshotDataAndRun 内部:
exit_code = main_instance.Run();
// main_instance.Run 内部:
LoadEnvironment(env, StartExecutionCallback{});
// LoadEnvironment 内部:
StartExecution(env, cb);

StartExecution 中其实会对 process.argv 进行修改,改成当前的实际入口文件,这部分我们参考下文的 用户代码处理 部分。

用户代码处理

用户代码处理是 pkg 这个仓库的主要工作,它的主要目的是对相关业务代码进行打包,整合成一个大的 Buffer,这部分有点类似 webpack 或者 vite 这类打包工具所做的事情,对开发人员的理解来说心智负担较小,我们对其主要流程进行介绍。

最终来说,我们打包的所有代码会被整合成下面这段,其中 VIRTUAL_FILESYSTEM 可以理解为所有代码文件的文件路径和字节码(经由 vm.Script 处理过的)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const prelude =
`return (function (REQUIRE_COMMON, VIRTUAL_FILESYSTEM, DEFAULT_ENTRYPOINT, SYMLINKS, DICT, DOCOMPRESS) {
${bootstrapText}${
log.debugMode ? diagnosticText : ''
}\n})(function (exports) {\n${commonText}\n},\n` +
`%VIRTUAL_FILESYSTEM%` +
`\n,\n` +
`%DEFAULT_ENTRYPOINT%` +
`\n,\n` +
`%SYMLINKS%` +
'\n,\n' +
'%DICT%' +
'\n,\n' +
'%DOCOMPRESS%' +
`\n);`;

被执行的 bootstrapText 的关键代码:

1
2
3
4
5
6
7
8
9
if (process.env.PKG_EXECPATH === EXECPATH) {
process.argv.splice(1, 1);
if (process.argv[1] && process.argv[1] !== '-') {
// https://github.com/nodejs/node/blob/1a96d83a223ff9f05f7d942fb84440d323f7b596/lib/internal/bootstrap/node.js#L269
process.argv[1] = path.resolve(process.argv[1]);
}
} else {
process.argv[1] = DEFAULT_ENTRYPOINT;
}

根据上文的描述,这里的代码执行在启动初始化早期阶段,因此可以通过直接修改 process.argv[1] 来修改入口文件。

总结

  1. pkg 把用户的所有代码打包按照 文件名:字节码 变成一个大的对象
  2. pkg 增加一个 prelude 代码,它会覆盖 fs 等模块,构造一个虚拟文件系统,并修改 process.argv[1] 为虚拟文件系统中可执行文件路径
  3. 通过修改 Node 的代码,在启动初始化早期阶段,增加一个 node/lib/internal/bootstrap/pkg.js,执行这段代码之后,就会对 fs 等模块进行 patch,同时修改了启动文件的路径。
  4. 之后实际开始解析执行用户代码逻辑的时候,已经变成了虚拟文件系统,入口文件也变成了虚拟文件系统中的入口文件,直接执行即生效。

局限性

pkg 目前也存在着一些局限性,其中主要的局限性就是不支持 ESM 模块规范,这里有相关的 issue 进行了讨论。

从相关 issue 可以看出,pkg 当前完整支持 ESM 规范会有一定的问题,后续支持的困难也比较多,现阶段可以采用的解决方案包括:

  • 可以使用 webpack 或 vite 等构建工具打包成 commonjs 的单个文件然后再用。
  • 可以借助 https://github.com/vercel/ncc 工具进行打包,这个工具同样来自 vercel。
  • 还有网友提出了可以使用 caxa 协助打包。

其他参考资料