套件管理大師
Caution
npm / pnpm 軟連結在 Windows 平台以 JUNCTION 實現 (機制相似),vscode 會判定這東西是軟連結,但 Python 的
os.path.islink 是測不出來的。Prerequisites
module resolution algorithm (node)
假設 node 要找一個第三方模組 art-template
內建模組
若是內建模組名稱,直接載入模組
本地模組
若是相對路徑或絕對路徑,直接載入模組
第三方模組
若上面兩個都不是,依序執行以下步驟:
a. 尋找 node_modules 目錄
- 從當前目錄往上層尋找,直到找到為止
b. 尋找 art-template 目錄
- 在
node_modules中尋找,找不到則繼續往上層
c. 尋找 package.json
- 若無,預設 import
art-template/index.js - 若連
index.js也沒有,往上一層,退回步驟 3.a
d. 讀取 package.json 的 "main" 欄位
- 其 value 即為最終被 import 的檔案
- 若無,預設 import
art-template/index.js - 若連
index.js也沒有,往上一層,退回步驟 3.a
node_modules
before npm v3
node_modules
├── A@1.0.0
│ └── node_modules
│ └── C@1.0.0
├── B@1.0.0
│ └── node_modules
│ └── C@1.0.0
├── C@1.0.1
└── D@1.0.0
嵌套式的依賴結構。
每個套件都直接包含了各自使用的套件,
這會導致:
- 路徑長到靠北:node_modules/A/node_modules/C/node_modules...
- 一大堆重複安裝的套件
npm v3
node_modules
├── A@1.0.0
│ └── node_modules
│ └── C@1.0.0
├── B@1.0.0
│ └── node_modules
│ └── C@1.0.0
├── C@1.0.1
└── D@1.0.0
扁平化的依賴結構。
依賴關係通通都被抬升 (hoisting) 到最頂層,
這樣就不用重複安裝這麼多套件了,只保留一份即可。
但有個要注意的點:只有最新版套件會被 hoisting,
若有舊版套件被依賴,仍會保留在它們各自的 node_modules。
如上,假設 D@1.0.0 依賴最新版套件 C@1.0.1,這樣 C@1.0.1 放在頂層沒問題。
而 A@1.0.0 和 B@1.0.0 依賴舊版套件 C@1.0.0,
它們就只能將 C@1.0.0 放在各自的 node_modules。
這與 Python pip 的套件管理不同,雖然它也是扁平式的沒錯,
但 pip 不會出現一個套件有兩個版本的情況。
這也是因為 Node.js Module Resolution Algorithm 的特殊性,
所以 Node.js 的 npm 可以這樣玩,但 Python 的 pip 不能。
npm v5
npm 的 package.json (或者是 pip 的 requirements.txt)
指定的範圍版本號雖然對相容性友好,
但在多人開發時,由於團隊成員 install 套件的時間差,
可能在過程中,依賴套件有新版本釋出,這就導致可能下載到不同版本。
這表示大家一同開發的專案,開發時運行的依賴套件的版本卻可能不盡相同。
這在多人開發中是一大隱憂。
也因此 lockfile 應運而生,例如:
- npm 的 packge-lock.json
- yarn 的 yarn.lock
- pnpm 的 pnpm-lock.yaml
pip(抱歉了沒有你)- poetry 的 poetry.lock
- ...
其作用就是將下載的依賴套件的精確版本號記錄下來,
這樣子的話,只要團隊中所有成員拿到的是同一個 package-lock.json,
它們 install 的每個依賴套件的版本也將會是相同的。
package.json
type
{
// 讓 JS 檔案被當作 esmodule 來解析
"type": "module"
}
- 預設情況下,JS 檔案會被當作 commonjs 來解析
main
{
// 最傳統、歷史最悠久的套件主入口 (大門)
// 告訴系統:當使用者引入整個套件時,預設要讀取哪一個檔案。
"main": "./dist/index.js",
}
exports
{
// 現代化、優先權最高的套件入口設定 (Node.js 12+ 支援)
"exports": {
// 1. 預設主入口點 (".") 與「條件匯出」 (Conditional Exports)
// 系統會根據使用者的環境,自動分發對應的檔案
".": {
"types": "./dist/index.d.ts", // 若使用者用 TypeScript,給他型別檔
"import": "./dist/index.mjs", // 若使用者寫 import pkg from 'my-package',給他 ESM 版本
"require": "./dist/index.cjs" // 若使用者寫 require('my-package'),給他 CommonJS 版本
},
// 2. 子路徑匯出 (Subpath Exports)
// 提供乾淨的引入路徑,例如:import { add } from 'my-package/math'
// 這種做法能讓打包工具直接拿目標檔案,極大地神助攻 tree-shaking!
"./math": "./src/math.js",
"./api": "./src/api.js",
// 3. 允許存取 package.json
"./package.json": "./package.json"
// 「路徑封裝」 (Encapsulation) 防護機制:
// 只要沒有寫在這個 exports 物件裡的路徑 (例如 "./src/utils/private.js"),
// 外部就絕對無法引入,會直接報錯!這保護了套件的內部結構不被亂用。
}
}
private
{
// 防誤發布的安全開關
"private": true
}
- 要發布到 npm,表示專案性質是函式庫,但有的專案是作為 frontend、monorepo 或是私人用途
script
{
"scripts": {
// 安裝前 hook 腳本
"preinstall": "node check-env.js",
// 安裝時 hook 腳本
"install": "node install.js",
// 安裝後 hook 腳本
"postinstall": "node install-binary.js"
}
}
bin
{
"bin": {
// CLI 名稱: JS 腳本
"q-cli": "./bin/q-cli.js"
}
}
- global 安裝時,會在管理目錄頂層放置 shell 腳本,其將執行指定 JS 腳本
管理目錄的路徑,有註冊在環境變數,也就是說 CLI 名稱是全域可見的
- local 安裝時,會在
node_modules/bin目錄放置 shell 腳本,其將執行指定 JS 腳本詳見
npm run
sideEffects
{
// 標記套件是否有副作用 (可優化 tree-shaking)
"sideEffects": false
}
Nouns
lockfile
即「紀錄所有套件的精確版本號的檔案」。
scoped package
開頭有 @ 的套件,比如 @babel/eslint-parser、@vue/cli-service,
有點類似命名空間,可以防止自己寫的套件名稱和別人衝突。
flat dependency directory
phantom dependency
即「幽靈依賴套件」。詳見此文。 假設專案使用到 axios 套件,package.json 應長這樣
{
"dependencies": {
"axios": "^1.7.2"
}
}
然而 axios 套件內部依賴 follow-redirects,所以你很合理的可以這樣寫
const followRedirects = require('follow-redirects')
console.log(followRedirects)
follow-redirects 就是所謂的 phantom dependency, 雖然看起來好像沒甚麼,但這通常被視為一種 anti-pattern, 假設某一天 axios 套件出新版本了, 在新版本中,它可能不依賴 follow-redirects 套件了。 然後你的 npm 根據專案 packge.json 重新下載 axios 套件最新版, 你的舊代碼就炸裂了。
peer dependency
簡單來說就是插件 (plugins) 和宿主 (host) 之間的依賴關係。詳見 npm doc。
而 peer dependency 指定的版本,就是插件所依賴的宿主版本。
有玩過魔獸世界嗎?印象中最多人用的就是彩虹 UI 插件包嘛。

