制到幽靈依賴排查)
如果你維護(hù)過(guò)一個(gè)稍大的 Monorepo 倉(cāng)庫(kù)大概率會(huì)遇到這類問(wèn)題在 packages/web 子包里執(zhí)行 npm install xxx裝完卻發(fā)現(xiàn)依賴跑到了根目錄明明其他子包都用了同一個(gè)版本的庫(kù)到了自己這邊就行為不一致更麻煩的是CI 上拉下代碼重新安裝依賴直接報(bào)錯(cuò)說(shuō)缺少某些依賴項(xiàng)。這些問(wèn)題的根源大多集中在一個(gè)地方——子包安裝依賴。這篇文章我打算把 Monorepo 里子包依賴安裝這件事徹底講透包括目錄結(jié)構(gòu)怎么搭、依賴該裝在根還是裝在子包、本地包之間怎么互相引用、報(bào)錯(cuò)時(shí)從哪里開始排查以及一些我踩過(guò)坑之后沉淀下來(lái)的習(xí)慣。適合剛開始用 Monorepo 的團(tuán)隊(duì)也適合已經(jīng)用了 npm/yarn workspace 卻總是被依賴問(wèn)題拖累的同學(xué)。1. 先搞清楚Monorepo 里的“安裝依賴”到底是個(gè)什么場(chǎng)景1.1 一個(gè)倉(cāng)庫(kù)里塞了一堆子包依賴關(guān)系立刻變復(fù)雜Monorepo 本質(zhì)上就是把多個(gè)原本獨(dú)立管理的項(xiàng)目合并到一個(gè) Git 倉(cāng)庫(kù)里統(tǒng)一維護(hù)。這些項(xiàng)目通常叫子包package放在 packages/ 或者 apps/ 目錄下面。每個(gè)子包都有自己的 package.json聲明自己的名字、版本、腳本和依賴。在傳統(tǒng)多倉(cāng)庫(kù)模式下每個(gè)項(xiàng)目獨(dú)立安裝依賴在項(xiàng)目根目錄執(zhí)行 npm install 就能生成 node_modules 和 lockfile流程簡(jiǎn)單新人也能搞定。但到了 Monorepo 場(chǎng)景依賴關(guān)系徹底變了子包 A 依賴子包 BB 又依賴 C根目錄要有所有子包共享的開發(fā)依賴比如 TypeScript、ESLint子包之間還可能有版本范圍交叉A 要求 lodash ^4.17.20B 要求 ^4.17.21兩邊能不能統(tǒng)一用一個(gè)版本這些問(wèn)題已經(jīng)在挑戰(zhàn)傳統(tǒng)的安裝方式了。正因?yàn)檫@種復(fù)雜性Monorepo 的依賴安裝不能再用“進(jìn)入某個(gè)子包目錄執(zhí)行 npm install”這種原始方式你需要一套統(tǒng)一的工作區(qū)機(jī)制來(lái)協(xié)調(diào)。這就是 npm workspaces、yarn workspaces、pnpm workspace 這些概念出現(xiàn)的原因。1.2 npm、yarn、pnpm 三種工作區(qū)機(jī)制對(duì)比目前主流的就是這三種方案底層行為差異很大。維度npm workspacesyarn workspacespnpm workspace需要版本npm 7workspace 協(xié)議建議 8.5Yarn 1.x / Berrypnpm 3符號(hào)鏈接支持支持支持配合硬鏈接默認(rèn)依賴提升是是否嚴(yán)格訪問(wèn)未聲明依賴否否是workspace 協(xié)議部分版本支持支持支持磁盤占用優(yōu)化一般一般明顯多子包集成體驗(yàn)一般好好npm 從 7 開始把 workspaces 作為原生能力配置方式是在根 package.json 里加 workspaces 字段。yarn 1.x 也是類似思路yarn classic 的 workspace 命令是 yarn workspace 包名 add 依賴。pnpm 則在 pnpm-workspace.yaml 里聲明目錄使用 --filter 來(lái)指定子包。這里最本質(zhì)的差異在于是否提升依賴hoisting以及是否容忍幽靈依賴phantom dependencies。npm 和 yarn 默認(rèn)會(huì)把所有子包的依賴提升到根 node_modules方便是方便但很容易出現(xiàn)“我的代碼里明明沒有聲明 lodash卻能用 lodash”的情況。pnpm 默認(rèn)不這么做它會(huì)把依賴安裝到全局 store 中再通過(guò)符號(hào)鏈接放到每個(gè)子包自己的 node_modules 下并且只暴露該子包 package.json 里聲明過(guò)的依賴。1.3 為什么我更推薦 pnpm 作為 Monorepo 的默認(rèn)選擇不是說(shuō)什么場(chǎng)景都必須上 pnpm但如果你接手一個(gè)中大型 Monorepo尤其是前端項(xiàng)目Vue、React 等框架混合我一般建議優(yōu)先考慮 pnpm。三個(gè)原因第一嚴(yán)格的依賴訪問(wèn)控制。pnpm 會(huì)強(qiáng)制你只能使用子包 package.json 里聲明的依賴其他間接依賴即便已經(jīng)存在于 node_modules 中也無(wú)法直接訪問(wèn)。這初看有點(diǎn)煩但能提前暴露大量隱患比等到上線前才發(fā)現(xiàn)某個(gè)包在全倉(cāng)庫(kù)都找不到要好。第二磁盤空間優(yōu)化。多個(gè)子包使用同一版本的依賴時(shí)pnpm 只會(huì)在 store 里存一份并通過(guò)硬鏈接復(fù)用。我在一個(gè)包含 12 個(gè)前端子包的倉(cāng)庫(kù)里實(shí)測(cè)過(guò)用 npm 安裝后 node_modules 約 4.8GB改用 pnpm 后降到了 1.9GB 左右。第三對(duì) workspace 協(xié)議的支持非常成熟filter 參數(shù)能精確控制對(duì)子包的操作適合快速定位單個(gè)子包的依賴問(wèn)題。順帶說(shuō)一句有的團(tuán)隊(duì)因?yàn)闅v史原因必須留在 npm workspaces也不用太擔(dān)心后面幾節(jié)介紹的思路大多通用只是命令換成 npm 對(duì)應(yīng)形式。2. 動(dòng)手實(shí)操在子包中安裝依賴的完整流程2.1 最小 Monorepo 目錄與 package.json 準(zhǔn)備為了把概念落到地上我們建一個(gè)最小 Monorepo。目錄結(jié)構(gòu)大致如下my-monorepo/ ├── package.json ├── pnpm-workspace.yaml ├── packages/ │ ├── utils/ │ │ └── package.json │ └── ui/ │ └── package.json根 package.json 可以很薄只保留腳本和公共開發(fā)依賴{ name: my-monorepo, private: true, scripts: { build: pnpm -r build }, devDependencies: { typescript: ^5.4.0 } }根 package.json 里不建議放業(yè)務(wù)依賴只放那些跟整體構(gòu)建、CI 相關(guān)的工具。業(yè)務(wù)依賴應(yīng)該盡量放到對(duì)應(yīng)子包里否則容易出現(xiàn)“這個(gè)庫(kù)到底是誰(shuí)在用”的歧義。pnpm-workspace.yaml 聲明哪些目錄算子包packages: - packages/*子包 utils 的 package.json 可以這樣寫{ name: my/utils, version: 1.0.0, main: src/index.ts, dependencies: { lodash: ^4.17.21 } }然后在根目錄執(zhí)行pnpm install完成后你會(huì)看到根目錄出現(xiàn) pnpm-lock.yaml以及一個(gè) node_modules 文件夾。pnpm 默認(rèn)會(huì)把符號(hào)鏈接粒度做到子包級(jí)別所以在 packages/utils/node_modules 下能看到 lodash 的符號(hào)鏈接但它的物理文件只存在于全局 store 里。2.2 在子包內(nèi)安裝單個(gè)依賴的正確姿勢(shì)最常見的需求是給某一個(gè)子包單獨(dú)安裝依賴。pnpm 推薦在根目錄使用 filter 指定pnpm add --filter my/ui react這條命令會(huì)修改 packages/ui/package.json并同步更新根 lockfile。npm 對(duì)應(yīng)的是npm install react -w packages/uiyarn classic 對(duì)應(yīng)的是yarn workspace my/ui add react有的人習(xí)慣先 cd 到子包目錄再執(zhí)行 install比如cd packages/ui pnpm add react其實(shí)也可以但我不推薦原因后面第 3.2 節(jié)會(huì)講。在 Monorepo 根目錄統(tǒng)一操作能保證所有子包之間的依賴變化都在同一份 lockfile 里被記錄不會(huì)出現(xiàn)某個(gè)子包目錄下自己生成了一個(gè)獨(dú)立 lockfile 的情況。如果你要安裝的依賴只是本地開發(fā)用的不進(jìn)入發(fā)布產(chǎn)物可以加-Dpnpm add --filter my/ui -D vitest這樣會(huì)寫入 devDependencies。2.3 子包之間互相引用的依賴聲明workspace 協(xié)議Monorepo 里最常用到的功能是子包 A 依賴另一個(gè)子包 B。比如 my/ui 要用 my/utils 提供的工具函數(shù)。第一反應(yīng)可能是這樣寫{ dependencies: { my/utils: ^1.0.0 } }這樣寫有一個(gè)問(wèn)題pnpm 會(huì)先檢查 my/utils 是否存在于本地 workspace 中如果版本匹配會(huì)直接鏈接本地包。理論上可行但不夠明確。更好的方式是使用 workspace 協(xié)議{ dependencies: { my/utils: workspace:* } }workspace:* 的意思是不管版本號(hào)是多少一律使用本地 workspace 中的這個(gè)包。這樣寫的好處是本地開發(fā)時(shí)幾乎不會(huì)出現(xiàn)“明明改過(guò) utils 源碼但 ui 里看到的是 registry 上的舊版本”這種詭異問(wèn)題。如果希望發(fā)布時(shí)能自動(dòng)轉(zhuǎn)換changesets 可以將 workspace:* 改寫為實(shí)際版本號(hào)再發(fā)布到 npm registry。這一點(diǎn)后面 4.2 節(jié)詳細(xì)說(shuō)。pnpm 和 yarn 原生支持 workspace 協(xié)議。npm 的 workspaces 對(duì)這種協(xié)議的兼容涉及版本差異所以如果你在用 npm workspaces建議直接用本地版本號(hào)或者提前確認(rèn) npm 版本對(duì) workspace 協(xié)議的支持情況。2.4 依賴提升機(jī)制為什么 npm 會(huì)偷偷改變你的安裝結(jié)構(gòu)這一節(jié)要解釋依賴提升hoisting它是理解 Monorepo 子包安裝依賴的關(guān)鍵。npm 和 yarn 在安裝時(shí)有一個(gè)默認(rèn)策略能提升到根 node_modules 的盡量提升。比如 packages/ui 依賴 lodashpackages/utils 也依賴 lodash而且版本范圍兼容那 lodash 只會(huì)在根 node_modules 里裝一份兩個(gè)子包通過(guò) Node.js 的模塊查找一路向上找到根目錄都能用。這個(gè)策略的初衷是節(jié)省磁盤和安裝時(shí)間同時(shí)也帶來(lái)一個(gè)副產(chǎn)品幽靈依賴。假設(shè) packages/web 的代碼里 import 了 lodash但 package.json 里并沒有聲明 lodash因?yàn)?lodash 恰好被根 node_modules 下的某個(gè)其他依賴帶過(guò)來(lái)了所以本地運(yùn)行一切正常。這種暗藏的不合法依賴一旦根 node_modules 結(jié)構(gòu)變化比如其他依賴升級(jí)后不再依賴 lodash你的代碼會(huì)瞬間在 CI 或同事電腦上崩掉。pnpm 的選擇不同它把依賴提升關(guān)掉了默認(rèn)采用嚴(yán)格模式。每個(gè)子包的 node_modules 下面只有它聲明過(guò)的依賴符號(hào)鏈接未聲明的依賴在 Node.js 模塊解析時(shí)根本找不到。這個(gè)特性強(qiáng)制開發(fā)者寫清每個(gè)子包的真實(shí)依賴長(zhǎng)期來(lái)看省下的排查時(shí)間遠(yuǎn)比當(dāng)初省掉的幾步命令要多。3. 子包安裝依賴最容易踩的坑與排查方法3.1 幽靈依賴A 子包能用 B 子包沒聲明的庫(kù)第一個(gè)必須說(shuō)透的就是幽靈依賴。我在前端 Monorepo 里見過(guò)太多次這種問(wèn)題。舉一個(gè)真實(shí)場(chǎng)景。倉(cāng)庫(kù)里有 apps/admin 和 apps/site 兩個(gè)應(yīng)用子包還有一個(gè) packages/hooks 子包。hooks 依賴了 dayjsadmin 的代碼里也順手用了 dayjs但 admin 的 package.json 沒有聲明。npm 安裝時(shí)由于 hoistingdayjs 被提升到根 node_modulesadmin 自然也能引用一切正常。過(guò)了兩個(gè)月hooks 升級(jí)時(shí)移除了 dayjs 依賴或者安裝順序變化導(dǎo)致 dayjs 不再出現(xiàn)在根 node_modulesadmin 立刻崩了報(bào)錯(cuò)通常是 Cannot find module dayjs。排查時(shí)先別著急猜用幾個(gè)命令定位pnpm why dayjs這條命令會(huì)列出 dayjs 依賴關(guān)系鏈告訴你哪些子包聲明了它、哪些是間接依賴。如果發(fā)現(xiàn)某個(gè)子包在代碼里用了它但 dependencies 里沒寫那就是幽靈依賴。如果是 npm 環(huán)境可以用npm ls dayjs它會(huì)給出依賴樹但因?yàn)樘嵘龣C(jī)制可能顯示在根 node_modules 下你還要人工判斷誰(shuí)真正聲明了它。更隱蔽的是版本間不一致A 子包依賴 dayjs 1.xB 子包依賴 dayjs 2.x如果兩個(gè)子包互相依賴鎖文件就會(huì)同時(shí)鎖定兩個(gè)版本。解決思路要么升級(jí)統(tǒng)一到一個(gè)大版本要么用別名。3.2 安裝到根還是安裝到子包位置不對(duì)引發(fā)的怪問(wèn)題很多人容易犯的一個(gè)錯(cuò)誤是在子包目錄里執(zhí)行新的依賴安裝。假設(shè)你當(dāng)前在 packages/ui 目錄下直接跑pnpm add axiospnpm 會(huì)檢測(cè)這里的 workspace 根節(jié)點(diǎn)把 axios 安裝到 packages/ui/package.json這通常沒問(wèn)題。但 npm 的行為就復(fù)雜一些如果你在 packages/ui 目錄下跑 npm install axios有的 npm 版本會(huì)把依賴寫進(jìn)子包有的則會(huì)在子包目錄生成一個(gè)獨(dú)立的 package-lock.json從而破壞根目錄的 lockfile 一致性。我建議的統(tǒng)一習(xí)慣是所有依賴安裝都在倉(cāng)庫(kù)根目錄執(zhí)行并用 filter 或 workspace 參數(shù)指定目標(biāo)。這樣不管哪個(gè)團(tuán)隊(duì)成員執(zhí)行命令結(jié)果都是可預(yù)測(cè)的。還有一類情況想把某個(gè)只用于構(gòu)建的工具裝到根。比如你想統(tǒng)一裝一個(gè) prettier 給所有子包用可以用pnpm add -w -D prettier-w 是 --workspace-root 的簡(jiǎn)寫表示安裝到根目錄。寫在根目錄的依賴所有子包運(yùn)行時(shí)都能通過(guò) Node 模塊解析找到這本身沒問(wèn)題但要注意的是這會(huì)在子包之間形成隱性依賴。比如 ui 子包里的 prettier 相關(guān)腳本其實(shí)依賴的是根目錄的 prettier一旦團(tuán)隊(duì)有人把 root 當(dāng)成雜貨間后面很容易亂。3.3 “缺少依賴項(xiàng)”類報(bào)錯(cuò)的通用排查思路很多人在安裝依賴時(shí)會(huì)碰到類似“由于缺少一些依賴項(xiàng)無(wú)法安裝產(chǎn)品”這樣比較模糊的報(bào)錯(cuò)。出現(xiàn)這種報(bào)錯(cuò)時(shí)第一反應(yīng)不應(yīng)該是懷疑 Monorepo而是排查環(huán)境。這類問(wèn)題在 Monorepo 環(huán)境中高發(fā)原因在于子包數(shù)量多原生模塊、編譯工具鏈穿插其中。比如某個(gè)子包依賴了 node-sass 或 sqlite3 這類需要原生編譯的包安裝時(shí)就需要系統(tǒng)里有 Python、C 編譯工具鏈、對(duì)應(yīng)操作系統(tǒng)版本的 SDK。我在 Windows 和 Linux 上都遇到過(guò)類似問(wèn)題報(bào)錯(cuò)信息里甚至?xí)小罢?qǐng)確保已安裝這些驅(qū)動(dòng)程序”之類的提示雖然字面上說(shuō)的是驅(qū)動(dòng)實(shí)際上往往是系統(tǒng)缺了某個(gè)運(yùn)行庫(kù)或編譯環(huán)境。如果你在 Monorepo 里遇到這類安裝失敗按順序排查先看報(bào)錯(cuò)全文重點(diǎn)看 Failed 或 Error 后面的完整路徑確認(rèn)是哪一層子包、哪個(gè)依賴導(dǎo)致的中斷。檢查系統(tǒng)環(huán)境比如 Python 是否在 PATH 中node-gyp 要求的版本是否滿足編譯器是否具備。確認(rèn)網(wǎng)絡(luò)環(huán)境registry 是否能訪問(wèn)到對(duì)應(yīng)依賴的下載地址。如果是 Windows還需要注意符號(hào)鏈接相關(guān)權(quán)限pnpm 要求開啟開發(fā)者模式或使用管理員運(yùn)行的終端。有些項(xiàng)目還要求在指定 Python 路徑下做環(huán)境配置。比如某些量化平臺(tái) QMT、AI 工具鏈 ComfyUI 安裝依賴時(shí)都會(huì)要求你先把環(huán)境切到特定 Python 解釋器再安裝否則會(huì)裝到別的環(huán)境里去。Monorepo 里也有類似場(chǎng)景你可能會(huì)同時(shí)維護(hù)多個(gè)運(yùn)行環(huán)境不同的子包有的是 Node 應(yīng)用有的是 Python 腳本。這種跨語(yǔ)言 Monorepo 的依賴安裝方案一般不建議只靠 npm/pnpm 一把梭而是讓不同語(yǔ)言各自管理依賴再用根目錄腳本統(tǒng)一串聯(lián)。3.4 lockfile 沖突與版本漂移的實(shí)戰(zhàn)處理多人協(xié)作的 Monorepo最讓人頭疼的就是根目錄 lockfile 的沖突以及由此引發(fā)的版本漂移。假設(shè)倉(cāng)庫(kù)用的是 pnpm。根目錄 pnpm-lock.yaml 記錄所有子包的完整依賴樹任何子包改動(dòng)依賴都會(huì)導(dǎo)致 lockfile 變化。當(dāng)兩個(gè)分支同時(shí)新增依賴合并時(shí) lockfile 大概率沖突。處理方式有幾種養(yǎng)成“依賴變更獨(dú)立提交”的習(xí)慣盡量少把依賴升級(jí)和功能開發(fā)混在同一個(gè) PR 里。出現(xiàn)沖突時(shí)不要急著手工改 lockfile先在其中一個(gè)分支上重新生成pnpm install --lockfile-only再提交。CI 上強(qiáng)制使用pnpm install --frozen-lockfile發(fā)現(xiàn) lockfile 與 package.json 不一致時(shí)直接失敗這樣能防止有人把失敗狀態(tài)的 lockfile 合進(jìn)主分支。版本漂移是另一種坑。子包 A 用lodash: ^4.17.20子包 B 用lodash: ^4.17.21正常情況下 pnpm 會(huì)嘗試解析到一個(gè)符合所有范圍的版本并安裝在 store 和對(duì)應(yīng)位置。但如果兩個(gè)范圍互不兼容pnpm 會(huì)各自安裝導(dǎo)致同一份代碼在不同子包中的行為不同。這種問(wèn)題在開發(fā)時(shí)很難察覺直到你發(fā)現(xiàn)一個(gè)子包修好的 bug 在另一個(gè)子包復(fù)現(xiàn)。要想統(tǒng)一跨子包依賴版本可以用 syncpack。它能夠掃描所有子包的 package.json檢查相同的依賴是否用了不同的版本范圍還能一鍵把范圍內(nèi)的版本統(tǒng)一成指定值。3.5 網(wǎng)絡(luò)與緩存問(wèn)題換個(gè)環(huán)境就裝不上依賴Monorepo 的子包數(shù)量一多依賴下載量會(huì)非常大網(wǎng)絡(luò)和緩存問(wèn)題也隨之放大。最常見的現(xiàn)象是本地裝得很好推到 CI 或者換一臺(tái)機(jī)器就失敗。這種問(wèn)題一般有三個(gè)方向registry 不穩(wěn)定。國(guó)內(nèi)環(huán)境經(jīng)常需要配置鏡像pnpm 可以通過(guò) .npmrc 配置 registry例如registryhttps://registry.npmmirror.com。注意 .npmrc 的位置根目錄配置會(huì)影響所有子包。store 不一致。pnpm 的全局 store 如果在不同機(jī)器上內(nèi)容不同會(huì)導(dǎo)致安裝時(shí)重新下載??梢栽?CI 中緩存 store 目錄減少網(wǎng)絡(luò)請(qǐng)求。離線包問(wèn)題。有時(shí)你把依賴下載到本地希望用離線文件安裝。pnpm 有對(duì)應(yīng)命令比如通過(guò)pnpm store add先把某個(gè)包放入 store再離線安裝。這與你在 ComfyUI 這類工具中手動(dòng)指定本地依賴文件是相似的思路——關(guān)鍵是要搞清工具讀取的目錄和解析方式避免“我明明下載了但沒生效”的錯(cuò)覺。如果遇到“明明換了一臺(tái)機(jī)器就成功這臺(tái)機(jī)器總是失敗”的情況優(yōu)先測(cè)試網(wǎng)絡(luò)和全局緩存目錄權(quán)限不要一上來(lái)就改代碼。4. 把子包依賴裝穩(wěn)的進(jìn)階實(shí)踐4.1 用同一份 lockfile 約束所有子包的依賴Monorepo 的優(yōu)勢(shì)之一是根目錄只有一份 lockfile可以讓所有子包在同一個(gè)依賴快照下運(yùn)行。這樣在 CI 上構(gòu)建時(shí)無(wú)論構(gòu)建哪個(gè)子包使用的依賴版本都一致可復(fù)現(xiàn)性會(huì)好很多。但前提是你得保證不會(huì)產(chǎn)生多余 lockfile。你需要檢查倉(cāng)庫(kù)里是否存在 packages/*/package-lock.json 這類文件。一旦出現(xiàn)說(shuō)明有人在子包目錄單獨(dú)執(zhí)行過(guò) install。發(fā)現(xiàn)后應(yīng)刪除并在根目錄重新執(zhí)行安裝。為了防止再次發(fā)生可以在 CI 里加一道檢查掃描多余 lockfile 文件。在依賴鎖定的基礎(chǔ)上使用--frozen-lockfile是必要的。比如 pnpm 中pnpm install --frozen-lockfile如果 package.json 與 lockfile 不完全匹配命令會(huì)直接報(bào)錯(cuò)而不是幫你悄悄更新 lockfile。這樣能確保 CI 環(huán)境與提交的 lockfile 完全一致。npm 對(duì)應(yīng)參數(shù)是npm ciyarn classic 是yarn install --frozen-lockfile。4.2 結(jié)合 changesets 管理發(fā)布讓依賴變更可追溯子包安裝依賴只是第一步最終是要讓子包發(fā)布上線。在 Monorepo 里發(fā)布一個(gè)包時(shí)它依賴的本地 workspace 包如果也改了就必須一起發(fā)布新版本并更新依賴范圍。changesets 是目前最成熟的方案之一。它不直接管理依賴版本而是在倉(cāng)庫(kù)中記錄 changeset 文件描述哪些子包做了哪些改動(dòng)、版本是 minor 還是 major最后通過(guò) CI 合并成 changelog 和版本變更。使用流程大概是這樣pnpm add -w -D changesets/cli pnpm changeset init pnpm changeset每次改動(dòng)后在根目錄執(zhí)行 pnpm changeset 會(huì)交互式問(wèn)你哪個(gè)子包改了、版本級(jí)別是什么是否作為某個(gè)依賴的配套變更。提交后之后在 CI 執(zhí)行pnpm changeset version pnpm changeset publishchangesets 會(huì)自動(dòng)把子包里的workspace:*依賴改寫成實(shí)際發(fā)布的版本號(hào)然后發(fā)布到 registry。這樣做的好處是依賴變更可以追溯到一次 PR而不是散落在多個(gè)提交里。4.3 在 CI 里安裝子包依賴的高效姿勢(shì)CI 環(huán)境與本地有很大差異最大的問(wèn)題是每次都是全新環(huán)境依賴安裝時(shí)間可能長(zhǎng)達(dá)十幾分鐘。優(yōu)化思路有幾個(gè)第一合理緩存 store。pnpm 的全局 store 可以做持久化緩存比如在 GitHub Actions 中緩存~/.local/share/pnpm/storeWindows 路徑會(huì)不同可以大幅減少二次安裝時(shí)間。第二使用 frozen lockfile。前面提過(guò)這里再次強(qiáng)調(diào)CI 里絕不能允許依賴悄悄變更。第三按需安裝。雖然 Monorepo 統(tǒng)一安裝所有子包比較省心但如果你只需要構(gòu)建其中一個(gè)子包可以用 pnpm 的 filter 縮小范圍pnpm install --filter my/ui...注意后面是三個(gè)點(diǎn)表示同時(shí)安裝該子包及其依賴的 workspace 子包。第四不要把日志污染到失控。子包多時(shí)安裝日志會(huì)非常長(zhǎng)建議開啟--reporterappend-only或類似配置讓 CI 日志只顯示關(guān)鍵步驟。4.4 一些值得長(zhǎng)期堅(jiān)持的目錄設(shè)計(jì)原則最后聊一聊長(zhǎng)期維護(hù) Monorepo 時(shí)的幾個(gè)原則它們?cè)谠搭^減少依賴安裝問(wèn)題。一是盡量使用 scope 規(guī)范子包命名形如 your-org/package-name。scope 能精準(zhǔn)指定子包避免出現(xiàn) ui、utils 這樣容易沖突的名字。二是控制子包規(guī)模。子包越多依賴關(guān)系越復(fù)雜安裝和編排成本也越高。如果一個(gè)子包只有幾行代碼不如考慮合并到其他包中。三是依賴關(guān)系圖盡量保持有向無(wú)環(huán)。子包之間的依賴不要形成環(huán)否則發(fā)布和安裝都會(huì)變得很難受??梢杂?madge 之類的工具在 CI 中檢查依賴關(guān)系防止循環(huán)依賴。四是謹(jǐn)慎使用 peerDependencies。Monorepo 中跨子包傳遞 peer 依賴經(jīng)常引發(fā)版本匹配問(wèn)題尤其在 Vue/React 組件庫(kù)場(chǎng)景中peer 依賴范圍寫得太緊會(huì)導(dǎo)致安裝失敗寫得太松又可能讓使用者拿到不兼容版本。在能明確統(tǒng)一的情況下直接使用 dependencies 反而更省心。5. 我的幾個(gè)依賴管理習(xí)慣說(shuō)到底Monorepo 子包安裝依賴的核心不是在命令層面記住一堆參數(shù)而是理解依賴的本質(zhì)節(jié)點(diǎn)之間如何解析、提升和鏈接以及如何用統(tǒng)一鎖文件約束版本。單倉(cāng)庫(kù)時(shí)代我們只需要關(guān)心 npm install 做了什么到了 Monorepo你需要額外關(guān)心的是“哪一個(gè)包”和“在哪一層”。我這里給出一個(gè)最實(shí)用的建議無(wú)論你最終選 npm、yarn 還是 pnpm都請(qǐng)?jiān)诟夸浗y(tǒng)一操作用 filter 或 workspace 命令指定子包如果遇到依賴安裝報(bào)錯(cuò)先看環(huán)境再改代碼盡量把 lockfile 當(dāng)成代碼來(lái)維護(hù)該 review 就 review。我在實(shí)際維護(hù) Monorepo 的過(guò)程中深有體會(huì)依賴裝錯(cuò)位置的坑遠(yuǎn)遠(yuǎn)少于依賴清理不及時(shí)的坑?,F(xiàn)在我維護(hù)新倉(cāng)庫(kù)時(shí)會(huì)把“能否用 pnpm 嚴(yán)格鏈接”作為默認(rèn)驗(yàn)收項(xiàng)如果團(tuán)隊(duì)不想換工具那至少也要保證根 lockfile 始終存在于版本庫(kù)且 CI 里開啟 frozen 校驗(yàn)。一套流程順下來(lái)再回頭看那些“莫名其妙裝不上”的問(wèn)題大概率都是環(huán)境或約定層面可以提前規(guī)避的。最后再分享一個(gè)小技巧如果你發(fā)現(xiàn)自己經(jīng)常在 Monorepo 里依賴某個(gè)包時(shí)來(lái)回踩坑先在根目錄跑一次pnpm why和pnpm outdated看清楚依賴來(lái)源和版本狀態(tài)再?zèng)Q定是升級(jí)還是固定版本。比起盲目刪 node_modules 重裝這種排查方式要精準(zhǔn)得多。