1 Node.js 加载 Native 模块 .node 文件的方法
在 Node.js 中加载由 C++、Rust 等其他编程语言编译的 .node
二进制模块文件,可以有两种方式。
1.1 方式一:直接使用 require
加载
示例:
const filepath = path.resolve(`./sqlcipher/${process.platform}-${process.arch}.node`); return require(filepath);
需要注意的是,在 Electron 中,使用 webpack 编译输出的代码中 require
是经过 webpack 辅助函数包装的,如果遇到因 require
方法调用导致的报错,可尝试改为如下方式:
const filepath = path.resolve(`./sqlcipher/${process.platform}-${process.arch}.node`); const nodeRequire = typeof __non_webpack_require__ === 'function' ? __non_webpack_require__ : globalThis.require; return nodeRequire(filepath);
1.2 方式二:使用 process.dlopen()
函数
process.dlopen()
函数允许动态加载共享对象。用法示例:
const filepath = path.resolve(`./sqlcipher/${process.platform}-${process.arch}.node`); const m = { exports: {} }; // @ts-ignore // eslint-disable-next-line process.dlopen(m, filepath); return m.exports as never as T;
一般来说 require
方式应该优先于 process.dlopen
,除非有特殊原因,例如需要自定义 dlopen
标志或从 ES
模块加载。
1.3 webpack
构建对 .node
加载的支持
在基于 webpack 构建的项目中,对于依赖其他 Native 语言开发编译的 .node
文件的处理,当前常见的方案是引入 node-loader 或 native-ext-loader 等模块实现。翻阅其源码可以了解到,其实它们的实现非常简单:根据 require('xxx.node')
依赖分析识别 xxx.node
,将该文件以 emitFile
方式产出到输出目录,同时返回基于 process.dlopen
加载的代码进行模块加载适配。
在基于 Electron 的应用中,可能会因为路径处理的问题存在加载 .node
异常的现象,需要具体分析和适配。
2 第三方模块库加载 .node
文件的方式
当前在 npm 仓库中流行的模块中,对于 Native 实现的 .node
文件的加载,基本都会使用 require
的方式,但具体方案则各有差异:
在早期没有 N-API 之前,针对于每一个 Node.js 的版本,都需要编译独立的 .node
二进制文件,不同版本之间不能通用。在这种背景下,大部分流行模块都会将 C++ 等 Native 语言的源码放在 npm 包中一起发布,同时支持基于 node-gyp 在模块安装时触发 install
钩子脚本启动本地编译。但这种方案常常会因为本地编译环境必要工具的缺失而失败,给使用方制造了额外的复杂性。
在 N-API 出现后,基于该规范开发编译的 .node
文件,一般都可以向后兼容不同的 Node.js 版本,于是一般只需要预编译一次,将产物共享出来即可。
一些项目会将针对所有平台编译的 .node
文件与包一起发布。但由于不同操作系统平台的 .node
文件是不可共用的,这可能会导致 npm 包的大小非常大。
更为常见的做法是将 .node
文件上传到存储服务器上,通过配置 scripts.install
钩子脚本,在模块被安装时实时根据当前系统类型下载对应的 .node
文件。node-pre-gyp工具实现了一种部署原生 Node 预编译二进制模块的方法, 许多流行的模块都是使用它。
对于 Node.js
项目来说,只要模块可以正常下载即可。但当部分网络不可访问的环境下(如屏蔽外网访问的企业内网服务器环境),则问题变的复杂起来。
一些流行模块支持以环境变量的方式自定义下载地址。例如 Electron
、node-sass
等著名模块。这种情况下只需要简单的配置内网可访问的资源地址即可。
但是也有一些模块并不支持自定义逻辑,这种情况下方案实现就变得复杂的多。
3 一种离线维护第三方 Native 模块的方案
从上面的介绍内容中,我们了解到对于不同的第三方模块实现的 .node
文件加载方案,可能会面临两个主要的问题:
- 内部私有网络下,无法下载
.node
文件 - 简单的使用
node-loader
可能不能正常的实现项目正常加载运行,需要逐一作对应的方案适配
为了避免过于复杂的构建环境配置,在我们的项目中采用了以下方法,实现了一种完全离线加载的方案:
- 实现工具方法
getAddonNodePath
和requireAddon
方法,用于支持自定义加载.node
文件 - 下载第三方模块源码到
src/addons
目录下维护 - 下载第三方模块用到的
.node
文件到addons
目录下,以<moduleName>/${process.platform}-${process.arch}.node
方式命名 - 修改
.node
文件的加载方式,使用requireAddon
方法加载,并移除远程下载逻辑 - 开发辅助脚本,项目构建时复制
addons
目录到产出目录,同时根据构建产物对应的目标系统平台,将其他系统平台的.node
文件过滤或移除。
getAddonNodePath
和 requireAddon
方法实现示例:
/** 获取适用于当前平台的 addon .node 文件路径 */ export function getAddonNodePath(addonName: string) { const platform = process.platform; const arch = process.arch; const nodeName = `${addonName.replaceAll('-', '_')}.node`; const list = [`build_win_${arch}/ReleaseDll/${nodeName}`, `napi-v6-${platform}-${arch}/${nodeName}`, `${platform}-${arch}/${nodeName}`]; for (let nodePath of list) { nodePath = getResourcesFilePath(path.join(`addons/${addonName}`, nodePath)); if (fs.existsSync(nodePath)) return nodePath; } return ''; } /** 加载 addon 模块 */ export function requireAddon<T>(addonName: string, dlopen = false): T { const nodePath = getAddonNodePath(addonName); if (!nodePath) throw Error(`未找到该模块:${addonName}`); (globalThis.logger || console).debug('[env][requireAddon]', addonName, dlopen, nodePath); if (dlopen) { const m = { exports: {} }; // @ts-ignore // eslint-disable-next-line process.dlopen(m, nodePath); return m.exports as never as T; } else { const nodeRequire = typeof __non_webpack_require__ === 'function' ? __non_webpack_require__ : globalThis.require; return nodeRequire(nodePath); } }
在第三方模块中使用 requireAddon
方法:
const { requireAddon } = require('@/utils'); const sqlite3 = requireAddon('sqlcipher', true);
由于模块源码和 .node
文件均在本地,.node
文件加载方案也完全自定义,不会再为项目构建发布流程增加复杂度。