1. 报错现场:一次“make 异常中断”的完整日志链
先说背景。我手头有一台刚换没多久的 M4 芯片 MacBook Pro,跑的是最新版 macOS,平时主要是用来做 Electron 相关的桌面应用开发。项目本身的依赖不复杂:Electron + Electron Forge + 几个需要编译的原生模块,之前在 Intel Mac 和 Apple Silicon 的其他机器上都是正常打包的。结果这周在新机器上跑npm run make的时候,连续几次都在同一步倒下,而且报错方式非常“不讲武德”——不是红字一堆,也没有提示具体哪一行语法错误,就是整个构建过程在某个子任务里静默中断,最后只留下一段残缺的日志。
这里直接贴我当时看到的失败尾巴,方便你对一下自己遇到的症状是不是同款:
> @ my-app@1.0.0 make > electron-forge make ✔ Checking your system ✔ Resolving Forge Config ✔ Resolving Make Targets ✔ Running Package hook ✔ Packaging application ✖ Running the make command An unhandled error has occurred inside Forge: make: *** [target] Error 1 Error: make failed with exit code 2这个报错最气人的地方在于:它根本没有告诉你到底是哪个 target 失败,也没有指明是哪一个 Makefile 里出的问题。Electron Forge 会把内部一大串子任务包装成一个很粗粒度的执行步骤,一旦中间有任何一步返回非零,它就直接把这个“锅”抛给你。我当时第一反应是“项目里哪个子模块的 Makefile 写坏了”,但翻了半天,项目里根本没有手写的 Makefile,这就很诡异了。
为了后续排查方便,我先把当时的环境信息完整列下来,这些信息在后面对比根因时非常关键:
- 芯片:Apple M4(arm64)
- 系统:macOS 最新稳定版
- Shell:zsh
- Node.js:v26.x(当时主版本刚追新装上的)
- npm:随 Node 26 一起安装的内置版本
- Electron Forge:8.x 系列
- 项目中原生模块:包含
node-serialport、sqlite3这种需要 node-gyp 参与编译的模块
如果你目前的报错信息和我上面的很像,先不要急着去改业务代码,也别一上来就“重装依赖三连”(删 node_modules、清缓存、重新 install)。这个问题的根子不在项目依赖,而在于构建工具链本身。
2. 排查链路:从怀疑 Makefile 到锁凶 node-gyp
那段时间我按“最可能的嫌疑犯”顺序排查了一遍,现在复盘来看,这个顺序本身是有问题的——我一开始把重点放在了 make 命令和 Makefile 上,导致前面几个小时基本是在浪费时间。但这个过程本身也很有价值,它帮我排除掉了大量干扰项,最终才精准定位到真正的问题。
2.1 第一轮排查:make 到底存不存在
报错信息里直接出现了make这个字眼,所以我第一件事就是检查系统里到底有没有 make、能不能正常执行。在 macOS 上,make 依赖的是 Command Line Tools,没有这个工具链,很多底层编译操作都会失败。
which make # 输出:/usr/bin/make make --version # 输出:GNU Make 3.81系统自带 make 是在的,而且版本是 GNU Make 3.81——这个版本号后来成了整件事的“破案关键”之一。接着我又验证了 Xcode Command Line Tools 的状态:
xcode-select -p # 输出:/Library/Developer/CommandLineTools这说明基础工具链是完整的。到这里,第一轮“是不是没装 make / 没装 Xcode CLT”的怀疑被排除。如果你在执行xcode-select -p时提示找不到路径,或者提示你需要安装 Command Line Tools,那问题就简单多了,直接执行xcode-select --install装好再继续。
2.2 第二轮排查:复现最小构建,缩小“案发范围”
既然基础工具链没问题,我开始怀疑是某个子模块的编译环节出了问题。Electron Forge 在make之前会先执行package,也就是把应用打成可执行的 .app 包,然后再执行平台相关的 make 步骤,背后主要依赖@electron/packager和electron-rebuild。
为了缩小范围,我把构建拆开跑:
npx electron-forge package这一步居然正常通过了。也就是说,应用被打包成 .app 的过程没毛病。问题出在更后面的make阶段。
然后我再单独去看依赖里的原生模块有没有被正常 rebuild。Electron Forge 在打包过程中会对原生模块做 ABI 重建,也就是确保这些模块编译出来的二进制能匹配当前 Electron 的 Node ABI 版本,而不是匹配你本机开发用的 Node 版本。这一步对 sqlite3、serialport 这类模块至关重要。
我直接用 node-gyp 手动触发了一次重建,来复现完整日志:
cd node_modules/sqlite3 npx node-gyp rebuild这次日志给了我一个非常有价值的线索:
gyp info using node-gyp@11.x.x gyp info using node@v26.x.x | darwin | arm64 gyp ERR! build error gyp ERR! stack Error: `make` failed with exit code: 2看到没有?node-gyp 调用了 make,然后 make 返回了退出码 2。但具体是 make 的什么命令挂掉,日志里还是没给全。于是我又手动执行了一次 make,这次能看到更具体的报错:
make -j 8画面瞬间就展开了:
make: *** [Release/obj.target/sqlite3/src/database.o] Error 1 make: *** [Makefile:146: Release/obj.target/sqlite3] Error 2而且在这个过程中,编译进程并不是稳定地走到某个文件才挂,有时候是 database.o,有时候是 statement.o,看起来毫无规律。更奇怪的是,单独编译某个 .o 文件时,clang++本身是可以正常执行的。这让我开始意识到:问题不在 C++ 源码,而在 make 引擎本身对某些内容的解析环节。
2.3 最终定位:node-gyp 重建阶段的“隐形中断”
我换了个思路,不再用 node-gyp 的简洁模式,而是让它在 rebuild 时打出每一行完整命令:
npx node-gyp rebuild --verbose这次终于看到了关键的一段。node-gyp 在执行编译任务时,会往 Makefile 中注入一些动态变量,而它注入的方式依赖于当前 Node.js 自带的 npm 版本所携带的 node-gyp 逻辑。在 verbose 模式下,我能看到 make 在解析一行带函数调用的规则时直接中断,而不是给出友好的错误提示。
结合 Google 和 GitHub Issues 里的大量案例,我基本锁定了一个反直觉的事实:真正导致 make 异常中断的,不是项目里的原生模块代码,而是 Node.js 26 内置的 npm/node-gyp 工具链在和 macOS 自带的 GNU Make 3.81 打交道时出现了不兼容。
3. 根因拆解:为什么 Node.js 26 能“背刺” macOS 自带的老 make
很多人在这一步会陷入一个思维误区:Node.js 跟 make 明明是八竿子打不着的关系,一个 JavaScript 运行时怎么会去影响 C++ 编译工具?我第一次意识到它们之间有关联时,也是在翻了半天工具链架构图之后。下面我把整条链路掰开讲清楚。
3.1 make 在 macOS 上的真实身份
macOS 系统里的/usr/bin/make其实是一个久未更新的 GNU Make 3.81,这个版本最早是 2006 年发布的。苹果一直赖着不升级它,原因很简单——系统内部还有其他组件依赖老版本的行为,贸然升级会影响系统稳定性。
GNU Make 3.81 的问题在于:它只支持很老的一套函数语法和规则解析方式。比如新版 Make 4.x 引入的$(intcmp ...)、$(file ...)这类函数,3.81 根本不认识。如果一个 Makefile 或一条构建规则里用到了这些新语法,老 make 的行为就是直接解析失败,而且报错信息往往非常“抽象”,有时候甚至不做任何解释就退出。
node-gyp 在生成 Makefile 时,理论上应该兼容不同版本的 make。但“理论上”和“实际上”之间的差距,就是现实里踩坑的地方。
3.2 Node 26 的隐式升级:node-gyp 换了“讲话方式”
Node.js 26 并不是一个简单的版本号升级。它内部自带的 npm 版本也跟着升了一大截,而 npm 内部又内置了一个 node-gyp 版本。这个内置 node-gyp 在生成构建规则时,针对的是现代 GNU Make(4.x)的解析能力来设计——底层维护者默认“大家的 make 都是新版”,但在 macOS 上这个默认值是不成立的。
具体差异体现在两个地方:
- 新 node-gyp 生成的 Makefile 规则中使用了较新的 make 函数和语法结构;
- 新的构建规则对
MAKEFLAGS和并行任务的处理方式,要求 make 能正确解析一些 3.81 不认识的控制指令。
当 Electron Forge 的 make 流程进入 rebuild 阶段时,它执行的是“Electron 的 Node ABI 版本 + 系统 make + node-gyp 生成的 Makefile”这套组合。在你本机开发时,如果用 Node 26 直接跑 node-gyp,同样的问题也会复现,只是很多项目不涉及原生模块,所以感知不到。
3.3 为什么 M4 芯片让这个问题更容易暴露
这里就要提到 Apple Silicon,尤其是 M4 芯片带来的一个“隐藏加成”了。
在 Intel Mac 时代,很多开发者会装 Rosetta 或者交叉工具链来兼容不同架构。当你用 Homebrew 装一些构建工具时,它们常常会同时安装 x86_64 和 arm64 两套,或者自动处理路径。到了 M4 芯片上,Homebrew 的默认前缀变成了/opt/homebrew,很多原本装在/usr/local下的构建工具路径变了,这本身不会直接导致 make 挂掉,但它会放大一些 PATH 相关的坑。
更重要的是,M4 芯片的新机器很多是从旧机器迁移过来的。迁移助手会把项目文件、环境变量配置一起搬过来,但不会把你以前手动装的构建工具链也完整搬过来。这就导致:你的 shell 里 PATH 包含了原来的某些路径,系统里也残留了一些半新不旧的工具,而真正完整的 Xcode 工具链却不在正确位置。node-gyp 在做工具链检测时,就会在这种混乱环境下做出错误判断,进而生成一份“看起来没问题、跑起来就挂”的构建环境。
我自己这台机器就遇到了这个问题——which make能被找到,但make --version的解析结果和新 node-gyp 的预期不一致,最终表现为“make 异常中断”。所以,M4 并不是根因本身,它更像是把老问题放大了的放大器。
4. 三种修复路径与实操命令
如果你也遇到了类似问题,不用纠结太久,下面这三条路径我全部实测过。根据你的实际情况选一条就行。
4.1 方案 A:切回 Node.js LTS 主线(最推荐)
这几乎是成本最低、见效最快的方式。Electron Forge 以及它底层的@electron/rebuild、node-gyp、node-addon-api这一整条工具链,对 Node.js 的激进主线版本并没有做到同步适配。Node 26 这种比较新的主线版本,其实更适合用来跑纯 JavaScript 项目和尝试新特性,而不是用来做 Electron 打包这种重度依赖原生工具链的工作。
具体操作:
# 如果你用的是 nvm nvm install 22 nvm use 22 node -v # 输出:v22.x.x # 确保 npm 也切过来 npm -v切换完 Node 之后,一定要做一次干净的依赖重装,否则旧 node_modules 里的二进制产物和新 Node 版本之间可能还会有些“历史遗留问题”:
rm -rf node_modules out .webpack npm install npm run make我在切到 Node 22 之后,第一次跑npm run make就顺利通过,整个 rebuild 阶段不再报 make 错误。这个方案背后其实很简单:把问题抛给工具链“舒适区”内的版本组合,让 node-gyp 用它可以完全掌控的方式工作。
4.2 方案 B:给 node-gyp 一条正确的 make 路径(保留 Node 26)
如果你因为某些原因必须留在 Node 26,也不要慌,还是有办法的。核心思路是:让 node-gyp 在构建时能找到新版 GNU Make,而不是去解析 macOS 自带的老版本 3.81。
第一步,安装新版 make。这里注意,Homebrew 默认会将 make 安装为“keg-only”软件,意思是不会直接替换系统的/usr/bin/make,而是放在独立目录,等你去手动调用:
brew install make安装完成后,新版 make 的真实路径通常是:
/opt/homebrew/opt/make/libexec/gnubin/make第二步,在构建时把这个路径提前注入 PATH。不推荐直接改系统的/etc/paths,也不推荐把 Homebrew 的 libexec 目录全局塞进 PATH——那样可能影响其他依赖老 make 行为的系统工具。更稳的方式是,在跑构建命令时临时指定一次:
export PATH="/opt/homebrew/opt/make/libexec/gnubin:$PATH" make --version # 输出:GNU Make 4.x然后重新执行:
rm -rf node_modules out .webpack npm install npm run make第三步,如果 node-gyp 仍然检测不到你刚加进 PATH 的 make,可以手动指定环境变量告诉它:
export npm_config_build_from_source=true export CC=clang export CXX=clang++ npm run make这种方法我实测下来也能跑通,但前提是你对工具链的 PATH 管理有基本概念。如果你只想一劳永逸,方案 A 会更省心。
4.3 方案 C:跳过 rebuild,强制使用预编译二进制(有风险)
还有一种方法能绕过编译环节:让 Electron Forge 跳过原生模块的 rebuild。适用于项目中使用的原生模块本来就有对应 Electron ABI 的预编译二进制的情况。
export ELECTRON_SKIP_REBUILD=1 npm run make这个方法跑得飞快,但风险在于:如果原生模块没有提供匹配当前 Electron 版本的预编译二进制,应用在运行时就会直接加载失败,报错往往是这样的:
Error: The module 'xxx.node' was compiled against a different Node.js version所以这个方案我只建议在两类场景下使用:
- 这个原生模块完全不需要 ABI 重编译,纯 JavaScript 实现;
- 你已经确认模块的
prebuilds目录里有匹配 Electron ABI 的现成产物。
多数情况下,我并不推荐方案 C 作为长期依赖,它更适合当作“临时绕过”手段来帮你判断问题到底出在编译环节还是打包环节。
5. 修复后的验证、签名提醒与相邻同类坑
问题修好之后,不要只盯着“build 成功”这一句话就完事。验证步骤做不全,后面分发阶段还会连环踩坑。
5.1 打包成功的判断标准
Electron Forge 的make默认会把产物输出到out/make目录。执行完npm run make之后,检查下面几个点:
- 退出码是否为 0;
- 终端里是否出现
Making a darwin arm64 distributable之类的提示; out/make/下是否生成了.dmg或.zip文件;- 生成的
.app能否在本机正常启动。
这里多提一句:如果你的构建配置里同时定义了多个 target(比如 zip + dmg),那么 dmg 生成失败时整体命令也会失败。有时候make报错不一定是编译挂了,也可能是 dmg 打包脚本出了问题。排查时先用npx electron-forge make --targets=zip这种方式单独验证某一个 target,能更快缩小范围。
5.2 签名、公证:比 make 更隐蔽的坑
就算make这一步顺利通过了,在 macOS 上你还会遇到一个紧随其后的坑:签名和公证。尤其是在 M 系列芯片的 Mac 上,系统对未签名应用的管控比 Intel 时代更严格。
Electron Forge 默认生成的 dmg 是不带开发者签名的,如果你只是自用,那没问题;但如果你要把这个 dmg 发给别人,对方大概率会收到“无法打开,因为无法验证开发者身份”的提示。
两个基本检查命令:
# 查看 .app 是否有签名 codesign -dv --verbose=4 out/make/你的应用.app # 查看是否已经公证 spctl -a -vv out/make/你的应用.app如果第二行输出里有accepted且 source 不是no signature,说明签名和公证状态是正常的。如果压根没签名,你需要在 forge.config 里配置osxSign和osxNotarize。这块一定要提前做,否则“make 成功”之后的愉快心情持续不了十分钟就会迎来新一波崩溃。
5.3 相邻的同类坑:一次说清楚
构建过程中的坑永远是相似的。这次的“Node 26 + 老 make”问题解决之后,我把相邻的几个常见报错也一起整理了一下,方便你一次性排查到位。
| 报错 / 现象 | 常见原因 | 快速解法 |
|---|---|---|
make: 没有指明目标并且找不到 makefile | 在错误目录直接执行 make,或者 Makefile 生成失败 | 不要手动乱跑 make,让 node-gyp / Forge 自己调用;清掉 node_modules 重装 |
error: program "make" not found in path | 系统没装 Xcode CLT,或 CLT 路径损坏 | 执行xcode-select --install;xcode-select -p确认路径存在 |
this version of pnpm requires at least node.js v22.13 | pnpm 版本和 Node 版本不匹配 | 用 nvm 切换 Node 到 LTS;或升级 / 降级 pnpm 到兼容版本 |
error installing 24.x.x: not yet released or not available | Node 版本发布信息还没同步到版本管理工具 | nvm install 22切到稳定版,或更新 nvm 后重试 |
| “打包到没有 Node.js 的电脑上跑不了” | 对 Electron 产物的误解 | Electron 应用自带 Node.js 运行时,目标机器不需要额外安装 Node |
最后那个误解值得多说两句。很多人以为“我用了 Node.js 开发,那用户电脑上是不是也得装 Node.js”。实际上 Electron 打包出来的 .app 文件里面已经包含了整套运行时,用户双击就能跑,跟你本机装没装 Node 没有任何关系。这也是很多新手对“打包”这个概念最深的误会之一。
6. 给还在 Node 26 边缘试探的人:几条预防建议
这一路排查下来,我最大的感受就是:构建工具的“版本组合”这个变量,往往比业务代码本身更容易让你加班。虽然这次的问题我已经给出了明确修复方案,但更希望你在以后的项目里从一开始就规避掉它。
第一,一个项目锁定一套 Node 版本,并把它写进项目配置里。最直接的做法是使用.nvmrc:
22然后在项目文档里写明“开发 / 打包请使用 Node 22 并执行nvm use”。CI/CD 流水线里也强制校验 Node 主版本,不匹配就直接拦截。这种事情看起来小题大做,但能省掉整个团队未来大量的“环境问题”排查时间。
第二,升级工具前先看一眼它的“底层依赖链”。你升级的不是一个孤立的包,而是它背后的一整条工具链。Electron Forge 依赖 electron-rebuild,electron-rebuild 依赖 node-gyp,node-gyp 又依赖 make 和 C++ 编译器。这条链上任何一环节跳了版本,后面全都会受牵连。升级前先想清楚:我升这个版本能换来什么新能力?如果答案只是“想用最新版”,那还是先缓一缓。
第三,在 M 系列芯片的机器上,强烈建议把“原生模块编译”当成一个独立的验证步骤。新机器到手,先别急着搬项目跑构建,花十分钟做一次工具链自检:Xcode CLT 是否完整、make 版本是什么、Homebrew 路径对不对。这些检查都是可复现的命令,跑一遍记录下来,以后出问题还能对比着看。
第四,不要迷信“清缓存能解决一切”。网上很多构建报错回答上来就是npm cache clean --force、rm -rf node_modules三连,但这次的问题我试过清缓存,完全没用。遇到构建失败,先看日志、先拆步骤,把“包一层壳”的命令拆开执行,定位到最底层的工具链再下手,比盲目重装高效得多。这条经验不仅适用于 Electron Forge,也适用于任何一门语言的构建体系。
最后再分享一个小小的实操习惯:如果你需要长期维护一个 Electron 项目,可以在项目里加一个scripts/env-check.js,启动构建前自动打印 Node 版本、npm 版本、make 版本、CLT 路径。不用太复杂,四五行脚本就能完成。将来不管是自己还是同事遇到报错,第一件事就是贴这份环境信息,省掉大量来来回回的“你那边是什么版本”的对话。
这次踩坑让我对 macOS 上的 Electron 打包工具链有了挺深的理解,也希望这篇记录能帮你少走几步弯路。如果你按这个思路排查后问题依然存在,建议带着完整的--verbose日志去 Electron Forge 的 GitHub 仓库开 issue。日志里包含的 Node 版本、node-gyp 版本、make 版本、芯片架构四个字段,是开发者定位这类问题的第一手依据。