本文对 Node.js 的二进制打包工具 pkg 进行介绍和解析。
最终希望能达成以下目的:
- 读者可以大致了解这类二进制打包框架的原理,做到心中有数。
- 可以有一定的能力在需要的情况下进行部分逻辑的二次开发。
使用方式
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 打包的大致流程如下:
- 二进制预处理:查找通过 pkg-fetch 打包好的对应二进制文件,根据配置和缓存不同,来源可能是云端下载、本地缓存文件或者重新编译一个。
- pkg 自身提供一个 prelude,因为事实上所有的代码文件都是 Buffer 中的一段,并不存在对应的文件系统,prelude 通过覆盖 fs 等相关方法,让我们得以在实际运行中可以正常使用 fs 相关的方法。
- 用户代码处理:根据我们提供的入口文件,遍历读取所有依赖项目,通过
vm.Script 来编译成字节码
- 将 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) { + + 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)
return LoadSnapshotDataAndRun(&snapshot_data, result.get());
exit_code = main_instance.Run();
LoadEnvironment(env, StartExecutionCallback{});
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] !== '-') { process.argv[1] = path.resolve(process.argv[1]); } } else { process.argv[1] = DEFAULT_ENTRYPOINT; }
|
根据上文的描述,这里的代码执行在启动初始化早期阶段,因此可以通过直接修改 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 规范会有一定的问题,后续支持的困难也比较多,现阶段可以采用的解决方案包括:
其他参考资料