Electron 加载原生模块总崩溃?搞懂这两行配置就够了
用 Electron + Vite 做桌面端开发,肯定会遇到这个情况:引入 C++ 原生模块。
比如你想搞个全局快捷键、监听鼠标宏,引入了 uiohook-napi 或者 koffi。 结果往往是:本地开发一启动就白屏报错,或者好不容易本地跑通了,打包成 exe 发给测试,双击秒退。 然后,弹出原生模块/依赖确实的弹窗。
如果你也经历过这种问题,大概率是没搞定这两部分:Vite 的打包拦截 和 Electron Builder 的 ASAR 归档。
今天我们就扒一扒 vite.config.ts 和 electron-builder.js 里,那两段看起来毫不起眼、却救了无数命的配置。
先搞清楚:原生模块为什么这么"娇贵"?
平时我们写的 JS/TS,Vite 随便揉捏,打包成什么样子都能跑。但像 uiohook-napi 这种底层库,它们最终会编译成一个 .node 后缀的二进制文件(动态链接库)。
这玩意儿有两个注意点:
- Vite 不认识它:强行用 Esbuild/Rollup 去打包分析二进制文件,路径绝对错乱。
- 操作系统的底层加载机制很笨:它只能通过绝对路径去硬盘上找真实的物理文件,读不懂虚拟的压缩包。
针对这两点,我们需要在不同的阶段下药。
第一道防线:保本地开发
位置 :vite.config.ts 的 plugins 里
typescript
plugins: [
isServe && notBundle({
filter: ['electron-log', 'uiohook-napi']
})
]
场景还原:
当你敲下 npm run dev,Vite 开始接管主进程的编译。它的本能是把 node_modules 里的依赖全打包到一起。一旦它碰到 uiohook-napi,由于里面掺杂了 .node 文件的引用,打包过程就会出岔子,导致主进程直接挂掉。
这行代码干了啥?
notBundle 插件的作用很简单,就是"按兵不动"。配置了 filter: ['uiohook-napi'] 后,相当于告诉 Vite:"碰到这个库,你别管,别打包,把 import 原样转成 require() 留着。"
这样,代码运行时实际上是 Node.js 运行时直接去 node_modules 目录里动态加载真实的二进制文件,本地开发就顺滑了。
顺便提个坑 : 你可能会在网上看到另一种函数写法:
filter: (id) => ['uiohook-napi'].includes(id) ? false : undefined。千万别这么写! 在这个插件里,返回
false等于"放行让 Vite 打包",返回undefined才是"不打包"。上面那种函数写法相当于把notBundle给废了,原生模块照样崩溃。直接用数组['xxx']才是最稳的标准姿势。
第二道防线:保生产打包
位置 :electron-builder.js
javascript
asarUnpack: [
"**/*.node"
]
场景还原:
本地 dev 好不容易没报错了,你信心满满地执行 npm run build,然后拿着生成的安装包去别人电脑上安装。双击图标,转圈,闪退。翻开日志一看:Error: dlopen failed...。
这行代码又干了啥?
electron-builder 打包时,默认会把你的代码、资源全塞进一个叫 app.asar 的归档文件里(你可以理解为一种没有后缀的 zip 虚拟目录)。

但是!操作系统底层加载 .node 文件用的 dlopen 方法,它是个"老实人",它不认 asar 这种虚拟文件系统,它必须去硬盘的真实路径上找文件。找不到,就直接抛异常闪退。
加上 asarUnpack: ["**/*.node"] 后,打包工具在生成 app.asar 的同时,会像个扫地僧一样,把所有 .node 文件单独挑出来,放进一个叫 app.asar.unpacked 的真实物理文件夹里。

当用户打开你的软件,Electron 底层做了个聪明的兼容:发现你要加载原生模块,它会自动去 unpacked 目录里找。这样,生产环境也稳了。
总结:一个原生模块的"通关"历程
如上图,我们可以把这两行配置看作是一个原生模块在项目里的两个关卡:
- 开发期(Vite) :
notBundle把它拦下,不让进打包队列了,直接走requirecjs通道去node_modules找本体吧。" - 打包期(ASAR) :
asarUnpack把它拦下,不压进虚拟压缩包,去旁边的unpacked目录待着。"
两道关卡各司其职,少一个,你的 Electron 应用在某个阶段必定崩给你看。下次再遇到原生模块加载失败,直接查这两个地方,基本应该没啥问题。