node项目打包成可执行文件pkg(一): pkg介绍

2,117 阅读5分钟

pkg简介

pkg 是一个命令行工具,可以帮我们把 node.js 脚本打包成可执行文件,直接在linux、mac、windows上运行,比如exe文件。

比如我们希望打包 index.js 脚本:

// index.js
console.log('hello world');
  1. 安装
npm install -g pkg
  1. 打包成index.exe
pkg index.js -t node14-win-x64 --out-path out
  1. 运行index.exe
./out/index.exe 

hello world 

控制台打印出hello world

pkg原理

我们直接从使用pkg打包之后的二进制文件看起。

20210623175406

如上图所示,在 node 后添加了 虚拟文件系统 和 prelude.js 共同组成了打包文件。

这里的 node 不是官方提供的二进制可执行node文件,而是经过修改了的,具体修改内容请查看 pkg-fetch/patches

修改 node 的主要目的是让 node 在启动阶段运行 prelude.js; 在 prelude.js 中, 修改 fs.readFile 等跟文件读写相关函数,如果文件路径是以 C:\snapshot 开始的,就从虚拟文件系统中获取。

虚拟文件系统

虚拟文件系统元数据 VIRTUAL_FILESYSTEM 会作为参数传递给 prelude 函数使用,如下面代码所示。

// prelude.js
function prelude(REQUIRE_COMMON, VIRTUAL_FILESYSTEM, DEFAULT_ENTRYPOINT, SYMLINKS){
  ...
}

VIRTUAL_FILESYSTEM 是一个 json 对象,我们来看一下一个例子。

VIRTUAL_FILESYSTEM = {
  "C:\\snapshot\\helloworld\\index.js": {
    "0": [0, 656],
    "3": [656, 118]
  },
  "C:\\snapshot\\helloworld": {
    "2": [774, 12],
    "3": [786, 117]
  },
  "C:\\snapshot": {
    "2": [903, 14],
    "3": [917, 117]
  }
}

每个文件或者目录作为对象的键,每个文件或者目录都是以 C:\\snapshot 开头,根据这个我们判断一个文件是在虚拟文件系统中还是在本地文件系统中,根据文件或目录名我们就可以获取该文件在虚拟文件系统中存储的位置信息。

具体的,每个文件的元数据也是一个对象,它的键名可以为 "0"、"1"、 "2"、 "3",我在prelude.js中找到如下常量的定义。

STORE_BLOB = 0; // 二进制文件, 字节码
STORE_CONTENT = 1; // 文本文件
STORE_LINKS = 2; // 链接文件
STORE_STAT = 3; // 文件stat

"0"表示 该记录 记录的是这个js文件编译之后的字节码。 "1"表示 该记录 记录的是这个js文件的 javascript源代码。 "2"表示 该记录 记录的是这个js文件的link信息。 "3"表示 该记录 记录的是这个js文件的stat信息, 调用 fs.stat 函数的返回结果。

每条记录, 如 [0, 656],数组中第一个元素表示该文件在虚拟文件系统中的起始位置, 第二个元素表示该文件的大小。

[0, 656] --> [position, size], index.js 在整个二进制文件的存储位置如下图所示。

20210624111307

当然,我们还需要知道 node 的大小才能定位到虚拟文件系统的开始位置,我们管这个位置叫 PAYLOAD_POSITION,如下图所示。

20210624112242

使用虚拟文件系统

在开始之前,我们需要一些前置知识:

pkg的打包原理简单来说,就是将js代码(源代码或字节码)以及相关的资源文件打包到可执行文件中,然后劫持fs里面的一些函数,使它能够读到可执行文件中的代码和资源文件。

node模块加载机制 有提到,模块加载时读取javascript代码文件 使用的是 fs.readFileSync 函数。 在 prelude.js 中,对它进行了修改。

const ancestor = {
  readFileSync: fs.readFileSync, // 保存原来的 fs.readFileSync 函数
  ...
}

fs.readFileSync = function(path_, options_){
  // 如果文件路径不是以 C:\\snapshot 开头,就使用原始的 fs.readFileSync
  if (!insideSnapshot(path_)) {
    return ancestor.readFileSync.apply(fs, arguments);
  }

  // 如果文件路径以 C:\\snapshot 开头,则从虚拟文件系统中获取
  let buffer = readFileFromSnapshot(path_);
  return buffer;
}
...

