昨天在一个空目录里装 nuxt,终端刷出六行警告:
perl
npm warn EBADENGINE Unsupported engine {
npm warn EBADENGINE package: 'nuxt@4.6.4',
npm warn EBADENGINE required: { node: '^22.22.3 || ^24.15.0 || >=26.0.0' },
npm warn EBADENGINE current: { node: 'v24.14.1', npm: '11.14.1' }
npm warn EBADENGINE }
我机器上是 Node 24.14.1,差 0.0.9 个版本,不满足。然后 npm 说了句 up to date,装完了。
警告归警告,装归装。
我就想看看这个 engines.node 字段到底有多少人在认真写,于是把六个主流框架的依赖树整个拉了下来,814 个包,逐个查它们的声明。
814 个包里,375 个根本没写 Node 要求,46%。写了的 439 个里,218 个的声明下限低于 Node 18,将近一半。还有 97 个,声明连 Node 10 都接受。
先说口径
不是随便抓几个包。种子是 next、vite、@sveltejs/kit、nuxt、astro、@nestjs/core 六个包的最新版,用 npm 解析出真实依赖树:
bash
npm install --package-lock-only
得到了 814 个包的真实版本(含各平台二进制包)。然后按名字+版本去 registry 查每个包的 engines.node,跟它自己的直接依赖比。
这里踩过一个坑,得说清楚。我第一版脚本图省事,拿依赖的 latest 跟父包比,跑出来 postcss 声明支持 Node 10、却「需要」Node 22。核对后发现是假的:postcss 实际锁定的 nanoid 是 3.x,而 latest 的 nanoid 5 才要 Node 22。换成真实的 lockfile 后重跑,那批结论全部作废。 下面所有数字都来自第二版。
「矛盾」的判定是:包自己声明的下限,严格低于它某个直接依赖声明的下限。双方都得有可解析的区间才算数,一共 160 个可比对,13 个对不上,8.1%。
声明和它自己的依赖打架
举几个真实例子:
unimport@7.0.2声明>=18.12.0,它依赖的unplugin要20.19.0@bruits/satteri-wasm32-wasi@0.11.7声明>=14.0.0,依赖的@napi-rs/wasm-runtime要20.19.0browserslist@4.29.5声明^6 || ^7 || ... || >=13.7,依赖的node-releases要 18vite-plugin-inspect@11.4.1声明>=14,依赖的unplugin-utils要20.19.0
按自己的声明,Node 14 就能装。装上去,它自己的依赖先不答应。
这 13 个只是下限。我只比了直接依赖,传递依赖里的矛盾没算,所以 8.1% 是下限,不是全景。
不匹配会拦截吗?看是谁在装
我把「根包不匹配」和「依赖不匹配」两种情况,在四个包管理器上各跑了一遍:
| 位置 | npm 默认 | npm 加 engine-strict | pnpm 10 | yarn 1 |
|---|---|---|---|---|
| 根包不匹配 | 警告,退出码 0 | 失败 | 警告,退出码 0 | 失败 |
| 依赖不匹配 | 警告,退出码 0 | 失败 | 静默,退出码 0 | 失败 |
npm 默认相信「你比你依赖的作者更懂你的项目」,所以只劝不拦。加 engine-strict 才动真格:
bash
npm install --engine-strict
# 或在 .npmrc 里写 engine-strict=true
pnpm 10.33 是三个里最松的,根包不匹配也只给一行 WARN,退出码 0;依赖不匹配连 WARN 都没有。yarn 1 最硬,两种位置都直接失败,非得加 --ignore-engines 才过得去。
还有一个东西叫 devEngines,是 npm 新推的写法:
json
"devEngines": {
"runtime": { "name": "node", "version": ">=22", "onFail": "error" }
}
同样是不匹配,加上这段之后 npm 默认就失败,EBADDEVENGINES,退出码 1,不需要任何开关。同一个 npm,旧字段当建议,新字段当命令。
97 个「Node 10 也行」
这是我扫之前没想到的数字。
439 个写了声明的包里,97 个的区间允许 Node 10。Node 10 是 2021 年 4 月 EOL 的,2026 年还允许它,这个声明等于没写。
其中 26 个是 @babel 家族,统一写 >=6.9.0------能理解,转译器得往下兼容。剩下 71 个不是。更离谱的是 41 个包,下限写的是 >=0.x:deepmerge 是 >=0.10.0,depd 是 >= 0.8,diff 是 >=0.3.1。Node 0.4 是 2011 年的版本。
再往上数,允许 Node 20 的有 264 个。Node 20 今年 4 月也 EOL 了。也就是说,439 个声明里,一大半在今天这个时间点已经过期。
这不是说这些包在 Node 10 上跑不起来------很多包确实还能跑,babel 那种纯 JS 的转译工具尤其能。问题是:当这个字段允许一切的时候,它就没有保护任何东西。
有人会说
「声明过期不代表包坏了。」对,我整篇都没说包会崩。我说的是另一件事:engines 被当成版本安全网在用,但它默认只警告,写的内容又大面积过期,这个网是漏的。
「我项目锁了 Node 版本,不需要它。」那是另一道防线,nvm、volta、CI 里的 node-version 都算。但依赖是有传递性的,你锁了根,管不住下游某个包偷偷要求更高的版本------我机器上那六行警告就是 nuxt 的传递依赖冒出来的。
「小项目装得上就行。」同意。这个字段真正的价值不在安装,在排查:线上挂了、本地好好的,先看一遍 engines 有没有人越界,比瞎猜快。
我现在怎么用
三个动作,五分钟:
bash
# 1. 看自己的声明和 CI 里的 node-version 对不对得上
npm pkg get engines
bash
# 2. 装的时候把警告当回事(CI 里加这一行)
npm install --engine-strict
bash
# 3. 想知道哪些依赖越界了,重新装一遍,数 EBADENGINE
rm -rf node_modules package-lock.json && npm install 2>&1 | grep -c EBADENGINE
新项目别再用 engines 当开关了,直接上 devEngines + onFail: error,npm 默认就拦。
我的立场很简单:这个字段不是没用,是被用成了「写了就算做过」。写了 >=8,和写「Node 环境请自行保证」,效果一样。
你项目里的 engines 写的是多少?跑一下 npm pkg get engines,评论区报个数,我看看有多少人还停在 14 以下。