在 Electron 应用开发中,主进程运行在 Node.js 环境中,所以很多项目会依赖包含原生 C++ 扩展的 npm 包,比如 serialport、better-sqlite3、node-hid 等。这些包安装时通常会编译出 .node 后缀的二进制文件,并通过 require 动态加载。一旦引入 Webpack 来打包主进程代码,默认的模块解析机制很容易把这些 .node 文件当成 JavaScript 去解析,或者干脆在打包产物里找不到它们,最终导致启动主进程时抛出 MODULE_NOT_FOUND 或语法错误。处理这类问题需要从 Webpack 的 target、externals、loader 以及 electron-builder 的打包策略多个层面入手。

先搞清楚 target 和运行环境的边界
Webpack 打包 Electron 主进程时,第一步必须把 target 设为 electron-main。这个配置告诉 Webpack 打包代码的运行环境是 Electron 的主进程,而主进程本质上是一个 Node.js 环境。如果不设置或者错误地设置为 web,Webpack 会按照浏览器环境来处理代码,把 Node 内置模块如 fs、path 等当作不存在,或者用 polyfill 替代,导致后续 require native 模块时出现各种怪异行为。很多初学者直接在 main 进程使用默认的 webpack 配置,结果发现打包后连 require(‘electron’) 都不能正常工作,原因就在这里。
下面是 Electron 主进程 Webpack 配置的最小可行版本,它只解决了环境识别,还没有处理 native 模块。
// webpack.main.config.js
const path = require('path');
module.exports = {
target: 'electron-main',
entry: './src/main/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'main.js',
},
node: {
__dirname: false,
__filename: false,
},
mode: 'production',
};
target: electron-main 会让 Webpack 保留 Node 的全局变量和模块加载方式,但在遇到 .node 文件时仍然会尝试把它当成 JavaScript 模块处理。这是因为 Webpack 的模块解析器不认识 .node 扩展名,也不会把它当作二进制资源来复制。所以仅靠 target 还不够,需要继续配置 externals 或专门的 loader。
处理 native 模块的两种路线:externals 与 node-loader
最常用也最稳妥的方案是把所有包含 .node 文件的依赖通过 externals 排除在 Webpack 打包范围之外。externals 的作用是告诉 Webpack,遇到这些模块名时不要解析它们,而是在运行时保留原有的 require 语句。这样主进程启动后仍然会从 node_modules 目录中加载原生模块,二进制文件位置不变,也不受 Webpack 打包过程影响。配置时可以写成一个对象,键是模块名,值是 commonjs2 模块名,例如把 serialport 和 better-sqlite3 排除。
// webpack.main.config.js 增加 externals
module.exports = {
target: 'electron-main',
entry: './src/main/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'main.js',
},
externals: {
'serialport': 'commonjs2 serialport',
'better-sqlite3': 'commonjs2 better-sqlite3',
'node-hid': 'commonjs2 node-hid',
},
node: {
__dirname: false,
__filename: false,
},
};
如果你使用的原生模块很多,手动列举会显得繁琐,也可以写成函数形式,利用正则把包含 node-gyp 构建标识的包统一排除。不过要注意,externals 只是让 Webpack 不处理这些模块,它不会把模块复制到输出目录。应用发布时仍然需要把这些模块作为生产依赖安装到最终的应用 node_modules 中。
另一种方案是使用 node-loader 来处理 .node 文件。node-loader 会把 .node 文件复制到输出目录,并返回新的路径给 require 使用。配置方法如下:
// 使用 node-loader 处理 .node 文件
module.exports = {
target: 'electron-main',
entry: './src/main/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'main.js',
},
module: {
rules: [
{
test: /\.node$/,
use: 'node-loader',
},
],
},
};
这个方案的好处是构建产物里会包含 .node 文件,看起来更“完整”。但它也有明显缺点:.node 文件一旦被复制到输出目录,后续使用 electron-builder 打包成 asar 时,必须把这些文件解包到真实文件系统,否则 Node 无法从 asar 虚拟路径加载原生模块。而且 node-loader 不会解决原生模块针对 Electron ABI 的编译问题,如果模块是用系统 Node 编译的,Electron 运行时仍然会报版本不匹配。因此,在实际项目中,externals 配合运行时依赖是更推荐的方案,node-loader 更适合某些对文件位置有特殊要求的场景。
发布阶段的 asar 解包与 electron-rebuild
使用 electron-builder 打包应用时,默认会把所有文件压缩进 app.asar 文件。asar 是一个虚拟文件系统,对于纯 JavaScript 代码没有问题,但 Node 在加载原生模块时需要访问真实的 .node 文件路径,不能从 asar 内部直接加载。因此凡是项目里包含 native 模块的,都必须在 electron-builder 配置中把 .node 文件解包到 app.asar.unpacked 目录。具体做法是在 package.json 的 build 字段中加入 asarUnpack 数组,匹配需要解包的文件。
{
"build": {
"appId": "com.example.myapp",
"asar": true,
"asarUnpack": [
"**/*.node",
"node_modules/serialport/**/*",
"node_modules/better-sqlite3/**/*"
],
"files": [
"dist/**/*",
"node_modules/**/*"
]
}
}
其中 “**/*.node” 可以覆盖大多数情况,但如果某些原生模块除了 .node 文件外还有依赖的 .dll 或 .dylib 动态库,也需要一并解包。更保险的做法是把整个原生模块目录都加入 asarUnpack,就像上面示例中对 serialport 和 better-sqlite3 的处理。
另一个关键步骤是 electron-rebuild。Electron 内置的 Node.js 版本与系统安装的 Node.js 可能不同,ABI 版本也不一致。如果一个原生模块是用系统 Node 编译的,直接放进 Electron 主进程会报 NODE_MODULE_VERSION 错误。electron-rebuild 会针对当前 Electron 版本重新编译所有或指定的原生模块。安装为开发依赖后,在 postinstall 脚本中执行 npx electron-rebuild -f -w 指定模块名即可。例如:
npx electron-rebuild -f -w serialport
建议在 CI 构建流程中把 electron-rebuild 作为固定步骤,确保每次发布的二进制文件都与 Electron 版本匹配。如果应用依赖多个 native 模块,可以不带 -w 参数直接重建所有检测到的原生模块,这样更省心。
常见错误排查与最佳实践
当主进程启动时出现 Cannot find module ‘serialport’ 或者 Cannot find module ‘./build/Release/serialport.node’,首先检查 externals 配置是否真的生效,确认打包产物中没有把模块代码打进去。可以通过在打包后的 main.js 中搜索模块名来验证:如果搜索不到,说明被排除了,运行时需要从 node_modules 加载。其次检查应用打包后 node_modules 目录是否完整,尤其是原生模块是否被错误地当作 devDependency 而没有安装到生产环境。
如果报错信息是 NODE_MODULE_VERSION mismatch 或 The module was compiled against a different Node.js version,基本可以确定是 ABI 不匹配。解决方法是先删除 node_modules,重新安装依赖,然后执行 electron-rebuild,再重新打包。不要试图通过修改 package.json 的 engines 字段来绕过,这治标不治本。
还有一个容易被忽略的点:在 Webpack 配置中使用了 node-loader 或 file-loader 处理 .node 文件时,electron-builder 的 asarUnpack 必须覆盖输出目录里的 .node 文件,而不是只覆盖 node_modules。否则运行时依然会因为 asar 虚拟路径报错。最佳实践是始终把原生模块放在 externals 中,让运行时从 node_modules 真实路径加载,并在 electron-builder 里解包整个模块目录。这样开发环境和打包环境的差异最小,也方便调试。
综合来看,处理 Electron 主进程的 native 模块,核心就是让 Webpack 不要把这些二进制文件当普通 JS 资源处理,同时保证发布后的应用能访问到真实文件系统上的 .node 文件。只要把 target、externals、asarUnpack、electron-rebuild 这几个环节配合好,原生模块在 Electron 主进程中就能稳定工作。