在 prelude.js 中,修改 Module.runMain 函数,主模块文件从虚拟文件系统中获取:

function prelude(REQUIRE_COMMON, VIRTUAL_FILESYSTEM, DEFAULT_ENTRYPOINT, SYMLINKS){
  process.argv[1] = DEFAULT_ENTRYPOINT; // C:\\snapshot\\helloworld\\index.js
  let ENTRYPOINT = process.argv[1];

  Module.runMain = function runMain() {
    Module._load(ENTRYPOINT, null, true);
    process._tickCallback();
  };
}

这样后续的 require(...) 的文件路径都是相对于 C:\\snapshot\\helloworld\\index.js, 则都会到虚拟文件系统中查找。

编译代码

在 pkg 中, 在打包阶段,可以先javascript代码编译成字节码,存储到虚拟文件系统;然后在运行阶段加载模块时就可以复用字节码了。

在 prelude.js 中,修改了 Module.prototype._compile函数,在加载模块时复用字节码。

Module.prototype._compile = function (content, filename_){
  if (!insideSnapshot(filename_)) {
    return ancestor._compile.apply(this, arguments);
  }

  // 根据文件名读取虚拟文件系统元数据,例如:
  // "C:\\snapshot\\helloworld\\index.js": {
  //   "0": [0, 656],
  //   "3": [656, 118]
  // }
  const entity = findVirtualFileSystemEntry(filename_);
  const entityBlob = entity[STORE_BLOB];
  const entityContent = entity[STORE_CONTENT];


  if (entityBlob) {
    const options = {
      filename: filename_,
      lineOffset: 0,
      displayErrors: true,
      cachedData: payloadFileSync(entityBlob), // 从虚拟文件系统中读取字节码
      sourceless: !entityContent,
    };

    const code = entityContent
      ? Module.wrap(payloadFileSync(entityContent)) // 从虚拟文件系统中读取javascript代码, 主要是要保证code的长度与编译时的代码长度一致
      : undefined;

    const script = new Script(code, options);
    const wrapper = script.runInThisContext(options);
    if (!wrapper) process.exit(4); // for example VERSION_MISMATCH
    const dirname = path.dirname(filename_);
    const rqfn = makeRequireFunction(this);
    const args = [this.exports, rqfn, this, filename_, dirname];
    return wrapper.apply(this.exports, args);
  } else {
    return ancestor._compile.apply(this, arguments);
  }
}

将javascript代码编译成字节码

将javascript代码编译成字节码并不复杂,但需要注意的时,编译字节码用的node版本要和运行字节码用的版本要一致。

例如,本机安装的node版本是10,需要打包的node版本是12,那么可以使用如下方式,使用 child_process.spawn 调用需要的打包的node二进制,这样编译字节码时用就是运行字节码的node二进制。

const child_process = require("child_process");

const script = `
var vm = require('vm');
var module = require('module');
var body = Buffer.alloc(0);
process.stdin.on('data', function (data) {
  body = Buffer.concat([ body, data ]);
});
process.stdin.on('end', function (data) {
  var code = module.wrap(body);
  var script = new vm.Script(code, {
    produceCachedData: true,
    sourceless: true
  });
  if (!script.cachedDataProduced) {
    process.exit(2);
  }
});
process.stdin.resume();
`;

const code = 'console.log(1)';

const cmd = '被打包的node二进制文件路径';
const child = child_process.spawn(cmd, ['-e', script], {
  stdio: ['pipe', 'pipe', process.stdout],
});

function kill() {
  child.kill();
}

function onClose(code) {

  removeListeners();
  kill();
} 

function onError(error) {
  removeListeners();
  kill();
}


let bytecode = Buffer.alloc(0);

function onData(data) {
  bytecode = Buffer.concat([bytecode, data]);
}

function onEnd() {
  saveByteCode(bytecode);
}

function removeListeners() {
  child.removeListener('error', onError);
  child.removeListener('close', onClose);
  child.stdin.removeListener('error', onError);
  child.stdout.removeListener('error', onError);
  child.stdout.removeListener('data', onData);
  child.stdout.removeListener('end', onEnd);
}
function addListeners() {
  child.on('error', onError);
  child.on('close', onClose);
  child.stdin.on('error', onError);
  child.stdout.on('error', onError);
  child.stdout.on('data', onData);
  child.stdout.on('end', onEnd);
}

addListeners();

let codeBuffer = Buffer.from(snap);

child.stdin.write(codeBuffer);