1. 为什么 Electron 桌面项目绕不开原生 DLL
如果你做的是纯 Web 转桌面的项目,Electron 调用 DLL 这件事大概一辈子都不会遇到。但只要你的项目沾上一点"和真实世界打交道"的业务——读一块采集卡、驱动一台光谱仪、调用一个老旧的加密模块、复用一套只有 lib 和 dll 的算法库——这件事就一定会摆到桌面上。
我最近接手的一个项目就是典型场景:客户买了某厂商的传感器模组,随附的 SDK 压缩包解开来只有三样东西——一堆 .h 头文件、一个 .lib 导入库、一个 .dll 动态库,外加一份 30 页的 PDF 说明书。界面要求用 Electron 做,理由是团队前端人手多、迭代快。于是问题变成了:一个只认 C 接口的硬件 SDK,怎么在 Node.js 运行时里被顺滑地调起来?
这个问题的难点不在"能不能调",而在于整条链路上有一堆隐形的地雷:位数对不对、调用约定对不对、字符串谁分配谁释放、打包后路径还在不在、ABI 版本和 Electron 自带的那份 Node 是不是一路货色。踩过一遍之后你会发现,真正调通那次调用只花了十分钟,剩下两天全在处理这些边角。
1.1 硬件和外设 SDK 基本只认原生接口
串口、USB、PCIe 采集卡、工业相机、指纹仪、POS 打印机、加密狗,这一类设备厂商提供的开发包 99% 是 C/C++ 动态库,少数会额外给个 C# 封装,几乎不会给你 JavaScript 版本。这不是厂商偷懒,而是驱动层和内核态交互本来就只能在原生语言里做。
对这类场景,你能做的选择其实很少:要么在 Electron 里想办法加载这个 DLL,要么写一个本地 HTTP 服务当中间层,让 Electron 去请求它。后者在工程上不是不行,但它把"一个桌面应用"硬生生拆成了"一个桌面应用 + 一个常驻进程",安装、升级、进程守护、端口占用全都要额外处理,得不偿失。
1.2 手里已有的 C/C++ 资产复用
另一个高频场景是复用存量资产。很多做工业软件、医疗设备、检测仪器的团队,核心算法是用 C/C++ 写了十几年的,几十万行代码、各种数值优化和边界处理都磨得非常稳。你不可能为了做一个新界面就把它重写一遍,也没必要。
这种情况下,正确的姿势是给老代码写一层薄薄的导出接口,编译成 DLL,然后由 Electron 侧的 JS 去调用。老代码一行不改,新界面用现代前端技术栈,两边的迭代节奏互不干扰。我在几个项目里都用过这个思路,效果比想象中好。
1.3 反过来想:哪些情况下不该用 DLL
不是所有需求都值得上原生库。如果你的需求只是读写文件、处理 JSON、做个简单的数学计算,那用纯 JS 实现反而更省事——没有编译环节,没有跨平台适配,打包也不会出幺蛾子。
判断标准其实很简单:这个能力是不是只有原生代码能给,或者原生实现带来的性能收益是否大于它引入的工程复杂度。视频编解码、大矩阵运算、硬件寄存器操作、需要精确控制内存布局的二进制协议解析,这些值得上 DLL。而一个字符串格式化函数,哪怕 C 版本快十倍,也不值得。
2. 选型先行:ffi 系、koffi、原生 N-API 三条路的取舍
确定要调 DLL 之后,第一个决策是"用什么方式调"。业内主流有三条路,各自适用场景差别很大,选错了后面会一路别扭。
2.1 ffi-napi:能跑,但新项目别赌
ffi-napi是node-ffi在 N-API 时代的续作,它的特点是不用编译任何 C++ 代码,直接在 JS 里声明函数签名就能调用动态库。写法大概是这样的:
const ffi = require('ffi-napi'); const ref = require('ref-napi'); const lib = ffi.Library('C:\\sdk\\SampleSDK.dll', { AddEx: ['int', ['int', 'int']], ReadChannel: ['int', ['int', ref.refType('double')]], GetVersion: ['string', []] }); console.log(lib.AddEx(3, 4));看起来很美好,但它有个致命问题:ffi-napi依赖node-gyp现场编译原生模块,而它本身维护频率很低,对新版 Node 和 Electron 的适配经常滞后。你在 Electron 27 上装得好好的,升到 Electron 30 就可能编译失败,报一堆nan.h相关的错。更麻烦的是它的依赖树里有ref-napi、node-gyp-build等一串包,任何一环出问题都要顺着翻。
如果你的项目已经用了ffi-napi且跑得稳定,那没必要动它。但如果是新项目,我建议直接跳过。
2.2 koffi:目前性价比最高的一条路
koffi是近几年冒出来的一个替代方案,同样不需要写 C++ 代码,但它的原生部分是用 C 写的、预编译分发的,安装时不需要node-gyp,也不需要 Visual Studio 构建工具链。这一点在团队协作里价值极大——新人拉下代码npm install就能跑,不用先装一套 6 个 G 的 VS Build Tools。
它的调用写法比ffi-napi直观不少:
const koffi = require('koffi'); const lib = koffi.load('C:\\sdk\\SampleSDK.dll'); const AddEx = lib.func('int __stdcall AddEx(int a, int b)'); const ReadChannel = lib.func('int __stdcall ReadChannel(int ch, _Out_ double *value)'); const GetVersion = lib.func('const char *__stdcall GetVersion()'); AddEx(3, 4);注意_Out_这个标记,这是koffi的一个亮点:它明确区分了输入指针和输出指针,调用完直接拿返回值就行,不用像ref那样手动ref.alloc()再解引用。对结构体、数组的支持也更省心。
2.3 什么时候必须自己写 N-API 模块
koffi能覆盖绝大多数场景,但有几类情况它会力不从心:
第一类是回调密集的 SDK。比如某些设备库要求你注册一个事件回调,设备状态变化时从原生线程回调过来,频率可能上千赫兹。这种跨语言回调对 JS 引擎的压力很大,用 FFI 层转发容易出问题。
第二类是复杂结构体嵌套。如果 SDK 里有个三层嵌套的结构体、里面还带柔性数组和位域,用字符串签名去描述它既啰嗦又容易错。
第三类是需要精细控制生命周期的场景,比如你必须保证某个资源在特定时机释放。
这时候就得老老实实写一个 N-API 模块,用node-addon-api的 C++ 封装:
#include <napi.h> #include "SampleSDK.h" Napi::Value AddExWrapped(const Napi::CallbackInfo& info) { Napi::Env env = info.Env(); int a = info[0].As<Napi::Number>().Int32Value(); int b = info[1].As<Napi::Number>().Int32Value(); int result = AddEx(a, b); return Napi::Number::New(env, result); } Napi::Object Init(Napi::Env env, Napi::Object exports) { exports.Set("addEx", Napi::Function::New(env, AddExWrapped)); return exports; } NODE_API_MODULE(sample_addon, Init)代价是你需要一套完整的编译环境,而且每次 Electron 升级都要重新编译(后面会细说 ABI 的事)。
2.4 三条路线的横向对比
| 维度 | ffi-napi | koffi | 自写 N-API |
|---|---|---|---|
| 是否需要编译 C++ | 需要(node-gyp) | 不需要 | 需要 |
| 安装对环境的要求 | 高(VS Build Tools) | 低 | 高 |
| 复杂结构体支持 | 一般 | 好 | 最好 |
| 回调性能 | 差 | 中 | 好 |
| Electron 升级维护成本 | 高 | 低 | 中 |
| 上手难度 | 中 | 低 | 高 |
| 适合场景 | 已存量的老项目 | 绝大多数新项目 | 高频回调、复杂内存布局 |
我的建议很直接:新项目先上koffi,碰到它搞不定的再局部下沉到 N-API。不要一上来就写 C++,那是在给自己加无谓的负担。
3. 调用该放在哪个进程:主进程、渲染进程与 worker_threads 的边界
技术路线定了,下一个问题是"在哪调"。Electron 有主进程和渲染进程之分,这个问题处理不好会同时踩到安全和性能两个坑。
3.1 DLL 调用统一收口到主进程
渲染进程负责画界面,它需要开启contextIsolation、禁用nodeIntegration,这是基本的安全底线。一旦你为了图省事在渲染进程里require('koffi'),就等于把整个 Node 运行时暴露给了页面代码,任何一个 XSS 都可能变成任意代码执行。
所以正确的做法是:所有 DLL 调用都放在主进程,渲染进程通过 IPC 请求主进程代劳。
主进程里维护一个原生层的封装模块,比如native/meter.js:
const koffi = require('koffi'); const path = require('path'); let lib = null; let AddEx = null; function ensureLoaded(baseDir) { if (lib) return; lib = koffi.load(path.join(baseDir, 'SampleSDK.dll')); AddEx = lib.func('int __stdcall AddEx(int a, int b)'); } module.exports = { ensureLoaded, add: (a, b) => AddEx(a, b) };然后在main.js里注册 IPC 处理器:
const { ipcMain } = require('electron'); const meter = require('./native/meter'); ipcMain.handle('meter:add', async (event, { a, b }) => { meter.ensureLoaded(getNativeBaseDir()); return meter.add(a, b); });渲染进程通过 preload 暴露出来的接口调用,而不是直接碰原生层。
3.2 preload 只暴露业务语义,不要暴露通用调用器
preload 脚本是桥梁,但桥不能修成高速公路。我见过有人这么写:
// 反例,千万别这么干 contextBridge.exposeInMainWorld('native', { load: (p) => require('koffi').load(p), call: (fn, ...args) => fn(...args) });这等于把任意 DLL 加载能力送给了页面。正确的做法是把接口收敛到业务动作层面:
const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('meterApi', { add: (a, b) => ipcRenderer.invoke('meter:add', { a, b }), readChannel: (ch) => ipcRenderer.invoke('meter:readChannel', { ch }), getVersion: () => ipcRenderer.invoke('meter:getVersion') });页面能做的只有"读某个通道""取版本号"这类有明确语义的事,它无法指定加载哪个库、也无法传任意指针。安全性上了一个台阶,代码可读性也更好。
3.3 阻塞型调用交给 worker_threads
有些 DLL 函数是同步阻塞的,比如"等待设备响应",内部可能Sleep几百毫秒甚至几秒。如果你在主进程里直接调,整个 Electron 的主事件循环会被卡住——窗口拖不动,菜单点不开,用户会以为程序死了。
这种情况有两种处理方式。一是看 SDK 有没有异步版本,优先用异步接口。二是把调用挪到worker_threads里:
// worker.js const { parentPort } = require('worker_threads'); const meter = require('./native/meter'); parentPort.on('message', (msg) => { if (msg.type === 'read') { meter.ensureLoaded(msg.baseDir); parentPort.postMessage({ id: msg.id, value: meter.readChannel(msg.ch) }); } });主进程里起一个常驻 worker,通过postMessage通信。这样阻塞发生在 worker 线程里,主进程照样流畅响应界面操作。
注意:worker 里也要重新
koffi.load()一次,因为每个线程有独立的模块作用域,主进程加载过的库在 worker 里不可见。
4. 环境对齐:位数、ABI、运行库这三个隐形杀手
这一段是我踩坑最多的地方,也是很多教程一笔带过、但实际项目里翻车率最高的部分。
4.1 32 位和 64 位不匹配的报错形态
DLL 的位数必须和宿主进程完全一致。你不可能在一个 64 位进程里加载 32 位 DLL,反过来也一样。厂商给的 SDK 里 32 位版本特别常见,尤其是做工控、医疗设备的,因为很多老驱动就是 32 位的。
如果位数不匹配,Windows 加载器会直接抛出:
Error: %1 is not a valid Win32 application.对应的系统错误码是 193。这个提示有误导性——它没说清到底谁不是有效的 Win32 应用。实际情况往往是"你的 Electron 是 64 位,但这个 DLL 是 32 位"。
解决办法是让整个应用以 32 位模式打包。在package.json的构建配置里指定架构:
{ "build": { "win": { "target": [ { "target": "nsis", "arch": ["ia32"] } ] } } }然后确认node_modules里的原生模块也是 32 位的,需要重新安装:
npm install --arch=ia32这一步经常被忘掉,结果就是 DLL 加载成功了,但koffi自己的.node文件是 64 位的,一样崩。
4.2 Electron 的 ABI 和本机 Node 的 ABI 不是一回事
Electron 内置了一个改过的 Node 运行时,它的 NODE_MODULE_VERSION(也就是 ABI 版本号)和官方 Node 不一样。这意味着你在系统 Node 下能正常require的原生模块,在 Electron 里可能直接报:
Error: The module '...' was compiled against a different Node.js version using NODE_MODULE_VERSION 108. This version of Node.js requires NODE_MODULE_VERSION 119.解决方式是针对 Electron 重新编译。目前主流用@electron/rebuild:
npx electron-rebuild -f -w koffi-f表示强制重编,-w指定只处理某个模块,能省不少时间。
更省事的做法是在package.json里挂一个 postinstall 钩子,让electron-builder自动处理:
{ "scripts": { "postinstall": "electron-builder install-app-deps" } }这样每次npm install完,原生依赖会自动按当前 Electron 版本重建。团队协作时这一条能省掉大量"为什么你那边能跑我这边跑不了"的扯皮。
4.3 依赖链断了比 DLL 本身缺失更常见
最让人头大的错误不是"找不到 SampleSDK.dll",而是"找不到某个你从没听说过的 DLL"。Windows 加载一个 DLL 时会递归加载它的依赖,任何一个环节缺失都会失败,但报错信息只告诉你最外层那个加载失败了。
举个例子,某厂商的设备库依赖msvcr120.dll、libusb-1.0.dll、hidapi.dll三个东西,而它的安装包里只带了后者两个。你在开发机上能跑,是因为开发机装过 VC++ 2013 运行库;换到客户干净的系统上,直接崩。
排查方式是查依赖树。Windows SDK 自带的dumpbin就够用:
dumpbin /dependents SampleSDK.dll输出会列出它直接依赖的所有 DLL。如果某个名字你没见过,就去系统目录和 SDK 目录里找一下,找到就一并拷进发布包。
提示:如果开发环境没有
dumpbin,可以用开源的 Dependencies 工具(Dependency Walker 的现代替代品),界面更友好,还能识别 API Set 这类虚拟依赖,不会像老工具那样报一堆假警告。
4.4 VC++ 运行库:最容易被忽略的一环
很多 C++ 编译出来的 DLL 依赖vcruntime140.dll、msvcp140.dll这些微软运行库。这些文件在 Windows 上不保证一定存在,尤其是精简版系统或者全新装的企业环境。
两个处理策略:一是让安装包内置 VC++ Redistributable,安装时静默执行;二是直接把对应版本的运行库 DLL 放到应用目录,跟着一起发布。
前者更规范,后者更省事。我的习惯是优先做成安装包内置,因为直接塞 DLL 容易在系统更新后产生版本冲突。但如果你的应用是免安装的绿色包,那就只能选后者,记得把msvcp140.dll、vcruntime140.dll、vcruntime140_1.dll这几个都带上。
5. 从零跑通第一次调用:导出函数、字符串与结构体
环境对齐之后,终于可以开始写调用了。但"能加载"和"能用好"之间还有一段距离,这段距离全在数据类型的处理上。
5.1 先用 dumpbin 把导出表看清楚
拿到任何 DLL,第一步不是写代码,而是看它到底导出了什么。C++ 有个讨厌的特性叫名字修饰(name mangling),一个void Foo(int)编译出来可能变成?Foo@@YAXH@Z这种东西,你照着头文件里的名字去调是找不到的。
dumpbin /exports SampleSDK.dll输出会给出完整的导出符号表,包括函数名和序号。如果你看到一堆带?和@@的符号,说明这个库是用 C++ 编译的,你需要:
第一,看头文件里有没有extern "C"装饰,有的话说明它同时也导出了未修饰的名字; 第二,如果没有,那基本只能用序号调,或者自己再包一层 C 接口的壳。
顺便说一下 32 位和 64 位的修饰规则不一样。32 位下__stdcall导出int AddEx(int, int)会变成_AddEx@8,那个@8是参数总字节数。64 位下不做这个修饰,直接就是AddEx。所以同一个库在不同位数下的导出名可能不同,这点要注意。
5.2 基础类型与调用约定
调用约定是 32 位 Windows 上必须关心的事。最常见的是__stdcall和__cdecl,区别在于谁负责清理栈。SDK 文档里一般会写,如果没写就去头文件里翻:
SAMPLE_API int __stdcall AddEx(int a, int b); SAMPLE_API int __cdecl Multiply(int a, int b);在koffi里,调用约定直接写在签名里:
const AddEx = lib.func('int __stdcall AddEx(int a, int b)'); const Multiply = lib.func('int __cdecl Multiply(int a, int b)');在ffi-napi里则是通过第三个参数指定:
const ffi = require('ffi-napi'); const lib = ffi.Library('SampleSDK.dll', ffi.FFI_STDCALL, { AddEx: ['int', ['int', 'int']] });64 位 Windows 上只有一种调用约定(Microsoft x64 calling convention),所以写不写__stdcall都不影响。但如果你的应用是 32 位的,写错调用约定会直接导致栈错乱、程序崩溃,而且崩溃位置往往离出错点很远,非常难查。
5.3 字符串参数的内存归属问题
字符串是跨语言调用里最容易出问题的地方,核心矛盾在于内存是谁分配的、由谁释放。
常见的三种情况:
第一种,函数返回const char*,内存由库自己持有,调用方不能释放。koffi用const char *签名会自动转成 JS 字符串:
const GetVersion = lib.func('const char *__stdcall GetVersion()'); console.log(GetVersion()); // "1.2.3"第二种,函数返回char*但要求调用方释放,这种必须显式声明为指针,拿到地址后手动调Free:
const GetDetail = lib.func('char *__stdcall GetDetail()'); const FreeBuffer = lib.func('void __stdcall FreeBuffer(void *p)'); const ptr = GetDetail(); const text = koffi.decode(ptr, 'char', -1); // 读到 NUL 为止 FreeBuffer(ptr);第三种,调用方传缓冲区进去,库往里面写。这种最常见于取名字、取序列号之类的接口:
const GetSerial = lib.func('int __stdcall GetSerial(_Out_ char *buf, int bufLen)'); const buf = Buffer.alloc(64); const ret = GetSerial(buf, 64); console.log(buf.toString('utf8', 0, ret));这里有个细节坑:某些库用的是宽字符(wchar_t/char16_t),也就是 UTF-16。如果你用char*去接,拿到的会是一串夹杂 NUL 的乱码。这时候签名要改成char16_t *,koffi会自动处理宽窄转换:
const GetSerialW = lib.func('int __stdcall GetSerialW(_Out_ char16_t *buf, int bufLen)');5.4 结构体、数组与 out 参数
结构体是 FFI 场景下的重头戏。假设 SDK 里有这么一个结构:
typedef struct { int channel; double value; unsigned int timestamp; } SampleReading;koffi里用koffi.struct定义:
const SampleReading = koffi.struct('SampleReading', { channel: 'int', value: 'double', timestamp: 'uint32_t' }); const GetReading = lib.func('int __stdcall GetReading(int ch, _Out_ SampleReading *out)'); const reading = {}; GetReading(0, reading); console.log(reading.value);注意结构体的内存对齐。C 编译器默认按成员类型宽度对齐,int(4) + padding(4) + double(8) + uint32(4) + padding(4)总共 24 字节,而不是 4+8+4=16 字节。如果你自己拿 Buffer 手工拼结构体,对齐算错了就会读到错位的数据。用koffi.struct定义的好处是它会自动按平台规则算对齐,省得自己算。
数组的处理思路类似,用koffi.array('double', 8)之类的写法定义定长数组类型。
5.5 回调函数注册与释放
有些 SDK 需要你注册回调,设备状态变化时通知你。用koffi注册回调的基本写法:
const DeviceEvent = koffi.proto('void __stdcall DeviceEvent(int code, const char *msg)'); const RegisterCallback = lib.func('int __stdcall RegisterCallback(DeviceEvent *cb)'); const cb = koffi.register((code, msg) => { console.log('event', code, msg); }, koffi.pointer(DeviceEvent)); RegisterCallback(cb); // 不用的时候一定要注销,否则会内存泄漏 const Unregister = lib.func('void __stdcall UnregisterCallback()'); // Unregister(); // koffi.unregister(cb);这里有两个必须注意的点。
第一,回调函数必须保持引用。如果你把回调写成匿名函数直接传进去,V8 的垃圾回收随时可能把它回收掉,之后原生代码调用这个地址就是野指针,直接崩。koffi.register返回的句柄要存到一个长期存活的对象上。
第二,原生线程回调进来的代码要轻。回调可能发生在 SDK 自己的工作线程上,这时候不要在里面做耗时操作或者直接调 Electron 的 API,最安全的做法是把数据丢进队列,让主线程去取。