首先:
- plugins (插件) 不能獨立運行,它必須倚靠 host (魔獸) 才能運行。
- plugins (插件) 是會過期的,通常僅限於 host (魔獸) 某幾個版本能用。 比如要塞系統插件,只有德拉諾之霸 (也就是 6 版) 能用 (我真它〇老人), 而這就與 host 版本更新 (魔獸本身的版本更新) 有關。
拿魔獸當例子的話,比如:
{
"name": "要塞小助手",
"version": "1.0.0",
"peerDependencies": {
"魔獸世界": "6.x"
}
}
舉個實際點的例子,Ant Design 和 React:
{
"name": "antd",
"version": "5.21.6",
"peerDependencies": {
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
}
npm
套件管理工具
references
- 🔗 package.json
- 🔗 npm SemVer Calculator
- 🔗 PJCHENder - 發布 npm 套件
- 🎬 PJCHENder - npm create 是什麼?
- 🎬 How To Create And Publish Your First NPM Package
note
Windows:
~\AppData\Roaming\npmnpm init | npm create
雖然它倆是 alias,但後兩個 case 比較常用 create
npm init:初始化專案,會創建 package.jsonnpm create <pkg>: 同npm exec create-<pkg>(前綴 create)npm init foo->npm exec create-foo
npm create <@scoped>: 同npm exec <@scoped>/create(前綴 create)npm init @usr/foo->npm exec @usr/create-foo
npm install | npm i
npm install <pkg>:local 安裝、生產依賴套件npm install -g <pkg>:global 安裝、生產依賴套件npm install --ignore-scripts: 禁止套件執行 install 相關的 hook 腳本npm install --save-dev <pkg>:local 安裝、開發依賴套件npm install --package-lock-only:只產生 package-lock.json- 在僅使用 pnpm 的專案中,若想支援 npm,這個指令十分好用
npm install:根據 package.json 的紀錄下載套件- 若存在 package-lock.json,將按照此檔案指定的精確版本號下載套件
npm update
npm update:更新所有已安裝的依賴套件
npm uninstall
npm uninstall <pkg>移除指定依賴套件
npm list
npm list:已安裝的套件npm list -g:顯示全域安裝的依賴套件
npm outdated
npm outdated:顯示可更新的套件
npm run
執行在 package.json 中指定的 scripts (npm scripts)。
只能執行 local 套件的 scripts。
在 local 專案的node_modules/.bin目錄中,尋找 shell scripts 執行。
假設是 sass 的,那麼 shell scripts 的內容基本上就是執行node node_modules/sass/sass.js。
npm run <script-name>{ "scripts": { "start": "node app.js", "test": "jest" } }
npm exec | npm x
執行不在 package.json 中指定的 scripts。
若 local 沒有安裝套件,會去 global 找,
若 global 也沒有安裝套件,會暫時安裝,執行完畢後再解除安裝。
npm exec -- <command> [arg]...npm exec --package <pkg> -- <command> [arg]...--package:當套件名稱與它在.bin的 shell scripts 名稱不同時,指定套件名稱。- 會指定這個選項,表示你應該是想要暫時安裝。
npm cache
npm cache clean --force:清除 npm 快取 (功能同 pip 快取)
npm link
在測試發布套件時很好用
npm login
登入 npm
npm publish
發布必須登入 npm
npm publish: 發布npm publish --access=public: public 發布
npm deprecate
當已經沒辦法撤下在 npm 發布的套件時,棄用是你的好幫手 (使用者還是能安裝,但會看到棄用資訊)
npm deprecate <pkg>[@<version>] <message>:標記自己寫的某個套件為棄用,並提供棄用原因
npx
功能與 npm exec 完全相同,指令也完全相同
npm-run-all
一個開源小專案,使用 npm scripts 執行多個腳本時更方便。
- Simplify
- Before:
npm run clean && npm run build:css && npm run build:js && npm run build:html - After:
npm-run-all clean build:*
- Before:
- Cross platform
- 在 Linux 平台中,
&是非阻塞執行的,但在 Windows 平台是阻塞執行的。 run-p能確保腳本在 Windows 是非阻塞執行的。
- 在 Linux 平台中,
references
run-p | npm-run-all --parallel
- parallel 運行多個腳本
run-s | npm-run-all --sequential
- sequential 運行多個腳本
nvm
多版本 Node.js 環境
references
note
ls-remote 和 alias)nvm ls-remote (Linux)
列出遠端可用的 Node.js 版本
nvm list
列出本地可用的 Node.js 版本
nvm install
安裝指定 Node.js 版本
nvm alias (Linux)
nvm alias <alias> <version>:設定版本別名
nvm use
使用指定 Node.js 版本
nvm current
當前使用 Node.js 版本
pnpm
套件管理工具 (🔥大推) 有可能是當今地表上,綜合指標最頂尖的 package manager
references
note
PNPM_HOME需使用
pnpm setup 注入環境變數 PNPM_HOME (指定管理目錄路徑),才能使用 pnpm 指令Linux 管理目錄:
~/.local/share/pnpmWindows 管理目錄:
~\AppData\Local\pnpm管理目錄裡的 global 目錄,放的是全域套件,基本上同 npm 的全域安裝
管理目錄裡的 store 目錄,放的是區域套件,pnpm 會統一管理所有專案用到的套件
why
1. 省空間
每個專案都有各自的依賴套件,即便是依賴同種套件也可能是不同的版本,
這每種套件的每種版本,就會在 store 目錄被統一管理,
然後在專案內區域安裝時,
再藉由 hardlink 連結到 store 目錄中的套件用到的那些檔案即可。
稍微偷看一下裡面的目錄結構,大膽猜測原理應該類似 git object model 的概念,
以檔案做為基本單位 (object) 劃分,不同版本之間,只儲存有差異的檔案,
以此實現不重複儲存相同檔案的技術。
Q:你說使用 hardlink 會省空間,可是我打開目錄詳細資訊的所占空間還是很胖?
A:沒有騙人。舉個例子,圖書館有 5 本電子書 (檔案),
現在有 4 個人 (專案) 各自借了裡面的任意 3 本電子書,
每個人都會自稱它擁有 3 本電子書 (你在目錄詳細資訊看到的所占空間),
但實際上,圖書館並沒有 12 本電子書,而只有 5 本電子書 (真正所占空間)。
2. 省時間
詳見:benchmark
- npm
- pnpm
3. 非扁平化
專案內的 node_modules 目錄,採用非扁平化的方式管理,解決了 phantom dependency 的問題。

