JS 套件管理大師

Caution

🚨 CAUTION
Windows 使用 NTFS 檔案系統,
npm / pnpm 的軟連結在 Windows 平台是採用 JUNCTION 實現的,
也是一個類似軟連結的機制,
VScode 會判定這東西是軟連結,
但使用 Python 的 os.path.islink 是測不出來的。

Prerequisites

module resolution algorithm (node)

假設 node 要找一個第三方模組 art-template

1. 內建模組:若是內建模組名稱,加載模組
2. 本地模組:若是相對路徑或絕對路徑,加載模組
3. 第三方模組:若上面兩個都不是,那就是第三方了
  a. 尋找當前目錄中,有無 node_modules 目錄
    i. 沒有就往上一層找,退回步驟 3.a
  b. 尋找 node_modules 目錄中,有無 art-template 目錄
    i. 沒有就往上一層找,退回步驟 3.a
  c. 尋找 art-template 目錄中,有無 package.json
    i. 若無則預設 import art-template/index.js
    ii. 若連 index.js 也沒有,就往上一層找,退回步驟 3.a
  d. 尋找 package.json 內的 "main" key,其 value 將會是最終被 import 的檔案
    i. 若無則預設 import art-template/index.js
    ii. 若連 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

嵌套式的依賴結構。 每個套件都直接包含了各自使用的套件, 這會導致:

  1. 路徑長到靠北:node_modules/A/node_modules/C/node_modules...
  2. 一大堆重複安裝的套件

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
這與 Python pip 的套件管理不同,雖然它也是扁平式的沒錯,
pip 不會出現一個套件有兩個版本的情況
這也是因為 Node.js Module Resolution Algorithm 的特殊性,
所以 Node.js 的 npm 可以這樣玩,但 Python 的 pip 不能。

npm v5

package-lock.json (lockfile) 的出現

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

只記錄一些特別的

  • bin : key 表示 CLI 名稱,value 表示 JS 腳本
    • 全域安裝時,會在管理目錄頂層放 shell 腳本,它會執行你 value 指定的腳本 (管理目錄有註冊在 PATH 環境變數,也就是說 CLI 名稱是全域可見的)
    • 區域安裝時,會在 node_modules/bin 目錄放 shell 腳本,它會執行你 value 指定的腳本 (詳見 npm run)
    {
      "bin": {
        "q-cli": "./bin/q-cli.js"
      }
    }
    

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 插件包嘛。 wow-so-much-fun

首先:

  1. plugins (插件) 不能獨立運行,它必須倚靠 host (魔獸) 才能運行。
  2. 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"
  }
}

monorepo

待續...

npm

套件管理工具

references

note

📘 NOTE
Windows 管理目錄:~\AppData\Roaming\npm
🔮 IMPORTANT
使用 command line 執行 CLI 工具:能執行 global 安裝的 CLI 工具
使用 npm scripts 執行 CLI 工具:能執行 local 安裝的 CLI 工具

npm init | npm create

雖然它倆是 alias,但後兩個 case 比較常用 create

  • npm init:初始化專案,會創建 package.json
  • npm 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 --save-dev <pkg>:local 安裝、開發依賴套件
  • npm install --package-lock-only只產生 package-lock.json
    • 🔮 IMPORTANT:在僅使用 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 快取)

在測試發布套件時很好用

☢️ WARNING
不會處理依賴套件版本衝突問題
  • npm link : 在 global 套件中,放置一個當前目錄的軟連結 (假安裝)
    • 取消假安裝也很簡單,就是 uninstall
  • npm link <pkg> : global 安裝它 (若在 global 套件中找不到它),再以軟連結形式 local 安裝

npm login

登入 npm

npm publish

發布必須登入 npm

  • npm publish : 發布
  • npm publish --access=public : public 發布
🚨 CAUTION
npm 的 private 發布是付費功能,
注意 scoped package 預設是 private 發布
如果免費帳號使用預設是會發布失敗的,要手動指定成 public 發布。
☢️ WARNING
在 npm 發布套件後,無法再撤下套件的條件:npm unpublish policy

npx

功能與 npm exec 完全相同,指令也完全相同

note

📘 NOTE
Linux 暫存目錄:~/.npm/_npx
Windows 暫存目錄:~\AppData\Local\npm-cache\_npx

npm-run-all

一個開源小專案,使用 npm scripts 執行多個腳本時更方便。

  1. Simplify
  • Before: npm run clean && npm run build:css && npm run build:js && npm run build:html
  • After: npm-run-all clean build:*
  1. Cross platform
  • 在 Linux 平台中,& 是非阻塞執行的,但在 Windows 平台是阻塞執行的。
  • run-p 能確保腳本在 Windows 是非阻塞執行的。

references

run-p | npm-run-all --parallel

  • parallel 運行多個腳本

run-s | npm-run-all --sequential

  • sequential 運行多個腳本

