症状
一个包的 CI 流水线,步骤是装依赖、构建、发布。某次改完之后构建时间翻了将近一倍,日志里出现两段完全相同的编译输出,中间隔了几十秒。第一段结束时产物已经生成好了,第二段又从头编译一遍,把刚生成的覆盖掉。
package.json 里是这么写的:
{
"scripts": {
"prepare": "npm run build",
"build": "tsc -p tsconfig.build.json"
}
}流水线里显式调用的只有 npm ci 和 npm publish。没有任何地方写了两次 build。
最小复现
不用真项目,一个空包就能把次数数出来。让 prepare 往日志里追加一行带时间戳的记录:
mkdir -p /tmp/exp-npm && cd /tmp/exp-npm
cat > package.json <<'EOF'
{
"name": "demo-app",
"version": "1.0.0",
"files": ["index.js"],
"scripts": {
"prepare": "node -e \"console.log('PREPARE', new Date().toISOString())\" >> /tmp/exp-npm/log.txt"
}
}
EOF
echo 'module.exports = 1;' > index.js
count() { rm -f log.txt; rm -rf node_modules package-lock.json; eval "$1" >/dev/null 2>&1; \
printf '%-38s prepare x%s\n' "$2" "$(grep -c PREPARE log.txt 2>/dev/null || echo 0)"; }
count "npm install --no-audit --no-fund" "npm install (冷)"
count "npm install --no-audit --no-fund >/dev/null 2>&1; rm -f log.txt; npm ci --no-audit --no-fund" "npm ci (热)"
count "npm install --no-audit --no-fund && npm pack" "npm install && npm pack"
count "npm install --no-audit --no-fund && npm publish --dry-run --registry http://127.0.0.1:1/" "npm install && npm publish --dry-run"在 npm 11.19.1 / node v24.22.2 上的结果:
npm install (冷) prepare x1
npm ci (热) prepare x1
npm install && npm pack prepare x2
npm install && npm publish --dry-run prepare x2npm pack 和 npm publish 各自会再触发一次 prepare。流水线里 npm ci 跑一次、npm publish 跑一次,加起来正好两遍。--dry-run 也一样触发,它跳过的是上传,不是打包前的钩子。
为什么会这样
prepare 这个钩子的定位是"在包被消费之前把它准备好",所以 npm 在好几个入口都挂了它:本地 npm install 不带参数时、npm ci 之后、npm pack 之前、npm publish 之前,以及从 git 依赖安装某个包时在克隆出来的临时目录里。
这几个入口的设计假设是:多数情况下你只会走其中一条路径。开发者本地 npm install 一次;发布机器上 npm publish 一次。假设成立的时候,prepare 就跑一遍。
CI 流水线恰好打破了这个假设——它先装依赖再发布,两条路径都走。于是同一份构建逻辑被执行两次。
这不是 bug,是钩子语义和流水线形状不匹配。
三种修法
改法一:CI 里让安装步骤不跑脚本,构建显式做一次。
npm ci --ignore-scripts
npm run build
npm publish --ignore-scripts--ignore-scripts 会连带跳过依赖包的生命周期脚本,这在有原生依赖需要编译的仓库里可能出问题,得逐个确认。npm 11 已经引入了安装脚本的审批机制,第一次跑 npm install 时会看到这样的提示:
npm warn install-scripts 1 package has install scripts not yet covered by allowScripts:
npm warn install-scripts demo-lib@1.0.0 (prepare: ...)
npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow.也就是说依赖侧的脚本本来就该被显式批准,--ignore-scripts 的破坏性比过去小了一些。但它仍然是一刀切,我不建议在有大依赖树的仓库里直接上。
改法二:把构建从 prepare 挪到只在真正需要的地方。
如果这个包只通过 npm publish 分发,不需要支持 npm install git+https://... 这种直连 git 的安装方式,那就删掉 prepare,把构建放进流水线:
{
"scripts": {
"build": "tsc -p tsconfig.build.json",
"prepublishOnly": "npm run build"
}
}prepublishOnly 只在 npm publish 时触发,不会在 npm install 时跑。这样 npm ci 阶段干净,npm publish 阶段构建一次,总共一次。
代价是失去 git 依赖支持——别人 "dep": "github:you/repo" 装的时候不会自动构建。要不要这个能力,得看包的实际使用方式。
改法三:让构建本身可跳过。
保留 prepare,但让它先判断产物是否已经比源码新:
{
"scripts": {
"prepare": "node scripts/build-if-stale.js"
}
}脚本里比较 src/ 下最新 mtime 和 dist/ 下最新 mtime,新就跳过。这条路能治双跑,但它把正确性押在时间戳上——git clone 之后所有文件 mtime 相同、CI 缓存恢复目录时 mtime 被打乱、跨时区容器里时钟不同步,都会让判断失效。
我的做法
改法二优先。理由很简单:钩子越少,触发时机越明确,出问题时越容易推理。prepare 的触发点太多,而它的名字又完全不暗示"我会在 pack 和 publish 前再跑一次"。
如果一个包确实需要同时支持 git 安装和 npm 发布,我会保留 prepare 但让它只做最轻的事(比如生成一个版本常量文件),真正的编译放到 prepublishOnly 和 CI 的显式步骤里。两处各跑一次轻量脚本,比一处跑两次完整编译好接受。
改法三我倾向不用。构建缓存这件事应该交给构建工具自己做——tsc 有 incremental 和 .tsbuildinfo,vite/rollup 有持久化缓存——而不是在 npm 钩子层面用 mtime 猜。
分情况的地方
上面那次计数是在 npm 11.19.1 上做的。prepare 的触发时机在 npm 的大版本之间改过不止一次,npm 6 和 npm 7 的行为差别尤其大(npm ci 是否触发 prepare 就变过)。换到别的 npm 大版本上,这段复现脚本要重跑一遍再下结论,不能直接搬。
另一个变量是包管理器。pnpm 和 yarn 的钩子集合与 npm 不完全对应,prepare 在它们那边的触发点也不同。仓库里如果同时存在 package-lock.json 和 pnpm-lock.yaml,先确认 CI 实际用的是哪个。
最后一个容易漏的:monorepo 里 workspace 之间的依赖也会触发被依赖包的 prepare。一个根目录的 npm install 可能让若干个 workspace 各构建一次,看起来像"构建跑了很多遍",但和 npm pack 那条路径无关,排查方向不一样。