1. "Ignored build scripts" 到底在拦什么
1.1 一次典型报错现场
先把场景铺开。我最近在维护一个内部后台项目,依赖里有better-sqlite3、sharp、esbuild这些带原生二进制的包。某天同事升级了 pnpm 版本之后重新安装依赖,pnpm install跑完没有红字报错,但启动服务时直接抛异常:
Error: Cannot find module 'bindings'或者更直白一点:
Ignored build scripts: esbuild, sharp. Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.如果你也看到这句 "Ignored build scripts",说明你用的 pnpm 版本已经默认开启了依赖构建脚本拦截策略。这不是装坏了,也不是网络问题,而是 pnpm 从某个版本开始,默认不再执行依赖包里的preinstall、install、postinstall脚本。
我见过太多人在这里绕远路:有人反复删除node_modules重新安装,有人把 pnpm 卸载重装,有人干脆换回 npm。这些操作都没用,因为问题根本不在安装过程,而在 pnpm 的配置策略。
1.2 默认拦截背后的安全逻辑
pnpm 10 这次改动不是拍脑袋,它针对的是软件供应链攻击。npm 生态里,依赖包在安装时可以运行任意脚本,这意味着你只是装了个包,它就能在你的机器上执行任何代码。过去几年里,ua-parser-js、coa、rc这些流行包都被攻击者篡改过,往postinstall里塞挖矿脚本或者窃取环境变量的恶意代码。一旦这些包进入依赖树,所有安装它的人都会中招。
pnpm 的做法是:我默认不执行依赖的构建脚本,你明确点头我才执行。这个逻辑放在安全视角下完全合理——默认拒绝,而不是默认放行。代价就是一大批依赖正常安装流程被打破,因为它们确实需要install脚本来下载二进制或者执行编译。
这里有个反直觉的点:pnpm 10 并不是 pnpm 第一个引入拦截机制的版本。在 pnpm 9 及更早版本里,如果你在.npmrc里设置了enable-pre-post-scripts=false,或者使用了特定配置,也会出现类似行为。但 pnpm 10 把它变成了默认值,所以大量用户是"升级完突然就出问题"。
1.3 被拦的是依赖的脚本,不是你项目自己的脚本
很多人一开始会误以为所有脚本都被禁了,连自己项目里的predev、postinstall都不跑了。其实不是。
pnpm 拦截的是"依赖包"的构建脚本。你自己项目根目录里package.json定义的生命周期脚本(比如preinstall、postinstall、prepare)仍然照常执行,因为那是你主动写的代码,属于受信任范围。
真正被拦的是node_modules里那些第三方包自己的安装脚本。pnpm 在解析依赖时发现某个包声明了postinstall或install脚本,就把它记录到"待审批"列表里,然后在安装日志里给你打一行警告。
搞清楚这个边界,后面所有操作就不会迷糊了。
2. 先盘点:哪些依赖最容易被拦
2.1 警告信息与 pnpm ignored-builds 的配合使用
pnpm install的警告信息通常长这样:
Scope: all 3 workspace projects Lockfile is up to date, resolution step is skipped ... Ignored build scripts: esbuild, sharp. Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.注意这行警告只列出了被拦截的包名,不会告诉你每个包为什么需要构建脚本。如果你用的 pnpm 版本比较新,可以直接执行:
pnpm ignored-builds它会列出当前项目里所有被拦截了构建脚本的依赖,比安装日志里的信息更全。这个命令很适合在 CI 或者新环境里快速摸底。
还有一个容易忽略的点:警告信息里的包名来自整个依赖树,可能包含间接依赖。也就是说你没直接装esbuild,但某个构建工具链依赖它,它一样会被拦截。这种情况下你仍然需要处理,只是处理的位置在配置层面,不是去你项目里加依赖。
2.2 最容易踩雷的几类包
根据我自己的项目经验和社区里高频出现的问题,下面这几类包几乎必然触发拦截:
| 包名 | 构建脚本作用 | 被拦截后的典型症状 |
|---|---|---|
| esbuild | 校验/安装平台二进制 | 构建时提示 esbuild 安装不正确或平台不匹配 |
| sharp | 安装 libvips 预编译二进制 | 运行时Could not load the sharp module |
| electron | postinstall 下载 Electron 二进制 | 运行 electron 提示安装不完整 |
| puppeteer | postinstall 下载 Chromium | 启动浏览器时找不到对应版本 |
| better-sqlite3 / sqlite3 | prebuild-install 或 node-gyp 编译 | require 时找不到.node原生模块 |
| node-sass | 下载/编译 binding | 编译报 Missing binding |
| @tailwindcss/oxide | 安装 native binding | Tailwind v4 初始化报错 |
| simple-git-hooks | postinstall 安装 git hooks | hooks 没生效 |
| core-js | 仅 opencollective 赞助提示 | 无实际影响,可忽略 |
这里面core-js比较特殊,它的postinstall只是弹一个赞助广告,不执行也不影响功能。遇到这种包你完全不用放行,直接无视警告即可。
判断需不需要放行的标准很简单:这个包被拦之后,我的程序还能不能跑起来?如果一个包只是用postinstall做点非必需的事,比如打印个提示、发个埋点,那它不跑也无所谓。只有那些依赖构建脚本来安装原生二进制、执行编译的包,才是必须处理的硬需求。
2.3 拿不准时怎么查一个包有没有构建脚本
当你对一个包是否需要放行拿不准,最快的方法就是直接看它的package.json。在node_modules里找到对应包:
cat node_modules/.pnpm/esbuild@0.24.0/node_modules/esbuild/package.json重点看scripts字段里有没有preinstall、install、postinstall这三个键。如果有,再看脚本内容。以esbuild为例,它的postinstall实际上是执行一个校验脚本,确认当前平台对应的二进制包是否装好;better-sqlite3的install则是prebuild-install || node-gyp rebuild,属于必须执行的编译动作。
更省事的做法是直接看包的文档或 GitHub 仓库。一般需要构建脚本的包,README 里都会有明确的提示,比如"如果你使用 pnpm,请将本包加入onlyBuiltDependencies"。
3. 三条放行路径,按场景选
3.1 交互式命令 pnpm approve-builds
最直观的方式是执行:
pnpm approve-builds这个命令会弹出一个交互式列表,用空格键勾选你要放行的包,回车确认。确认之后 pnpm 会把结果写进package.json的pnpm.onlyBuiltDependencies字段(如果是 monorepo,可能会写进pnpm-workspace.yaml),然后自动重新安装受影响的包并执行它们的构建脚本。
这个方案的优点是零记忆成本,适合第一次遇到问题、列出来的包不多的情况。缺点也明显:交互式操作没法在 CI 里用,而且如果后续新增依赖又触发拦截,你还得再跑一次。它更适合作为"急救手段",而不是长期配置方案。
有个细节要注意:执行pnpm approve-builds时,列表里可能包含很多你根本不该放行的包。别一股脑全选,只勾选你确认有必要的。这一步其实就是安全审查,选错等于把 pnpm 的安全策略直接废了。
3.2 声明式配置:package.json 的 pnpm.onlyBuiltDependencies
我更推荐的方式是在package.json里显式声明:
{ "pnpm": { "onlyBuiltDependencies": [ "esbuild", "sharp", "better-sqlite3" ] } }写完之后重新执行pnpm install,pnpm 会读取这个白名单,只放行清单里的包,其他包继续默认拦截。
这个方案的好处是声明式的,配置跟着仓库走,提交到 Git 之后所有开发者和 CI 共享同一份规则。以后任何人拉代码执行pnpm install,都不会再出现"我这边没报错你那边报错"的 dev 环境不一致问题。
注意字段名是onlyBuiltDependencies,语义是"只有这些依赖允许执行构建脚本"。它的反面是ignoredBuiltDependencies,语义是"这些依赖明确不允许执行构建脚本"。
3.3 monorepo 工作区:pnpm-workspace.yaml
如果你的项目是 monorepo,配置位置要换一下。pnpm 10 里,工作区根目录的pnpm-workspace.yaml是配置的权威来源,比package.json优先级更高。
packages: - "apps/*" - "packages/*" onlyBuiltDependencies: - esbuild - sharp为什么 monorepo 要特意用工作区配置?因为 monorepo 下有多个子包,每个子包都有自己的package.json,如果各自声明一套pnpm.onlyBuiltDependencies,很难维护。统一放在工作区根部,一份清单管所有子包,清晰也省事。
这里有个容易踩的坑:pnpm 10 对 monorepo 的配置读取规则是"以pnpm-workspace.yaml为准"。如果你已经建了pnpm-workspace.yaml,还在某个子包里写了pnpm.onlyBuiltDependencies,子包里的配置可能不会生效。所以 monorepo 项目统一用工作区文件,别混着写。
3.4 相关字段辨析:ignoredBuiltDependencies 与 dangerouslyAllowAllBuilds
除了白名单,pnpm 还提供了几个容易混淆的配置。
ignoredBuiltDependencies是黑名单。它配合dangerouslyAllowAllBuilds使用的场景比较多:如果你在.npmrc里开了dangerouslyAllowAllBuilds=true,所有依赖的构建脚本都会执行,这时候你可以用黑名单把个别不信任的包单独拦掉。
{ "pnpm": { "ignoredBuiltDependencies": [ "some-suspicious-package" ] } }dangerouslyAllowAllBuilds这个名字本身就是警告。我见过有人为了省事直接开它,结果所有依赖的脚本全部放行,pnpm 10 的安全策略形同虚设。除非你清楚自己在做什么,并且对项目依赖链有足够信任,否则不建议开。真遇到依赖特别多、逐个人工审批不现实的情况,也应该先开一段时间跑通流程,之后再把白名单补全,而不是长期挂着一个全放行开关。
另一个相关配置是onlyBuiltDependenciesFile,它可以指向一个 JSON 文件,把白名单独立出来管理,适合白名单特别长的场景。普通项目用不上,知道有这么个东西就行。
4. 放行之后的验证与构建失败排查
4.1 怎么确认脚本真的执行了
配置白名单之后,重新跑一次pnpm install,观察输出。如果之前有 "Ignored build scripts" 警告,配置正确的话警告会消失,或者只剩下那些你确实没放行的包。
但"没有警告"不等于"构建成功"。某些原生模块的构建脚本执行了,但可能因为缺少编译工具链而失败。这时候 pnpm 会在 install 过程中报具体错误,而不是默默吞掉。所以关键是看 install 的完整输出,不要只看最后有没有报错。
更确定的验证方式是手动重建单个包:
pnpm rebuild better-sqlite3pnpm rebuild会重新执行指定包的所有生命周期脚本,并把输出打印到终端。如果脚本本身有问题,这一步会暴露出来。全量重建用:
pnpm rebuild在 monorepo 里可以加-r参数递归处理所有子包,或者用--filter指定子包范围。
4.2 放行后构建仍失败的常见原因
白名单配置没问题,但构建脚本跑挂了,这种情况我也遇到过不少。常见原因排序大概是这几类:
第一,缺少原生编译工具链。node-gyp编译需要 Python 和 C/C++ 工具链。Windows 上要装 Visual Studio Build Tools,macOS 要装 Xcode Command Line Tools,Linux 上要装build-essential、python3。报错信息里出现gyp ERR!基本都是这个原因。
第二,Node 版本不匹配。有些原生模块的预编译二进制只支持特定 Node ABI 版本,切换 Node 版本后旧二进制失效,需要重新构建。报错特征是NODE_MODULE_VERSION之类字样。这类问题的最新讨论大多来自 Node 版本升级到 22 之后的原生模块兼容性。
第三,包本身的安装脚本有 bug 或者对 pnpm 的目录结构处理不当。常见表现是脚本在安装时硬编码了npm命令,或者仅处理扁平化的node_modules。遇到过就查一下包仓库的 issue,看看有没有针对 pnpm 的已知问题。
通用排查路径先记下来,比盲目重装有效,我几乎每次都这么处理这类问题:
# 1. 清掉 pnpm 的安装缓存和模块目录 pnpm store prune rm -rf node_modules # 2. 重新安装并保留完整日志 pnpm install --reporter=append-only # 3. 单独重建出问题的包 pnpm rebuild <包名>4.3 二进制下载失败和镜像源
另一类高频失败是包在install脚本里下载预编译二进制,比如 Electron、Puppeteer、sharp 的二进制下载,这类操作受网络环境影响很大。如果你发现 build 脚本卡在下载阶段,或者报某个域名连接超时,说明是网络问题,不是 pnpm 的问题。
常规做法是给这些工具配置国内镜像。每个包有自己的环境变量,比如 Electron 可以用ELECTRON_MIRROR,Puppeteer 可以设置下载源,sharp 也可以用镜像。另一种更通用的思路是把 npm registry 切换到镜像源:
pnpm config set registry https://registry.npmmirror.com注意 registry 只影响 npm 包的下载,不影响那些包在 install 脚本里自己去外部域名下载二进制文件的行为。如果你同时遇到"包下载失败"和"构建脚本失败",先分开判断:包下载失败查 registry,构建脚本里的二进制下载失败查对应包自己的镜像配置。
5. 与 pnpm 环境绑定的那几个高频坑
5.1 找不到 pnpm:PATH 没配好
"pnpm' 不是内部或外部命令,也不是可运行的程序或批处理文件"——这句话经常出现在刚装完 pnpm 的命令行里。Windows 上尤甚。原因很简单:pnpm 装完了,但它的安装目录不在PATH里,终端找不到这个命令。
先查一下 npm 的全局安装目录:
npm config get prefix如果输出的是C:\Users\你的用户名\AppData\Roaming\npm,那 pnpm 就装在这个目录下,你把这个目录加进PATH就行了。Windows 上可以执行:
setx PATH "%PATH%;C:\Users\你的用户名\AppData\Roaming\npm"注意:
setx设置的是用户级 PATH,只对之后新开的终端生效。改完记得重开命令行窗口。
Linux 和 macOS 上,用 npm 全局安装的 pnpm 通常在$(npm prefix -g)/bin下,也需要确认这个目录在 PATH 里。很多人用 nvm 管理 Node,nvm 会自动把当前版本的 bin 目录加进 PATH,但如果 pnpm 是用系统 Node 装的,切到 nvm 的 Node 之后就会找不到命令。
5.2 corepack 缓存坏了:找不到 pnpm.cjs
这条热词背后的报错很典型:
Cannot find module '/root/.cache/node/corepack/v1/pnpm/12.4.2/bin/pnpm.cjs'看到corepack和.cache路径就知道,这个是 corepack 缓存出了问题。corepack 是 Node 自带的包管理器工具,你通过corepack enable启用的 pnpm 命令,实际上是个 shim,它会到缓存目录里找真正的 pnpm。如果缓存目录被清理过、权限不对,或者 Node 版本切换导致缓存路径冲突,就会出现上面这个报错。
最干净的处理方式是放弃 corepack 这条链路,直接用 npm 全局安装 pnpm:
corepack disable npm install -g pnpm这样 pnpm 就变成 npm 的普通全局包,不再依赖 corepack 的缓存,路径问题自然消失。如果你还是想用 corepack 管理版本,那就把旧缓存清掉重新激活:
rm -rf ~/.cache/node/corepack corepack prepare pnpm@latest --activate在 Windows 上对应的缓存目录是%LOCALAPPDATA%\node\corepack一类的位置,清理思路一样。
5.3 nvm 切换之后 pnpm 消失
nvm 按 Node 版本隔离全局包,这是它最重要的特性。你在 Node 18 下用npm i -g pnpm装的 pnpm,切到 Node 20 之后就会"消失",因为 nvm 切换时换了整套全局路径。这不是故障,是设计如此。
应对办法有两个。一个是每个 Node 版本都单独装一次 pnpm,切换后缺了再装。另一个是交给 corepack,但基于上一节的经验,我对 corepack 的稳定性持保留态度。实际项目中我更推荐用 nvm 的别名管理。
在.nvmrc或项目文档里固定 Node 版本,然后基于固定版本装 pnpm。这样团队里所有人在同一套环境链路上操作,少很多玄学问题。项目里我一般会在.npmrc或文档里写清楚 Node 版本和 pnpm 版本,配合package.json的packageManager字段把版本锁住。
{ "packageManager": "pnpm@10.4.1" }这个字段在配合 corepack 使用时会自动激活对应版本的 pnpm,但如前所述,corepack 本身可能出问题,所以它更多是"版本声明"作用。
5.4 彻底删除并重装 pnpm
排查到最后,如果决定重装 pnpm,建议按顺序做下面四步,避免残留文件影响新版本:
# 1. 卸载 npm 全局的 pnpm npm rm -g pnpm # 2. 关闭 corepack 的 shim corepack disable # 3. 清理 corepack 缓存目录(Linux/macOS) rm -rf ~/.cache/node/corepack # 4. 清掉 pnpm 的全局 store(确认不再需要本地缓存时再执行) pnpm store path rm -rf <上面输出的路径>重装之后记得验证:
pnpm --version which pnpmwhich pnpm(Windows 上是where pnpm)能告诉你 pnpm 到底挂在哪条路径下。如果pnpm --version有输出,但运行项目时仍然报"不是内部或外部命令",八成是 PATH 顺序问题,检查当前 shell 实际用的路径,和which输出是否一致。
6. 我推荐的安全放行方案
6.1 安全与效率的平衡
处理 "Ignored build scripts" 这件事,核心是在安全和效率之间找平衡点。我的原则很简单:能让它不跑脚本就不跑,必须跑脚本的包单独审查后放行。
具体操作上,每次pnpm install出现新的 "Ignored build scripts" 警告,我先看包名。像core-js这种纯广告脚本的,直接加入ignoredBuiltDependencies,让警告列表干净一点。像esbuild、sharp这种明确需要构建脚本的,先看一眼包来源和保护伞组织,确认没问题再加进白名单。
dangerouslyAllowAllBuilds对我来说是最后的兜底手段。有时候从老项目迁移,历史依赖特别多,一个个加白名单确实费时间。这时候我会临时开一下,跑通流程之后马上关掉,再稳下来补白名单。它也适合本地开发临时用,但绝不能让它活着进 CI。这不算什么高大上的方案,但很实际。
6.2 把放行清单锁进仓库
团队协作场景下,最关键的一步是把白名单写进仓库,而不是靠每个人本地执行pnpm approve-builds。否则一定会出现新同事拉代码装依赖就报错、CI 上构建失败、线上环境闪崩这些破事。
我现在的标准做法是:
- 普通项目:
package.json里维护pnpm.onlyBuiltDependencies - monorepo 项目:
pnpm-workspace.yaml里维护onlyBuiltDependencies - 配合
packageManager字段锁死 pnpm 版本 - 在仓库的 CONTRIBUTING 里写一句"新依赖触发了 build scripts 警告,按需添加白名单而不是全放行"
这套组合拳做好之后,"我本地没问题"这句话的可信度会大幅提升。因为依赖安装路径统一了,行为一致了,剩下的差异就只可能是 Node 版本和操作系统本身。
6.3 一次线上发布事故给我的教训
最后说一个真实教训。有一次我在一个 Node 服务项目里加了sharp做图片压缩,pnpm install之后本地跑得好好的,因为当时sharp的二进制其实是被onlyBuiltDependencies放行的。但我同事在 CI 里重新构建时,因为锁文件更新后 pnpm 解析的sharp小版本变了,新版本的安装脚本行为有差异,CI 里没走白名单,服务启动直接报错。
事后复盘才发现,当时我把白名单写在本地环境里,没提交到仓库,两个环境的 pnpm 配置根本不一致。那次事故之后,我强制自己在所有项目里把 pnpm 的放行配置写进仓库,并且每次更新依赖后都要在干净环境里验证一遍安装。
我现在处理这类问题时有个习惯:**永远先看 install 输出里有没有 "Ignored build scripts" 这一行,有就先处理配置,再去排查代码问题。**很多人把报错归因于代码或者依赖版本,其实根源只是 pnpm 的安全策略拦截了构建脚本。先把这个变量排除掉,后面的一切排查才会高效。