pnpm 的預設行為與 npm 有不少差異,而那些差異多半是對的——嚴格的 node_modules 結構、內容定址的 store、真正可重現的安裝。
但差異就是差異。當你的心智模型還停在 npm 上時,它們表現出來的樣子不是「pnpm 比較嚴格」,而是「這個錯誤訊息完全看不懂」。
這篇是六個我們實際踩過的。想先補 package manager 的基本概念,可以看認識 Node.js 的 Package Manager。
一、pnpm deploy 不會執行你的 deploy script
症狀
package.json 裡定義了:
{
"scripts": {
"deploy": "<你的部署指令>"
}
}
跑 pnpm deploy,預期觸發那個 script,實際撞到:
ERR_PNPM_NOTHING_TO_DEPLOY No project was selected for deployment
對 pnpm 不熟的人很容易把這個訊息讀成「部署工具設定錯了」,然後花時間去查部署那一端——而部署指令根本沒有被呼叫到。
根因
deploy 是 pnpm 的保留字,它是內建的 workspace deploy 命令(把 monorepo 的 sub-package 連同依賴輸出到一個目錄)。
規則很單純但很容易忘:
pnpm <保留字>→ 走內建命令,不會 fallback 到pnpm run <保留字>pnpm run deploy→ 正確觸發你的 script
其他會撞到同樣行為的 script 名稱:pack、publish、add、install、update、remove、link、unlink、exec、dlx。
為什麼 CI 不會撞到
因為 CI 的 workflow 幾乎都寫成 pnpm run deploy(顯式帶 run)。撞到的只有在終端機手打的人。
這是一個很典型的形狀:CI 用嚴謹的寫法,人用簡短的寫法,於是問題只發生在人身上,而 CI 永遠是綠的。
修法
兩條,建議都做:
- script 不要取保留字名稱。叫
release、deploy:prod、ship都可以,避開整組保留字 - 團隊慣例一律寫
pnpm run <script>。多打四個字,換掉一整類問題
二、跨平台 optional native dep 的 dangling symlink 炸掉 build
症狀
pnpm build 在打包階段失敗:
[nitro] ERROR ENOENT: no such file or directory, realpath
'.../node_modules/.pnpm/node_modules/@img/sharp-wasm32'
依賴鏈是 @nuxt/image → ipx → sharp → @img/sharp-wasm32(optional dependency)。
刪掉那個 symlink 沒有用。 刪了之後另一個路徑下的同名 symlink 又冒出來,而下一次 pnpm install 會全部重建。
根因
pnpm 為跨平台的 optional native dependency 建立 symlink,但只安裝當前平台的那一個。非當前平台的變體,symlink 的目標從未存在——也就是 dangling。
這是 pnpm 的常態行為,不需要任何特殊設定,幾乎每個用 pnpm 且有 native dependency 的專案都有一堆。
爆炸需要兩個條件同時成立:
- 存在 dangling symlink(前面說了,這幾乎必然)
- 該 symlink 位於 runtime dependency 鏈上,會被打包器的依賴追蹤解析到
打包器追 runtime dependency graph 時會對每個節點做 realpath。dangling symlink 落在鏈上,realpath 拋 ENOENT,整個 build 掛掉。
多數 dangling symlink 是無害的——建置期工具(各種平台變體的 binding、fsevents)不會被 runtime trace 解析。而圖片處理這條鏈是 runtime 依賴,所以中獎。
一個被推翻的錯誤歸因
第一次診斷時,我們把根因歸給 .npmrc 裡的 supportedArchitectures 設定,推論是「那個變體的 cpu 是 wasm32,被過濾掉所以沒裝」。
這個歸因是錯的,跨專案比對推翻了它:
| 證據 | 結論 |
|---|---|
另一個專案沒有設 supportedArchitectures,卻有 510 個 dangling symlink,包含同一個 | 不設也照樣中獎 |
把該平台補進 supportedArchitectures 後重跑 install,symlink 仍然 dangling | 設對了也不解決 |
留這一段是因為錯誤歸因本身有價值:一個「聽起來能解釋現象」的設定,只有在你能證明「拿掉它現象消失、加回去現象重現」時才是根因。 我們當時只做到「它聽起來能解釋」。
修法
在打包前把 dangling symlink 清掉。實務上是一支 prebuild 的 prune 腳本——掃 node_modules 底下的 symlink,realpath 失敗的就刪。
判定則相反,要打在會不會被 trace 到上:dangling symlink 的數量本身沒有意義,重點是它在不在 runtime 鏈上。
三、.pnpm/ 累積跨版 orphan,app 初始化就崩
症狀
dev server 啟動成功,但瀏覽器 console 出現一串連鎖錯誤,client 端的 app 初始化在很前面就掛掉:
[pinia/colada]: useQueryCache() was called outside of an injection context
[Vue warn]: inject() can only be used inside setup() or functional components.
[nuxt] error caught during app initialization
Error: useHead() was called without provide context,
ensure you call it through the setup() function.
接著是 Missing ref owner context、resolveComponent can only be used in render() or setup(),UI 整個翻不出來。
這些錯誤訊息全部指向錯的方向。 照著訊息查會去查 plugin 的註冊順序、composable 的呼叫位置、Vue 的 injection context——而那些地方全都是對的。
根因
反覆做增量 pnpm install 而沒有 prune,會讓 node_modules/.pnpm/ 累積跨大版的 orphan。
top-level 的 symlink(node_modules/nuxt、node_modules/@nuxt/ui)指向正確的 active 版本,但 .pnpm/<舊版本>_<hash>/ 整棵目錄樹仍然留著、沒有被刪。
於是同一個套件的多個大版本同時存在於 store 裡,而不同的 import 路徑可能解析到不同版本——Vue 的 injection context 是靠模組單例維持的,兩份不同版本的 Vue 或 unhead 就意味著兩套獨立的 context。「在 setup 裡呼叫卻說不在 injection context」就是這麼來的。
判定
不要看 node_modules/<pkg> 指向哪裡,看 .pnpm/ 底下同一個套件有幾個大版本:
ls node_modules/.pnpm/ | sed 's/@[^@]*$//' | sort | uniq -c | sort -rn | head -20
同一個套件出現跨大版的多筆([email protected] 與 [email protected] 並存)就是中獎。同版本多個 peer hash 是正常的,不要誤判。
修法
rm -rf node_modules && pnpm install
沒有比較優雅的做法。真正該做的是避免累積——升大版之後重裝,不要一路增量疊上去。
四、pnpm <script> 會先跑 install,於是檢查機制自己修好了要檢查的東西
症狀
一道用來偵測「設定檔有沒有被手動改壞」的 gate,接進 pre-commit 之後恆綠。
手動改壞設定檔,commit 照樣過。gate 輸出「無漂移」、exit 0,而且回頭一看——被改動的內容不見了。
外觀跟「真的沒有漂移」完全相同。這是它難被發現的原因。
根因
.npmrc 裡設了:
verify-deps-before-run=install
pnpm 10 在這個設定下,執行任何 script 之前會先跑一次 install。而 install 會觸發 postinstall,postinstall 裡的同步腳本以寫入模式執行,把被改壞的檔案照來源重寫回去。
等到 gate 真正執行檢查時,漂移已經被同一條命令抹掉了:
pnpm <gate-script>
└─ verify-deps-before-run=install
└─ pnpm install
└─ postinstall → 同步腳本(寫入模式) ← 漂移在這裡被抹除
└─ 實際的檢查 ← 看到的是已修好的乾淨狀態 → exit 0
這道 gate 不但沒擋住問題,還代為執行了它本來要阻止的那個動作。
判定要帶 known-positive control
驗這件事的方法很重要,因為「gate 沒抓到」與「本來就沒問題」在輸出上同形:
- 故意寫入一個 probe 字串到目標檔案
- 先
grep -c確認 probe 真的寫進去了(control 必須大於 0,否則後面都沒意義) - 再跑 gate
- 跑完再
grep -c一次
我們實測的結果是:走 pnpm <script> 這條路,probe 在 gate 跑完之後變成 0——被靜默抹除。
修法
檢查類的指令不要透過 pnpm <script> 呼叫,直接跑底層執行檔:
node scripts/<gate>.ts --check
或者把 verify-deps-before-run 設成 warn。但那只是降低機率——真正的原則是:
一道 gate 不能與「會改變它所檢查的狀態」的動作共用同一條命令。
這條原則的適用範圍比 pnpm 廣。任何「先同步、再檢查」的組合都有同樣的結構性問題,不管中間那層是 package manager、code generator 還是 formatter。
講到這裡你可能會想,那把 verify-deps-before-run 整個關掉就好了?那會換回它原本要解的問題——
依賴裝到一半就跑 script,錯誤訊息會指向完全無關的地方。它存在是有理由的。要分開的不是這個
設定,是兩種命令的呼叫路徑:日常開發走 pnpm <script> 讓它幫你補依賴,gate 走底層執行檔
讓它看到未經整理的現場。同一個設定對兩者的正確值本來就不同。
(同一類的「綠燈是假的」還有幾種形狀,整理在綠燈是假的:五個讓檢查機制自己失效的坑,其中也包含 pnpm 的 Already up to date 那個。)
五、worktree 的 node_modules 不會跟著走
症狀
在 git worktree 目錄跑任何 pnpm script 時出現:
WARN Your node_modules are out of sync with your lockfile.
The workspace structure has changed since last install
warning 本身不 block,dev server 照跑。但如果依賴真的有差異(主分支新增了套件而 worktree 沒重裝),就會變成 module not found,然後 dev server 崩掉。
根因
三件事交叉:
verify-deps-before-run會比對 hash。pnpm 10 在跑 script 前,比對package.json的全文 hash 與node_modules/.modules.yaml記錄的安裝時 hash,不匹配就 WARN- git worktree 不共享
node_modules。每個 worktree 需要獨立 install - worktree 的
package.json與主分支獨立演化。主分支升了版號或加了一個 script 之後,worktree 的package.json不會跟進
第 3 點是最容易低估的:即使 lockfile 與依賴完全相同,pnpm 10 把 package.json 的任何欄位改動都視為 structure changed。版號 bump 就足以觸發。
為什麼 CI 看不到
CI 在 fresh checkout 上跑 pnpm install,永遠是同步的。而 warning 不 block、exit 0,任何 gate 都抓不到。
典型的發現時機是 worktree 存活好幾天之後。
修法
建立 worktree 的流程裡直接帶一次 install。這是個一次性的補洞——git worktree add 本身不管依賴,那就讓包裝它的腳本管。
六、pnpm 注入的超長 PATH 讓 spawn 慢十倍
症狀
某個工具用 pnpm <script> 啟動時,熱路徑慢得不合理(5 到 10 倍)。同樣的程式碼用 node 直接跑,速度正常。
外觀條件:
- 該工具在單一 request 裡呼叫
spawn()很多次(十次以上) - spawn 的 binary 用相對名稱(
'git'),不是絕對路徑 - 啟動方式是
pnpm <script>,不是node <file>
根因
spawn(<name>, args) 在 binary 是相對名稱時要做 PATH lookup——iterate $PATH 的每一條 entry,對 <entry>/<name> 做 stat,直到找到第一個可執行檔。
而 pnpm <script> 啟動時,為了讓 node_modules/.bin、Node 版本管理、各種 lifecycle script 都找得到工具,會在 PATH 前面插入十幾條 entry,其中還有重複的。
於是每一次 spawn 都要多走幾十次檔案系統查詢。單次不明顯,一個 request 裡 60 次就很明顯了。
實測
我們拿一個會在單次請求裡 fire 60 多個 spawn('git', ...) 的端點量:
| 啟動方式 | PATH 長度 | 冷啟動耗時 |
|---|---|---|
pnpm <script> | 約 40 條 | 5.4s |
node <file> 直跑 | 系統預設 | 0.5s |
env -i PATH=/usr/bin:/bin node <file> | 2 條 | 0.5s |
pnpm <script>,但 spawn 改用 /usr/bin/git 絕對路徑 | 約 40 條 | 0.5s |
最後一列是決定性的:PATH 沒變、只把 binary 改成絕對路徑,就回到 0.5s。這證明慢的是 lookup,不是 pnpm 本身的其他開銷。
在 macOS 上放大倍數最大,Linux 也有但比較小。
修法
熱路徑上的 spawn 用絕對路徑。
// 啟動時解析一次,之後重複使用
import { execFileSync } from 'node:child_process'
const GIT = execFileSync('which', ['git'], { encoding: 'utf8' }).trim()
// 熱路徑
spawn(GIT, args)
解析一次的成本可以忽略,而且順便讓「用的是哪一個 git」變成明確的、可記錄的事實,而不是取決於當下 PATH 的第一個命中。
收束
六個坑,其中有四個的共同結構是:pnpm 在你的命令之前或之後多做了一件事,而那件事是隱形的。
| 坑 | pnpm 多做的事 |
|---|---|
| 保留字 | 攔截了你的 script 名稱,跑自己的內建命令 |
| script 前置 install | 在你的 script 之前跑了一次 install 加 postinstall |
| dangling symlink | 為你沒有的平台建了 symlink |
| 超長 PATH | 在你的 PATH 前面插了十幾條 entry |
這些行為單獨看都有正當理由。問題在於它們不出現在你打的那條命令裡,所以當結果不對時,你會去查你打的那條命令,而不是查它周圍發生了什麼。
實務上的一條原則:當「用 pnpm 跑」與「直接跑」結果不同時,差異幾乎一定在 pnpm 注入的環境或前置動作裡,不在你的程式碼裡。
先確認這一點,能省下大量往錯方向查的時間。