使用 nexe 进行跨平台编译

最近遇到一个小需求,希望写一个 Windows 可执行文件,做一些监听 U 盘插入事件的事情。由于不需要 GUI,electron 就用不到了。不过仍然需要打包 Node.js 执行环境进来。

一番搜索之后找到了 nexe 这个库。简单介绍一下它的几个关键特性:

下面我们看一下它的使用方法。

内置打包工具

首先默认使用的打包工具是 Fusebox,但是也支持通过 bundle 选项切换成其他构建工具。例如我们使用自定义的构建方法:

// build.js

const nexe = require('nexe');
nexe.compile({
    output: 'native-build',
    target: 'win32-x64-8.9.4',
    bundle: './nexe-bundle.js',
    silent: false
});

在构建方法中,我们就可以使用任意构建工具编译 JS 文件了,只要最后返回最终结果。 这里我们使用 Webpack 4:

// nexe-bundle.js

const webpack = require('pify')(require("webpack"))
const fs = require('fs')

module.exports.createBundle = function (options) {
    return webpack({
        entry: options.input,
        target: 'node',
        output: { filename: 'dist/tmp.js' }
    }).then(() => {
        const result = fs.readFileSync('./dist/tmp.js').toString()
        fs.unlinkSync('./dist/tmp.js')
        return result
    })
}

选择目标平台

nexe 支持 Mac Windows Linux 平台下的构建。通过 target: 'win32-x64-8.9.4' 我们可以选择操作系统以及 Node.js 版本。 全部可用的列表在这里

首次构建时会下载目标平台下的运行环境,并进行缓存。

需要注意的是,由于某些第三方库依赖当前运行环境,所以要想编译跨平台的程序最好在目标平台上进行构建。 例如在 Mac 上想要构建 Windows 下的 exe,最好安装虚拟机,比如 Parallel Desktop。

node-gyp

Node.js 虽然强大,但是在使用某些平台底层的功能时,还是需要依赖 C++ 编写的组件。在 Node.js 中,可以通过 node-gyp 将这些原生代码编译成 node 模块,在运行时很方便地进行调用。

node-usb-detection 为例,在 binding.gyp 文件中:

{
  "targets": [
    {
      "target_name": "detection",
      "sources": [
        "src/detection.cpp",
        "src/detection.h",
        "src/deviceList.cpp"
      ],
      "include_dirs" : [
        "<!(node -e \"require('nan')\")"
      ],
      'conditions': [
        ['OS=="win"',
          {
            'sources': [
              "src/detection_win.cpp"
            ],
            'include_dirs+':
            [
              # Not needed now
            ]
          }
        ],
        ['OS=="mac"',
          {
            'sources': [
              "src/detection_mac.cpp"
            ],
            "libraries": [
              "-framework",
              "IOKit"
            ]
          }
        ]
      ]
    }
  ]
}

更详细的例子可以参考 Node.js addons 文档

Windows 上的可怕经历

在 Windows 虚拟机上的编译经历可谓困难重重。

首先按照 node-gyp 的安装说明,执行:

npm install --global --production windows-build-tools

这一步会安装 Python 和 VS 构建工具。这时候可以检查下 C:\Program Files (x86)\MSBuild\Microsoft.Cpp\v4.0 下是否有 v140 也就是 VS2015 的构建工具。

一切顺利的话就可以开始安装 npm 依赖了,很多使用了 node-gyp 的第三方依赖此时会进行 prebuilt-install,开始构建 node addon。

运行时如果出现如下错误 MSB4019

error MSB4019: The imported project “C:\Microsoft.Cpp.Default.props” was not found. Confirm that the path in thedeclaration is correct, a nd that the file exists on disk.

需要在 CMD 中设置环境变量,这里我们设置成之前安装好的 VS2015 的路径。相关 ISSUE

SET VCTargetsPath=C:\Program Files (x86)\MSBuild\Microsoft.Cpp\v4.0\v140

如果出现如下错误 MSB8036

MSB8036: The Windows SDK version 8.1 was not found. Install the required version of Windows SDK

则需要配置 npm 环境变量,相关 ISSUE

npm config set msvs_version 2015

总之,遇到问题可以先在 MS 官方的指导意见 中查找,能少走一些弯路。

示例:监听 U 盘插拔

回到我们最初的需求,希望监听 U 盘的插拔。

启动监听后,进程不会退出,类似 DOS 中的 pause 语句。 在监听到 add 事件时,回调函数会传入插入 USB 设备的对象。

const usbDetect = require('usb-detection');

usbDetect.startMonitoring();
usbDetect.on('add', device => {});

值得注意的是在这个设备对象中,是不包含挂载点的,更多的是一些底层设备信息。 一些更高层次得操作,例如试图获取挂载点,并没有提供,相关ISSUE

{
	locationId: 0,
	vendorId: 5824,
	productId: 1155,
	deviceName: 'Teensy USB Serial (COM3)',
	manufacturer: 'PJRC.COM, LLC.',
	serialNumber: '',
	deviceAddress: 11
}

为了获取当前得挂载路径,不得不借助其他库,例如 drivelist。 通过定时遍历当前所有驱动设备,我们能找出其中的 USB 存储设备,得到其挂载点。 之所以使用定时器是因为挂载需要时间,触发 USB 设备的 add 事件时还没有挂载好。

const drivelist = require('drivelist');
let checkUSBIntervalID;

checkUSBIntervalID = setInterval(() => {
    drivelist.list((error, drives) => {
        if (error) {
            throw error;
        }
        drives.forEach(drive => {
            if (!drive.isSystem && drive.isRemovable
                && drive.mountpoints.length) {
                clearInterval(checkUSBIntervalID);

                scanDrive(drive);
            }
        });
    });
}, 1000);

得到了挂载点信息,就可以使用 fs 模块进行文件操作了:

mountpoints: [ { path: '/Volumes/KINGSTON', label: 'KINGSTON' } ],

查找 node addon

最后我们关注一下 nexe 中的一个技术细节。

使用 node-gyp 编译之后得到 node addon,可以通过 bindings 模块使用。 例如之前介绍过的 usb-detection 是这么使用的:

var detection = require('bindings')('detection.node');

那么 nexe 是如何解决运行时 node addon 路径查找的呢?

按照官方文档的说法,编译之后的 .node 文件会生成在 build/Release/ 目录下,解析路径时也会默认从这里开始查找。

Next, invoke the node-gyp build command to generate the compiled addon.node file. This will be put into the build/Release/ directory.

nexe 利用 Fusebox 的转译插件(类似 Babel),对代码中的 bindings 路径进行了重写