nvm

多版本 Node.js 環境

references

note

🚨 CAUTION
Windows 版和 Linux 版的指令似乎不大一樣
Windows 版不能用 ls-remotealias QQ
🔮 IMPORTANT
每次開啟 Shell 時, nvm 會抓取 alias 為 default 的版本 (Linux)

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

🚨 CAUTION
初次使用 pnpm 時,要先用 pnpm setup 設定,
它會加一個 PNPM_HOME 的環境變數 (管理目錄路徑),接著重開 terminal 就可以用了
📘 NOTE
Linux 管理目錄:~/.local/share/pnpm
Windows 管理目錄:~\AppData\Local\pnpm
📘 NOTE
管理目錄裡的 global 目錄,放的是全域套件,基本上同 npm 的全域安裝
管理目錄裡的 store 目錄,放的是區域套件,pnpm 會統一管理所有專案用到的套件

Why pnpm?

1. 省空間

saving disk space - pnpm

🔮 IMPORTANT
每個專案都有各自的依賴套件,即便是依賴同種套件也可能是不同的版本,
每種套件的每種版本,就會在 store 目錄被統一管理
然後在專案內區域安裝時,
再藉由 hardlink 連結到 store 目錄中的套件用到的那些檔案即可。
稍微偷看一下裡面的目錄結構,大膽猜測原理應該類似 git object model 的概念,
以檔案做為基本單位 (object) 劃分,不同版本之間,只儲存有差異的檔案,
以此實現不重複儲存相同檔案的技術。
🔮 IMPORTANT
騙人!你說使用 hardlink 會省空間,可是我打開目錄詳細資訊的所占空間還是很胖耶!
沒有騙人。舉個例子,圖書館有 5 本電子書 (檔案),
現在有 4 個人 (專案) 各自借了裡面的任意 3 本電子書,
每個人都會自稱它擁有 3 本電子書 (你在目錄詳細資訊看到的所占空間),
但實際上,圖書館並沒有 12 本電子書,而只有 5 本電子書 (真正所占空間)。

2. 省時間

這裡有 benchmark,讓你比一比有多快

  • npm

boosting installation speed - npm

  • pnpm

boosting installation speed - pnpm

3. 非扁平化

專案內 (local) 的 node_modules 目錄,採用非扁平化的方式管理

project's node_modules

🔮 IMPORTANT
用 bottom-up 的脈絡化思考,更好理解為什麼 pnpm 要採用這種方式。
  1. bar 和 foo 是兩個套件。
    bar (v1.0.0) 是明確使用套件:也就是在 package.json 指定的套件。
    foo (v1.0.0) 是依賴套件:也就是明確使用套件 (bar) 依賴的那些套件。
    ├── foo
    └── bar
    
  2. 為了讓 bar 看得到 foo (以及其他它需要的依賴套件),
    我們要在最外層加一個 node_modules。
    (複習:Node.js Module Resolution Algorithm)
    └── node_modules
        ├── foo
        └── bar
    
  3. 這是針對 bar (v1.0.0) 的依賴情況,所以我們把它裝成一個目錄,
    每個套件都有它們的依賴情況,它們都需要一個目錄
    └── bar@1.0.0
        └── node_modules
            ├── foo
            └── bar (主角)
    
  4. 這每個目錄就會被統一放在 .pnpm 目錄下,
    所以說,專案所有套件都會放在 .pnpm 目錄底下
    (所有套件 = 明確使用套件 + 依賴套件)
    .pnpm
    ├── foo@1.0.0
    └── bar@1.0.0
        └── node_modules
            ├── foo
            └── bar
    
  5. 開始接硬連結 (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)
    
  6. 然後接軟連結 (軟連結導向到硬連結,硬連結再導向真正的資源位置)
    .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
    
  7. 由於我們的明確使用套件只有 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
    
  8. 最後一步,把明確使用套件的軟連結接上 (完工了!)
    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
    

其他:peer dependency 的處理方式

pnpm add

安裝依賴套件。 同 npm install <package>

🚨 CAUTION
再次提醒,local 專案下載的套件會影響到 store 目錄
📗 TIP
若 dev 誤裝成 prod,指定選項可自動移除並重裝一次
  • --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 --depth 1 : 列出依賴樹 (深度可加)

pnpm run

執行在 package.json 中指定的 scripts (npm scripts)。
npm run <script-name>pnpm <script-name>

📗 TIP
run 可以不用寫
📗 TIP
<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.yaml
    • package-lock.json
    • npm-shrinkwrap.json
    • yarn.lock

pnpm licenses

  • pnpm licenses list:列出所有用到的套件的 licenses

Publishing Package

  • pnpm unlink <pkg> : 移除 local 假安裝軟連結套件

pnpm remove

  • pnpm remove --global <pkg> : 移除 global 假安裝軟連結套件