
1. 為什么 package.json 值得你花時間吃透如果你寫 JavaScript 或者 TypeScript哪怕只是偶爾碰一下前端項目package.json這個文件你肯定見過。它躺在項目根目錄里看起來就是一堆 JSON 鍵值對很多人平時只關(guān)心兩件事dependencies里裝了什么包scripts里怎么啟動項目。剩下的字段要么視而不見要么復(fù)制粘貼的時候順手帶過來從來沒想過它們是干什么用的。我剛開始做項目那幾年也是這個狀態(tài)。直到有一次排查一個線上問題發(fā)現(xiàn)構(gòu)建產(chǎn)物里混進了開發(fā)環(huán)境的調(diào)試代碼查了半天才定位到是dependencies和devDependencies放錯了位置還有一次發(fā)布一個工具庫別人裝完之后類型提示全丟了最后發(fā)現(xiàn)是types字段沒配對。這些坑踩多了之后我才意識到package.json根本不是一個“配置文件”那么簡單它是整個 Node 生態(tài)里包與包之間溝通的契約是 npm 理解你項目的唯一入口。這篇文章就是把我這些年跟package.json打交道積累下來的東西系統(tǒng)梳理一遍。從最基礎(chǔ)的字段含義到依賴版本號的語義化規(guī)則再到exports、workspaces這些相對新但越來越重要的能力我都會結(jié)合真實項目里的使用場景講清楚。不管你是剛?cè)胄械男率诌€是已經(jīng)寫了幾年業(yè)務(wù)代碼想往工程化方向走的人看完之后應(yīng)該都能對這個文件有一個更完整的認知至少下次再看到不認識的字段你知道該去哪里查、它大概會影響什么。文章里涉及的所有項目名、包名、人名我都會用虛構(gòu)代稱但字段名、版本規(guī)則、行為邏輯都是真實可驗證的。你可以邊看邊打開自己手頭的項目對照著檢查效果會更好。2. package.json 的整體結(jié)構(gòu)與核心定位2.1 它在 Node 生態(tài)里到底扮演什么角色要理解package.json得先搞清楚 npm 是怎么看待一個項目的。在 npm 的世界里任何一個包含package.json的目錄都可以被當作一個“包”來處理。這個文件承擔(dān)了三個層面的職責(zé)身份標識、依賴聲明、行為定義。身份標識指的是name和version這兩個字段它們組合起來構(gòu)成了這個包在 registry 里的唯一坐標。依賴聲明就是dependencies、devDependencies、peerDependencies這些字段告訴 npm 裝這個包的時候還需要哪些東西。行為定義則包括scripts、main、exports、bin等決定了這個包被安裝后怎么被調(diào)用、入口在哪里、有哪些命令可以執(zhí)行。很多人會把package.json和package-lock.json搞混。簡單說前者是你手寫的“意圖聲明”后者是 npm 根據(jù)你的聲明實際解析出來的“精確快照”。你寫lodash: ^4.17.0這是意圖lock 文件里記錄的4.17.21加上 integrity 哈希這是實際結(jié)果。兩者配合才能保證團隊里每個人裝出來的依賴樹是一致的。2.2 一個最小可用文件長什么樣理論上一個合法的package.json只需要name和version兩個字段就能通過校驗。但實際項目里當然不會這么寒酸。下面是一個我常用的起步模板你可以直接拿去改{ name: my-project, version: 1.0.0, description: 一個用于演示的項目, main: dist/index.js, types: dist/index.d.ts, scripts: { build: tsc, test: jest }, keywords: [demo, template], author: someone, license: MIT }這個模板里每個字段都有明確用途后面我會逐個展開。這里先提醒一點name字段有命名規(guī)范不能有大寫字母、空格也不能以點或下劃線開頭長度不能超過 214 個字符。如果你打算發(fā)布到公共 registry還要注意名字不能被別人占用。2.3 字段的分類邏輯我把package.json里的字段大致分成四類這樣記憶和理解都會清晰很多分類代表字段作用元信息name, version, description, author, license描述這個包是誰、干什么用的入口與導(dǎo)出main, module, types, exports, browser告訴外部怎么引用這個包依賴管理dependencies, devDependencies, peerDependencies, optionalDependencies聲明運行和開發(fā)所需的其他包腳本與工具scripts, bin, engines, config定義可執(zhí)行命令和運行環(huán)境要求這個分類不是官方標準但用起來很順手。你在排查問題時先判斷問題屬于哪一類然后去對應(yīng)字段里找原因效率會高很多。3. 依賴管理字段的深度拆解3.1 dependencies 與 devDependencies 的邊界怎么劃這是最常被問、也最容易出錯的一組字段。核心原則只有一句話運行時需要的放 dependencies只在開發(fā)、構(gòu)建、測試階段需要的放 devDependencies。什么叫“運行時需要”就是這個包被安裝到別人項目里之后如果缺少這個依賴代碼就會報錯或者功能不正常。比如你寫了一個 React 組件庫那react就是運行時依賴必須放dependencies。但如果你只是用typescript來編譯代碼編譯完的產(chǎn)物里并不包含 tsc 本身那typescript就應(yīng)該放devDependencies。我見過最常見的錯誤是把構(gòu)建工具、測試框架、類型定義全都塞進dependencies。這樣做本身不會讓代碼跑不起來但會帶來兩個后果一是別人安裝你的包時會莫名其妙多下載一堆根本用不到的東西安裝體積和耗時都會增加二是在一些對依賴體積敏感的場景比如前端打包可能會把不該打進去的東西打進去。有一個判斷技巧很實用問自己“如果把這個包刪掉用戶在使用我的包的功能時會不會出問題”會就是dependencies不會就是devDependencies。3.2 peerDependencies 的真實用途peerDependencies是很多人一直沒搞明白的字段。它的含義是“我用到了某個包但我不自己安裝它而是要求使用我的人來提供?!弊畹湫偷膱鼍熬褪遣寮w系。假設(shè)你寫了一個 Webpack 插件這個插件必須配合 Webpack 使用但你不應(yīng)該把 Webpack 打包進插件里因為使用者自己的項目里已經(jīng)有 Webpack 了。這時候你就把 Webpack 聲明在peerDependencies里。從 npm 7 開始peerDependencies的默認行為發(fā)生了變化如果使用者沒有安裝對應(yīng)的 peer 依賴npm 會嘗試自動安裝。這個變化解決了很多“裝完插件發(fā)現(xiàn)缺依賴”的問題但也帶來了一些意料之外的版本沖突。所以在聲明 peer 依賴時版本范圍要盡量寫得寬松一些比如4.0.0而不是^4.17.0給使用者留出選擇空間。3.3 optionalDependencies 與 bundledDependenciesoptionalDependencies的語義是“裝不上也沒關(guān)系”。npm 在安裝時會嘗試安裝這些依賴但如果失敗比如某個平臺沒有對應(yīng)的二進制包不會導(dǎo)致整個安裝過程失敗。這個字段用得不多主要出現(xiàn)在一些需要跨平臺兼容的底層工具里。bundledDependencies也可以寫成bundleDependencies則是把依賴直接打包進你的發(fā)布產(chǎn)物里。這個字段現(xiàn)在很少用了因為現(xiàn)代 npm 的依賴解析已經(jīng)很可靠但在一些離線部署或者需要鎖定特定版本的場景下仍然有用。3.4 版本號語義化規(guī)則與選擇策略版本號是依賴管理里最容易埋雷的地方。npm 遵循的是語義化版本SemVer規(guī)范格式是主版本號.次版本號.修訂號。規(guī)則很簡單主版本號變化有不兼容的 API 修改次版本號變化新增了向后兼容的功能修訂號變化修復(fù)了向后兼容的 bug在package.json里寫版本范圍時常用的前綴有這幾種寫法含義示例匹配^1.2.3允許次版本和修訂號升級1.2.3 到 1.x.x~1.2.3只允許修訂號升級1.2.3 到 1.2.x1.2.3精確鎖定只有 1.2.31.2.3大于等于1.2.3 及以上*任意版本任何版本我的經(jīng)驗是應(yīng)用項目用^工具庫用~或精確版本。應(yīng)用項目不怕自動升級因為你有 lock 文件和測試兜底但工具庫如果依賴范圍太寬使用者裝到的版本可能跟你測試時不一樣容易出兼容性問題。注意不要手動去改package-lock.json里的版本號那個文件應(yīng)該由 npm 自動維護。你只需要改package.json然后重新執(zhí)行安裝命令。4. 入口與導(dǎo)出字段的實戰(zhàn)解析4.1 main、module、browser 的分工這三個字段都跟“入口”有關(guān)但面向的環(huán)境不同。main是最傳統(tǒng)的字段指向 CommonJS 格式的入口文件。Node.js 環(huán)境默認讀這個字段。module是社區(qū)約定指向 ES Module 格式的入口打包工具如 Rollup、Webpack會優(yōu)先讀它以便做 tree-shaking。browser則用于指定瀏覽器環(huán)境下的替代入口可以是一個字符串也可以是一個對象映射把某些 Node 專用的模塊替換成瀏覽器可用的版本。一個典型的雙格式包會這樣寫{ main: dist/index.cjs.js, module: dist/index.esm.js, browser: dist/index.umd.js }這樣 Node 環(huán)境用 CJS打包工具用 ESM直接在瀏覽器里用 script 標簽引入時用 UMD。4.2 types 與類型聲明的正確姿勢如果你用 TypeScript 寫庫types字段必須指向生成的.d.ts文件。很多人會把它寫成源碼里的.ts文件路徑這是不對的——使用者裝的是你發(fā)布后的產(chǎn)物源碼文件通常不在發(fā)布范圍內(nèi)。正確的做法是在tsconfig.json里開啟declaration: true讓編譯器生成類型聲明文件然后讓types指向輸出目錄里的對應(yīng)文件。如果你同時提供了 CJS 和 ESM 兩套產(chǎn)物最好用exports字段里的types條件分別指定避免類型解析出錯。4.3 exports 字段新一代的入口控制exports是 Node.js 12.7 之后引入的字段它比main更強大也更嚴格。一旦你定義了exportsNode 就會忽略main所有外部引用都必須通過exports里聲明的路徑來訪問。它的基本結(jié)構(gòu)是這樣的{ exports: { .: { types: ./dist/index.d.ts, import: ./dist/index.esm.js, require: ./dist/index.cjs.js }, ./utils: { import: ./dist/utils.esm.js, require: ./dist/utils.cjs.js } } }這個配置做了兩件事一是定義了根入口在不同模塊系統(tǒng)下的解析路徑二是暴露了一個子路徑./utils讓使用者可以按需引入。exports的一個重要作用是封裝內(nèi)部結(jié)構(gòu)。在沒有exports的年代使用者可以引用你包里的任意文件比如require(your-pkg/dist/internal/helper)。這很危險因為內(nèi)部文件隨時可能重構(gòu)。有了exports之后只有你顯式暴露的路徑才能被訪問其他路徑一律報錯。這對庫作者來說是一個很好的保護機制。提示exports的路徑必須以./開頭不能寫成dist/index.js。這個細節(jié)很容易忽略但寫錯了會直接導(dǎo)致解析失敗。5. 腳本、二進制與運行環(huán)境配置5.1 scripts 的進階用法scripts字段大家都會用但有些技巧能讓它更好用。首先是pre和post鉤子如果你定義了build腳本那么prebuild會在它之前執(zhí)行postbuild會在它之后執(zhí)行。這個機制可以用來做清理、校驗、通知等事情。其次是跨平臺兼容。scripts里的命令默認由系統(tǒng)的 shell 執(zhí)行這意味著 Windows 和 Unix 下的寫法可能不一樣。比如rm -rf dist在 Windows 的 cmd 里就不管用。解決辦法是用rimraf這類跨平臺工具或者用 Node 腳本代替 shell 命令。還有一個實用技巧是用npm-run-all或者concurrently來并行或串行執(zhí)行多個腳本。比如{ scripts: { build: npm-run-all clean build:*, build:js: tsc, build:css: sass src:dist, clean: rimraf dist } }這樣build會先執(zhí)行clean然后依次執(zhí)行build:js和build:css邏輯清晰也方便單獨調(diào)用某個子任務(wù)。5.2 bin 字段與命令行工具如果你想讓自己的包提供一個命令行命令就需要用到bin字段。最簡單的寫法是{ bin: { my-cli: ./bin/cli.js } }這樣使用者全局安裝或者通過npx調(diào)用時就可以直接執(zhí)行my-cli。對應(yīng)的cli.js文件開頭必須加上 shebang#!/usr/bin/env node沒有這行系統(tǒng)不知道用什么解釋器來執(zhí)行這個文件。另外bin指向的文件在發(fā)布時必須有可執(zhí)行權(quán)限npm 在打包時會自動處理但如果你手動拷貝文件就要注意這一點。5.3 engines 與 packageManagerengines字段用來聲明你的包對 Node.js 或 npm 版本的要求{ engines: { node: 16.0.0, npm: 8.0.0 } }默認情況下npm 只會在版本不滿足時給出警告不會阻止安裝。如果你希望強制阻止可以在項目里加一個.npmrc文件寫入engine-stricttrue。packageManager字段是較新的約定用來指定項目應(yīng)該使用哪個包管理器及其版本比如packageManager: pnpm8.6.0。配合 Corepack 使用可以確保團隊成員用同一套工具鏈減少“我這里能跑你那里跑不了”的問題。6. 常見問題排查與避坑經(jīng)驗6.1 依賴裝不上或版本沖突怎么辦這是最高頻的問題。排查思路我一般按這個順序走第一步刪掉node_modules和package-lock.json重新安裝。很多問題是緩存或者 lock 文件損壞導(dǎo)致的重裝能解決一大半。第二步如果重裝無效用npm ls 包名查看這個包被誰依賴了、裝了幾個版本。版本沖突往往是因為不同的包依賴了同一個包的不兼容版本。第三步如果確實存在無法調(diào)和的沖突可以考慮用overrides字段npm 8.3強制指定某個包的版本{ overrides: { some-package: 2.0.0 } }這個能力要謹慎使用因為它可能讓某個依賴拿到它并不兼容的版本。用之前最好確認一下被覆蓋的包在目標版本下是否還能正常工作。6.2 發(fā)布后類型提示丟失這個問題通常有三個原因types字段路徑寫錯、.d.ts文件沒有被包含在發(fā)布產(chǎn)物里、或者exports里沒有聲明types條件。排查時可以先執(zhí)行npm pack生成一個 tarball解壓看看里面到底有哪些文件。如果.d.ts不在里面就要檢查files字段或者.npmignore是不是把它排除了。6.3 files 字段與發(fā)布范圍控制files字段決定了哪些文件會被發(fā)布到 registry。不寫這個字段的話npm 會發(fā)布除了.npmignore和默認忽略列表之外的所有文件很容易把測試代碼、配置文件、甚至密鑰文件發(fā)出去。我的習(xí)慣是顯式聲明files{ files: [dist, README.md, LICENSE] }這樣只有dist目錄和兩個說明文件會被發(fā)布干凈利落。發(fā)布前用npm pack --dry-run預(yù)覽一下文件列表確認沒問題再正式發(fā)。6.4 常見問題速查表現(xiàn)象可能原因解決方向安裝時報 peer 依賴缺失peerDependencies 未滿足手動安裝對應(yīng) peer 包或調(diào)整版本打包體積異常大開發(fā)依賴放進了 dependencies檢查依賴分類移到 devDependencies引用子路徑報錯exports 未暴露該路徑在 exports 中添加對應(yīng)子路徑類型提示不生效types 路徑錯誤或文件未發(fā)布檢查 types 字段和 files 字段命令執(zhí)行報權(quán)限錯誤bin 文件缺少 shebang 或權(quán)限添加 shebang檢查文件權(quán)限不同環(huán)境裝出不同版本缺少 lock 文件或版本范圍過寬提交 lock 文件收緊版本范圍7. 進階字段與工程化實踐7.1 workspaces 與 monorepoworkspaces字段讓一個倉庫可以管理多個包是 monorepo 的基礎(chǔ)。基本寫法是{ workspaces: [packages/*] }這樣packages目錄下的每個子目錄都會被當作一個獨立的包來處理npm 會把它們之間的依賴關(guān)系自動鏈接起來不需要手動npm link。對于維護多個相互依賴的包的項目來說這個能力能省掉大量繁瑣的操作。使用 workspaces 時要注意一點根目錄的package.json通常設(shè)為private: true避免誤發(fā)布。各個子包的依賴盡量聲明在子包自己的package.json里而不是全部堆在根目錄否則依賴關(guān)系會變得難以追蹤。7.2 private 字段與防誤發(fā)布private: true是一個簡單但極其重要的字段。加上它之后npm 會拒絕發(fā)布這個包。對于公司內(nèi)部項目、示例項目、根目錄的 monorepo 配置來說這個字段應(yīng)該默認加上。我見過不止一次有人不小心把內(nèi)部項目發(fā)到了公共 registry雖然可以撤回但處理起來很麻煩。7.3 條件導(dǎo)出與多環(huán)境適配exports字段支持條件導(dǎo)出可以根據(jù)不同的環(huán)境返回不同的文件。除了前面提到的import、require、types還有node、browser、default等條件。一個完整的例子{ exports: { .: { types: ./dist/index.d.ts, browser: ./dist/index.browser.js, node: { import: ./dist/index.node.esm.js, require: ./dist/index.node.cjs.js }, default: ./dist/index.esm.js } } }條件的匹配是從上到下依次判斷的所以順序很重要。types必須放在最前面default放在最后作為兜底。7.4 版本發(fā)布與 changelog 維護版本號不是隨便定的。每次發(fā)布前想清楚這次改動屬于哪一類修 bug 就升修訂號加功能就升次版本號有破壞性變更就升主版本號。配合CHANGELOG.md記錄每個版本的變化對使用你包的人來說是很大的幫助。如果團隊規(guī)模不大手動維護 changelog 完全夠用。如果發(fā)布頻繁可以考慮用changesets或者standard-version這類工具來自動化版本管理和 changelog 生成。工具的選擇取決于團隊習(xí)慣沒有絕對的好壞。8. 我個人的一些使用習(xí)慣寫了這么多年package.json有幾個習(xí)慣我覺得挺有價值分享出來供參考。第一每次新增依賴都問一句“這是運行時還是開發(fā)時”。這個動作只需要幾秒鐘但能避免后面大量的依賴分類混亂。第二發(fā)布前一定跑npm pack --dry-run。這個命令會列出即將發(fā)布的文件清單比任何檢查都直觀。我靠它攔下過好幾次誤包含的測試文件和本地配置。第三版本范圍寧緊勿松。尤其是工具庫依賴范圍寫寬了使用者裝到的版本可能跟你測試的完全不一樣。寧可讓使用者偶爾遇到版本沖突手動解決也不要讓他們在不知情的情況下裝到一個你沒驗證過的版本。第四把package.json當成代碼來維護。它值得被 review值得有注釋雖然 JSON 不支持注釋但可以在 README 里說明值得在重構(gòu)時一起整理。一個混亂的package.json往往是項目工程化水平的一個縮影。這些經(jīng)驗沒有什么高深的理論都是在一次次踩坑和修坑中攢下來的。你如果剛開始接觸這些字段不用一次性全部記住先把dependencies、scripts、main、types這幾個最常用的搞清楚其他的遇到再查、再用、再記慢慢就熟了。