用 bottom-up 的脈絡化思考,更好理解 pnpm 的非扁平化是怎麼構建出來的。
- bar 和 foo 是兩個套件。
bar (v1.0.0) 是明確使用套件:也就是在 package.json 指定的套件。
foo (v1.0.0) 是依賴套件:也就是明確使用套件 (bar) 依賴的那些套件。├── foo └── bar - 為了讓 bar 看得到 foo (以及其他它需要的依賴套件),
我們要在最外層加一個 node_modules。
(複習:Node.js Module Resolution Algorithm)└── node_modules ├── foo └── bar - 這是針對 bar (v1.0.0) 的依賴情況,所以我們把它裝成一個目錄,
而每個套件都有它們的依賴情況,它們都需要一個目錄。└── bar@1.0.0 └── node_modules ├── foo └── bar (主角) - 這每個目錄就會被統一放在 .pnpm 目錄下,
所以說,專案所有套件都會放在 .pnpm 目錄底下。
(所有套件 = 明確使用套件 + 依賴套件).pnpm ├── foo@1.0.0 └── bar@1.0.0 └── node_modules ├── foo └── bar - 開始接硬連結 (foo 沒有依賴套件,可直接硬連結)
.pnpm ├── foo@1.0.0 -> <store>/foo@1.0.0 (hard link) └── bar@1.0.0 └── node_modules ├── foo └── bar -> <store>/bar@1.0.0 (hard link) - 然後接軟連結 (軟連結導向到硬連結,硬連結再導向真正的資源位置)
.pnpm ├── foo@1.0.0 -> <store>/foo@1.0.0 └── bar@1.0.0 └── node_modules ├── foo -> ../../foo@1.0.0 (symbolic link) └── bar -> <store>/bar@1.0.0 - 由於我們的明確使用套件只有 bar,
pnpm 為了避免 phantom dependency,
再外面又多包一層 node_modules (頂層目錄),
並只把明確使用套件放在頂層目錄供 import。node_modules ├── bar └── .pnpm ├── foo@1.0.0 -> <store>/foo@1.0.0 └── bar@1.0.0 └── node_modules ├── foo -> ../../foo@1.0.0 └── bar -> <store>/bar@1.0.0 - 最後一步,把明確使用套件的軟連結接上 (完工了!)
node_modules ├── bar -> ./.pnpm/bar@1.0.0/node_modules/bar (symbolic link) └── .pnpm ├── foo@1.0.0 -> <store>/foo@1.0.0 └── bar@1.0.0 └── node_modules ├── foo -> ../../foo@1.0.0 └── bar -> <store>/bar@1.0.0
pnpm add
安裝依賴套件。
同npm install <package>。
再次提醒,local 專案下載的套件會影響到 store 目錄。
--save-prod(-P)--save-dev(-D)--save-optional(-O)--save-peer
pnpm install
根據 package.json 安裝所有依賴套件。
--shamefully-hoist: 將所有依賴套件提升到頂層shamefully,呵呵,還不忘羞辱你這違反他們的宗旨
pnpm create
效果同
npm create
pnpm list
pnpm list: 列出依賴樹 (深度:0)pnpm list --depth 1: 列出依賴樹 (深度:1)pnpm list --depth Infinity: 列出依賴樹 (深度:無限)pnpm list <pkg>: 列出依賴樹上存在的某個指定套件 (深度:0)pnpm list <pkg> --depth 2: 列出依賴樹上存在的某個指定套件 (深度:2)
pnpm run
執行在 package.json 中指定的 scripts (npm scripts)。
同npm run <script-name>和pnpm <script-name>。
run 可省略<script-name> 可用 regex 指定多個腳本pnpm exec
執行不在 package.json 中指定的 scripts。
類似npm exec <script-name>,但無法暫時下載 (所以不能用--package選項)。
pnpm dlx
執行不在 package.json 中指定的 scripts。
類似npm exec <script-name>,但專門用來暫時下載。
pnpm dlx <command> [arg]...pnpm --package=<pkg> dlx <command> [arg]...
pnpm store
pnpm store add: 向 store 目錄新增套件 (套件不會安裝到 local 專案)pnpm store path:store 目錄位置pnpm store prune:清理 store 目錄中沒被任何專案引用到的套件 (orphan)
pnpm import
pnpm import:支援將以下幾種 lockfile 轉成 pnpm 可以辨識的pnpm-lock.yamlpackage-lock.jsonnpm-shrinkwrap.jsonyarn.lock
pnpm licenses
pnpm licenses list:列出所有用到的套件的 licenses
pnpm runtime
功能類似
nvm
pnpm runtime set <name> <version> -g:全域安裝某版本 JS runtime<name>可以是node、deno、bun等等<version>可以是具體版本號,也可以是lts
pnpm link
類似
npm link,使用 remove 移除
npm link: 為【當前目錄】進行 global 假安裝npm link <dir>: 為【指定目錄】進行 local 假安裝
pnpm approve-builds
install 相關的 hook 腳本,常被用於套件檢查主機相關資訊,用以修正安裝程序。
然而,它是有能力在主機上執行任何 shell 指令的,
也就是說無腦執行腳本,很容易被供應鏈攻擊。
因此在 pnpm 中,預設是不給執行這種腳本的。
若要允許執行,必須使用此指令手動放行。
pnpm dedupe
優化重複的依賴套件。
強制讓專案中所有「版本相容」 (比如lodash@^4.0.0和lodash@^4.1.0) 的依賴套件,
通通指向同一版本,以減少專案中的重複套件。
pnpm outdated
列出哪些依賴套件有新版本
pnpm why
pnpm why <pkg>:是哪些套件,把這個套件裝進專案的? (反向列出依賴樹)
pnpm search
pnpm search <query>:搜尋 npm registry 上的套件
pnpm docs
pnpm docs <pkg>:打開套件的官方文檔 (在其 package.json 的homepage欄位指定)
pnpm audit
安全性檢查,檢查專案中用到的套件是否有已知的安全漏洞
pnpm sbom
生成軟體物料清單 (Software Bill of Materials)
pnpm sbom --format spdx