2018 年了,你還是隻會 npm install 嗎?

rianma發表於2018-03-24

本文同步發表於作者部落格: 2018 年了,你還是隻會 npm install 嗎?

nodejs 社群乃至 Web 前端工程化領域發展到今天,作為 node 自帶的包管理工具的 npm 已經成為每個前端開發者必備的工具。但是現實狀況是,我們很多人對這個nodejs基礎設施的使用和了解還停留在: 會用 npm install 這裡(一言不合就刪除整個 node_modules 目錄然後重新 install 這種事你沒做過嗎?)

當然 npm 能成為現在世界上最大規模的包管理系統,很大程度上確實歸功於它足夠使用者友好,你看即使我只會執行 install 也不必太擔心出什麼大岔子. 但是 npm 的功能遠不止於 install 一下那麼簡單,這篇文章幫你扒一扒那些你可能不知道的 npm 原理、特性、技巧,以及(我認為的)最佳實踐。

你懶得讀的 npm 文件,我幫你翻譯然後試驗整理過來了???

1. npm init

我們都知道 package.json 檔案是用來定義一個 package 的描述檔案, 也知道npm init 命令用來初始化一個簡單的 package.json 檔案,執行該命令後終端會依次詢問 name, version, description 等欄位。

1.1 npm init 執行預設行為

而如果想要偷懶步免去一直按 enter,在命令後追加 --yes 引數即可,其作用與一路下一步相同。

npm init --yes

1.2 自定義 npm init 行為

npm init 命令的原理並不複雜,呼叫指令碼,輸出一個初始化的 package.json 檔案就是了。所以相應地,定製 npm init 命令的實現方式也很簡單,在 Home 目錄建立一個 .npm-init.js 即可,該檔案的 module.exports 即為 package.json 配置內容,需要獲取使用者輸入時候,使用 prompt() 方法即可。

例如編寫這樣的 ~/.npm-init.js

const desc = prompt('description?', 'A new package...')
const bar = prompt('bar?', '')
const count = prompt('count?', '42')

module.exports = {
  key: 'value',
  foo: {
    bar: bar,
    count: count
  },
  name: prompt('name?', process.cwd().split('/').pop()),
  version: prompt('version?', '0.1.0'),
  description: desc,
  main: 'index.js',
}
複製程式碼

此時在 ~/hello 目錄下執行 npm init 將會得到這樣的 package.json:

{
  "key": "value",
  "foo": {
    "bar": "",
    "count": "42"
  },
  "name": "hello",
  "version": "0.1.0",
  "description": "A new package...",
  "main": "index.js"
}
複製程式碼

除了生成 package.json, 因為 .npm-init.js 是一個常規的模組,意味著我們可以執行隨便什麼 node 指令碼可以執行的任務。例如通過 fs 建立 README, .eslintrc 等專案必需檔案,實現專案腳手架的作用。

2. 依賴包安裝

依賴管理是 npm 的核心功能,原理就是執行 npm install 從 package.json 中的 dependencies, devDependencies 將依賴包安裝到當前目錄的 ./node_modules 資料夾中。

2.1 package定義

我們都知道要手動安裝一個包時,執行 npm install <package> 命令即可。這裡的第三個引數 package 通常就是我們所要安裝的包名,預設配置下 npm 會從預設的源 (Registry) 中查詢該包名對應的包地址,並下載安裝。但在 npm 的世界裡,除了簡單的指定包名, package 還可以是一個指向有效包名的 http url/git url/資料夾路徑。

閱讀 npm的文件, 我們會發現package 準確的定義,只要符合以下 a) 到 g) 其中之一條件,就是一個 package:

# 說明 例子
a) 一個包含了程式和描述該程式的 package.json 檔案 的 資料夾 ./local-module/
b) 一個包含了 (a) 的 gzip 壓縮檔案 ./module.tar.gz
c) 一個可以下載得到 (b) 資源的 url (通常是 http(s) url) https://registry.npmjs.org/webpack/-/webpack-4.1.0.tgz
d) 一個格式為 <name>@<version> 的字串,可指向 npm 源(通常是官方源 npmjs.org)上已釋出的可訪問 url,且該 url 滿足條件 (c) webpack@4.1.0
e) 一個格式為 <name>@<tag> 的字串,在 npm 源上該<tag>指向某 <version> 得到 <name>@<version>,後者滿足條件 (d) webpack@latest
f) 一個格式為 <name> 的字串,預設新增 latest 標籤所得到的 <name>@latest 滿足條件 (e) webpack
g) 一個 git url, 該 url 所指向的程式碼庫滿足條件 (a) git@github.com:webpack/webpack.git

2.2 安裝本地包/遠端git倉庫包

上面表格的定義意味著,我們在共享依賴包時,並不是非要將包發表到 npm 源上才可以提供給使用者來安裝。這對於私有的不方便 publish 到遠端源(即使是私有源),或者需要對某官方源進行改造,但依然需要把包共享出去的場景來說非常實用。

場景1: 本地模組引用

nodejs 應用開發中不可避免有模組間呼叫,例如在實踐中經常會把需要被頻繁引用的配置模組放到應用根目錄;於是在建立了很多層級的目錄、檔案後,很可能會遇到這樣的程式碼:

const config = require('../../../../config.js');
複製程式碼

除了看上去很醜以外,這樣的路徑引用也不利於程式碼的重構。並且身為程式設計師的自我修養告訴我們,這樣重複的程式碼多了也就意味著是時候把這個模組分離出來供應用內其他模組共享了。例如這個例子裡的 config.js 非常適合封裝為 package 放到 node_modules 目錄下,共享給同應用內其他模組。

無需手動拷貝檔案或者建立軟連結到 node_modules 目錄,npm 有更優雅的解決方案。

方案:

  1. 建立 config 包:
    新增 config 資料夾; 重新命名 config.js 為 config/index.js 檔案; 建立 package.json 定義 config 包

    {
        "name": "config",
        "main": "index.js",
        "version": "0.1.0"
    }
    複製程式碼
  2. 在應用層 package.json 檔案中新增依賴項,然後執行 npm install; 或直接執行第 3 步

    {
        "dependencies": {
            "config": "file:./config"
        }
    }
    複製程式碼
  3. (等價於第 2 步)直接在應用目錄執行 npm install file:./config

    此時,檢視 node_modules 目錄我們會發現多出來一個名為 config,指向上層 config/ 資料夾的軟連結。這是因為 npm 識別 file: 協議的url,得知這個包需要直接從檔案系統中獲取,會自動建立軟連結到 node_modules 中,完成“安裝”過程。

    相比手動軟鏈,我們既不需要關心 windows 和 linux 命令差異,又可以顯式地將依賴資訊固化到 dependencies 欄位中,開發團隊其他成員可以執行 npm install 後直接使用。

場景2: 私有 git 共享 package

有些時候,我們一個團隊內會有一些程式碼/公用庫需要在團隊內不同專案間共享,但可能由於包含了敏感內容,或者程式碼太爛拿不出手等原因,不方便釋出到源。

這種情況下,我們可以簡單地將被依賴的包託管在私有的 git 倉庫中,然後將該 git url 儲存到 dependencies 中. npm 會直接呼叫系統的 git 命令從 git 倉庫拉取包的內容到 node_modules 中。

npm 支援的 git url 格式:

<protocol>://[<user>[:<password>]@]<hostname>[:<port>][:][/]<path>[#<commit-ish> | #semver:<semver>]
複製程式碼

git 路徑後可以使用 # 指定特定的 git branch/commit/tag, 也可以 #semver: 指定特定的 semver range.

例如:

git+ssh://git@github.com:npm/npm.git#v1.0.27
git+ssh://git@github.com:npm/npm#semver:^5.0
git+https://isaacs@github.com/npm/npm.git
git://github.com/npm/npm.git#v1.0.27
複製程式碼

場景3: 開源 package 問題修復

使用某個 npm 包時發現它有某個嚴重bug,但也許最初作者已不再維護程式碼了,也許我們工作緊急,沒有足夠的時間提 issue 給作者再慢慢等作者釋出新的修復版本到 npm 源。

此時我們可以手動進入 node_modules 目錄下修改相應的包內容,也許修改了一行程式碼就修復了問題。但是這種做法非常不明智!

首先 node_modules 本身不應該放進版本控制系統,對 node_modules 資料夾中內容的修改不會被記錄進 git 提交記錄;其次,就算我們非要反模式,把 node_modules 放進版本控制中,你的修改內容也很容易在下次 team 中某位成員執行 npm installnpm update 時被覆蓋,而這樣的一次提交很可能包含了幾十幾百個包的更新,你自己所做的修改很容易就被淹沒在龐大的 diff 檔案列表中了。

方案:

最好的辦法應當是 fork 原作者的 git 庫,在自己所屬的 repo 下修復問題後,將 dependencies 中相應的依賴項更改為自己修復後版本的 git url 即可解決問題。(Fork 程式碼庫後,也便於向原作者提交 PR 修復問題。上游程式碼庫修復問題後,再次更新我們的依賴配置也不遲。)

3. npm install 如何工作 —— node_modules 目錄結構

npm install 執行完畢後,我們可以在 node_modules 中看到所有依賴的包。雖然使用者無需關注這個目錄裡的資料夾結構細節,只管在業務程式碼中引用依賴包即可,但瞭解 node_modules 的內容可以幫我們更好理解 npm 如何工作,瞭解從 npm 2 到 npm 5 有哪些變化和改進。

為簡單起見,我們假設應用目錄為 app, 用兩個流行的包 webpack, nconf 作為依賴包做示例說明。並且為了正常安裝,使用了“上古” npm 2 時期的版本 webpack@1.15.0, nconf@0.8.5.

3.1 npm 2

npm 2 在安裝依賴包時,採用簡單的遞迴安裝方法。執行 npm install 後,npm 2 依次遞迴安裝 webpacknconf 兩個包到 node_modules 中。執行完畢後,我們會看到 ./node_modules 這層目錄只含有這兩個子目錄。

node_modules/
├── nconf/
└── webpack/
複製程式碼

進入更深一層 nconf 或 webpack 目錄,將看到這兩個包各自的 node_modules 中,已經由 npm 遞迴地安裝好自身的依賴包。包括 ./node_modules/webpack/node_modules/webpack-core , ./node_modules/conf/node_modules/async 等等。而每一個包都有自己的依賴包,每個包自己的依賴都安裝在了自己的 node_modules 中。依賴關係層層遞進,構成了一整個依賴樹,這個依賴樹與檔案系統中的檔案結構樹剛好層層對應。

最方便的檢視依賴樹的方式是直接在 app 目錄下執行 npm ls 命令。

app@0.1.0
├─┬ nconf@0.8.5
│ ├── async@1.5.2
│ ├── ini@1.3.5
│ ├── secure-keys@1.0.0
│ └── yargs@3.32.0
└─┬ webpack@1.15.0
  ├── acorn@3.3.0
  ├── async@1.5.2
  ├── clone@1.0.3
  ├── ...
  ├── optimist@0.6.1
  ├── supports-color@3.2.3
  ├── tapable@0.1.10
  ├── uglify-js@2.7.5
  ├── watchpack@0.2.9
  └─┬ webpack-core@0.6.9
    ├── source-list-map@0.1.8
    └── source-map@0.4.4
複製程式碼

這樣的目錄結構優點在於層級結構明顯,便於進行傻瓜式的管理:

  1. 例如新裝一個依賴包,可以立即在第一層 node_modules 中看到子目錄
  2. 在已知所需包名和版本號時,甚至可以從別的資料夾手動拷貝需要的包到 node_modules 資料夾中,再手動修改 package.json 中的依賴配置
  3. 要刪除這個包,也可以簡單地手動刪除這個包的子目錄,並刪除 package.json 檔案中相應的一行即可

實際上,很多人在 npm 2 時代也的確都這麼實踐過,的確也都可以安裝和刪除成功,並不會導致什麼差錯。

但這樣的檔案結構也有很明顯的問題:

  1. 對複雜的工程, node_modules 內目錄結構可能會太深,導致深層的檔案路徑過長而觸發 windows 檔案系統中,檔案路徑不能超過 260 個字元長的錯誤
  2. 部分被多個包所依賴的包,很可能在應用 node_modules 目錄中的很多地方被重複安裝。隨著工程規模越來越大,依賴樹越來越複雜,這樣的包情況會越來越多,造成大量的冗餘。

——在我們的示例中就有這個問題,webpacknconf 都依賴 async 這個包,所以在檔案系統中,webpack 和 nconf 的 node_modules 子目錄中都安裝了相同的 async 包,並且是相同的版本。

+-------------------------------------------+
|                   app/                    |
+----------+------------------------+-------+
           |                        |
           |                        |
+----------v------+       +---------v-------+
|                 |       |                 |
|  webpack@1.15.0 |       |  nconf@0.8.5    |
|                 |       |                 |
+--------+--------+       +--------+--------+
         |                         |
   +-----v-----+             +-----v-----+
   |async@1.5.2|             |async@1.5.2|
   +-----------+             +-----------+
複製程式碼

3.2 npm 3 - 扁平結構

主要為了解決以上問題,npm 3 的 node_modules 目錄改成了更加扁平狀的層級結構。檔案系統中 webpack, nconf, async 的層級關係變成了平級關係,處於同一級目錄中。

         +-------------------------------------------+
         |                   app/                    |
         +-+---------------------------------------+-+
           |                                       |
           |                                       |
+----------v------+    +-------------+   +---------v-------+
|                 |    |             |   |                 |
|  webpack@1.15.0 |    | async@1.5.2 |   |  nconf@0.8.5    |
|                 |    |             |   |                 |
+-----------------+    +-------------+   +-----------------+
複製程式碼

雖然這樣一來 webpack/node_modules 和 nconf/node_modules 中都不再有 async 資料夾,但得益於 node 的模組載入機制,他們都可以在上一級 node_modules 目錄中找到 async 庫。所以 webpack 和 nconf 的庫程式碼中 require('async') 語句的執行都不會有任何問題。

這只是最簡單的例子,實際的工程專案中,依賴樹不可避免地會有很多層級,很多依賴包,其中會有很多同名但版本不同的包存在於不同的依賴層級,對這些複雜的情況, npm 3 都會在安裝時遍歷整個依賴樹,計算出最合理的資料夾安裝方式,使得所有被重複依賴的包都可以去重安裝。

npm 文件提供了更直觀的例子解釋這種情況:

假如 package{dep} 寫法代表包和包的依賴,那麼 A{B,C}, B{C}, C{D} 的依賴結構在安裝之後的 node_modules 是這樣的結構:

A
+-- B
+-- C
+-- D
複製程式碼

這裡之所以 D 也安裝到了與 B C 同一級目錄,是因為 npm 會預設會在無衝突的前提下,儘可能將包安裝到較高的層級。

如果是 A{B,C}, B{C,D@1}, C{D@2} 的依賴關係,得到的安裝後結構是:

A
+-- B
+-- C
   `-- D@2
+-- D@1
複製程式碼

這裡是因為,對於 npm 來說同名但不同版本的包是兩個獨立的包,而同層不能有兩個同名子目錄,所以其中的 D@2 放到了 C 的子目錄而另一個 D@1 被放到了再上一層目錄。

很明顯在 npm 3 之後 npm 的依賴樹結構不再與資料夾層級一一對應了。想要檢視 app 的直接依賴項,要通過 npm ls 命令指定 --depth 引數來檢視:

npm ls --depth 1
複製程式碼

PS: 與本地依賴包不同,如果我們通過 npm install --global 全域性安裝包到全域性目錄時,得到的目錄依然是“傳統的”目錄結構。而如果使用 npm 3 想要得到“傳統”形式的本地 node_modules 目錄,使用 npm install --global-style 命令即可。

3.3 npm 5 - package-lock 檔案

npm 5 釋出於 2017 年也是目前最新的 npm 版本,這一版本依然沿用 npm 3 之後扁平化的依賴包安裝方式,此外最大的變化是增加了 package-lock.json 檔案。

package-lock.json 的作用是鎖定依賴安裝結構,如果檢視這個 json 的結構,會發現與 node_modules 目錄的檔案層級結構是一一對應的。

以依賴關係為: app{webpack} 的 'app' 專案為例, 其 package-lock 檔案包含了這樣的片段。

{
    "name":  "app",
    "version":  "0.1.0",
    "lockfileVersion":  1,
    "requires":  true,
    "dependencies": {
        // ... 其他依賴包
        "webpack": {
            "version": "1.8.11",
            "resolved": "https://registry.npmjs.org/webpack/-/webpack-1.8.11.tgz",
            "integrity": "sha1-Yu0hnstBy/qcKuanu6laSYtgkcI=",
            "requires": {
                "async": "0.9.2",
                "clone": "0.1.19",
                "enhanced-resolve": "0.8.6",
                "esprima": "1.2.5",
                "interpret": "0.5.2",
                "memory-fs": "0.2.0",
                "mkdirp": "0.5.1",
                "node-libs-browser": "0.4.3",
                "optimist": "0.6.1",
                "supports-color": "1.3.1",
                "tapable": "0.1.10",
                "uglify-js": "2.4.24",
                "watchpack": "0.2.9",
                "webpack-core": "0.6.9"
            }
        },
        "webpack-core": {
            "version": "0.6.9",
            "resolved": "https://registry.npmjs.org/webpack-core/-/webpack-core-0.6.9.tgz",
            "integrity": "sha1-/FcViMhVjad76e+23r3Fo7FyvcI=",
            "requires": {
                "source-list-map": "0.1.8",
                "source-map": "0.4.4"
            },
            "dependencies": {
                "source-map": {
                    "version": "0.4.4",
                    "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz",
                    "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=",
                    "requires": {
                        "amdefine": "1.0.1"
                    }
                }
            }
        },
        //... 其他依賴包
    }
}
複製程式碼

看懂 package-lock 檔案並不難,其結構是同樣型別的幾個欄位巢狀起來的,主要是 version, resolved, integrity, requires, dependencies 這幾個欄位而已。

  • version, resolved, integrity 用來記錄包的準確版本號、內容hash、安裝源的,決定了要安裝的包的準確“身份”資訊
  • 假設蓋住其他欄位,只關注檔案中的 dependencies: {} 我們會發現,整個檔案的 JSON 配置裡的 dependencies 層次結構與檔案系統中 node_modules 的資料夾層次結構是完全對照的
  • 只關注 requires: {} 欄位又會發現,除最外層的 requires 屬性為 true 以外, 其他層的 requires 屬性都對應著這個包的 package.json 裡記錄的自己的依賴項

因為這個檔案記錄了 node_modules 裡所有包的結構、層級和版本號甚至安裝源,它也就事實上提供了 “儲存” node_modules 狀態的能力。只要有這樣一個 lock 檔案,不管在那一臺機器上執行 npm install 都會得到完全相同的 node_modules 結果。

這就是 package-lock 檔案致力於優化的場景:在從前僅僅用 package.json 記錄依賴,由於 semver range 的機制;一個月前由 A 生成的 package.json 檔案,B 在一個月後根據它執行 npm install 所得到的 node_modules 結果很可能許多包都存在不同的差異,雖然 semver 機制的限制使得同一份 package.json 不會得到大版本不同的依賴包,但同一份程式碼在不同環境安裝出不同的依賴包,依然是可能導致意外的潛在因素。

相同作用的檔案在 npm 5 之前就有,稱為 npm shrinkwrap 檔案,二者作用完全相同,不同的是後者需要手動生成,而 npm 5 預設會在執行 npm install 後就生成 package-lock 檔案,並且建議你提交到 git/svn 程式碼庫中。

package-lock.json 檔案在最初 npm 5.0 預設引入時也引起了相當大的爭議。在 npm 5.0 中,如果已有 package-lock 檔案存在,若手動在 package.json 檔案新增一條依賴,再執行 npm install, 新增的依賴並不會被安裝到 node_modules 中, package-lock.json 也不會做相應的更新。這樣的表現與使用者的自然期望表現不符。在 npm 5.1 的首個 Release 版本中這個問題得以修復。這個事情告訴我們,要升級,不要使用 5.0。

——但依然有反對的聲音認為 package-lock 太複雜,對此 npm 也提供了禁用配置:

npm config set package-lock false
複製程式碼

4. 依賴包版本管理

依賴包安裝完並不意味著就萬事大吉了,版本的維護和更新也很重要。這一章介紹依賴包升級管理相關知識,太長不看版本請直接跳到 [4.3 最佳實踐].

4.1 semver

npm 依賴管理的一個重要特性是採用了語義化版本 (semver) 規範,作為依賴版本管理方案。

semver 約定一個包的版本號必須包含3個數字,格式必須為 MAJOR.MINOR.PATCH, 意為 主版本號.小版本號.修訂版本號.

  • MAJOR 對應大的版本號迭代,做了不相容舊版的修改時要更新 MAJOR 版本號
  • MINOR 對應小版本迭代,發生相容舊版API的修改或功能更新時,更新MINOR版本號
  • PATCH 對應修訂版本號,一般針對修復 BUG 的版本號

對於包作者(釋出者),npm 要求在 publish 之前,必須更新版本號。npm 提供了 npm version 工具,執行 npm version major|minor|patch 可以簡單地將版本號中相應的數字加1.

如果包是一個 git 倉庫,npm version 還會自動建立一條註釋為更新後版本號的 git commit 和名為該版本號的 tag

對於包的引用者來說,我們需要在 dependencies 中使用 semver 約定的 semver range 指定所需依賴包的版本號或版本範圍。npm 提供了網站 https://semver.npmjs.com 可方便地計算所輸入的表示式的匹配範圍。常用的規則示例如下表:

range 含義
^2.2.1 指定的 MAJOR 版本號下, 所有更新的版本 匹配 2.2.3, 2.3.0; 不匹配 1.0.3, 3.0.1
~2.2.1 指定 MAJOR.MINOR 版本號下,所有更新的版本 匹配 2.2.3, 2.2.9 ; 不匹配 2.3.0, 2.4.5
>=2.1 版本號大於或等於 2.1.0 匹配 2.1.2, 3.1
<=2.2 版本號小於或等於 2.2 匹配 1.0.0, 2.2.1, 2.2.11
1.0.0 - 2.0.0 版本號從 1.0.0 (含) 到 2.0.0 (含) 匹配 1.0.0, 1.3.4, 2.0.0

任意兩條規則,用空格連線起來,表示“與”邏輯,即兩條規則的交集:

>=2.3.1 <=2.8.0 可以解讀為: >=2.3.1<=2.8.0

  • 可以匹配 2.3.1, 2.4.5, 2.8.0
  • 但不匹配 1.0.0, 2.3.0, 2.8.1, 3.0.0

任意兩條規則,通過 || 連線起來,表示“或”邏輯,即兩條規則的並集:

^2 >=2.3.1 || ^3 >3.2

  • 可以匹配 2.3.1, 2,8.1, 3.3.1
  • 但不匹配 1.0.0, 2.2.0, 3.1.0, 4.0.0

PS: 除了這幾種,還有如下更直觀的表示版本號範圍的寫法:

  • *x 匹配所有主版本
  • 11.x 匹配 主版本號為 1 的所有版本
  • 1.21.2.x 匹配 版本號為 1.2 開頭的所有版本

PPS: 在常規僅包含數字的版本號之外,semver 還允許在 MAJOR.MINOR.PATCH 後追加 - 後跟點號分隔的標籤,作為預釋出版本標籤 - Prerelese Tags,通常被視為不穩定、不建議生產使用的版本。例如:

  • 1.0.0-alpha
  • 1.0.0-beta.1
  • 1.0.0-rc.3

上表中我們最常見的是 ^1.8.11 這種格式的 range, 因為我們在使用 npm install <package name> 安裝包時,npm 預設安裝當前最新版本,例如 1.8.11, 然後在所安裝的版本號前加^號, 將 ^1.8.11 寫入 package.json 依賴配置,意味著可以匹配 1.8.11 以上,2.0.0 以下的所有版本。

4.2 依賴版本升級

問題來了,在安裝完一個依賴包之後有新版本釋出了,如何使用 npm 進行版本升級呢?——答案是簡單的 npm installnpm update,但在不同的 npm 版本,不同的 package.json, package-lock.json 檔案,安裝/升級的表現也不同。

我們不妨還以 webpack 舉例,做如下的前提假設:

  • 我們的工程專案 app 依賴 webpack
  • 專案最初初始化時,安裝了當時最新的包 webpack@1.8.0,並且 package.json 中的依賴配置為: "webpack": "^1.8.0"
  • 當前(2018年3月) webpack 最新版本為 4.2.0, webpack 1.x 最新子版本為 1.15.0

如果我們使用的是 npm 3, 並且專案不含 package-lock.json, 那麼根據 node_modules 是否為空,執行 install/update 的結果如下 (node 6.13.1, npm 3.10.10 環境下試驗):

# package.json (BEFORE) node_modules (BEFORE) command (npm 3) package.json (AFTER) node_modules (AFTER)
a) webpack: ^1.8.0 webpack@1.8.0 install webpack: ^1.8.0 webpack@1.8.0
b) webpack: ^1.8.0 install webpack: ^1.8.0 webpack@1.15.0
c) webpack: ^1.8.0 webpack@1.8.0 update webpack: ^1.8.0 webpack@1.15.0
d) webpack: ^1.8.0 update webpack: ^1.8.0 webpack@1.15.0

根據這個表我們可以對 npm 3 得出以下結論:

  • 如果本地 node_modules 已安裝,再次執行 install 不會更新包版本, 執行 update 才會更新; 而如果本地 node_modules 為空時,執行 install/update 都會直接安裝更新包;
  • npm update 總是會把包更新到符合 package.json 中指定的 semver 的最新版本號——本例中符合 ^1.8.0 的最新版本為 1.15.0
  • 一旦給定 package.json, 無論後面執行 npm install 還是 update, package.json 中的 webpack 版本一直頑固地保持 一開始的 ^1.8.0 巋然不動

這裡不合理的地方在於,如果最開始團隊中第一個人安裝了 webpack@1.8.0, 而新加入專案的成員, checkout 工程程式碼後執行 npm install 會安裝得到不太一樣的 1.15.0 版本。雖然 semver 約定了小版本號應當保持向下相容(相同大版本號下的小版本號)相容,但萬一有不熟悉不遵循此約定的包釋出者,釋出了不相容的包,此時就可能出現因依賴環境不同導致的 bug。

下面由 npm 5 帶著 package-lock.json 閃亮登場,執行 install/update 的效果是這樣的 (node 9.8.0, npm 5.7.1 環境下試驗):

下表為表述簡單,省略了包名 webpack, install 簡寫 i, update 簡寫為 up

# package.json (BEFORE) node_modules (BEFORE) package-lock (BEFORE) command package.json (AFTER) node_modules (AFTER)
a) ^1.8.0 @1.8.0 @1.8.0 i ^1.8.0 @1.8.0
b) ^1.8.0 @1.8.0 i ^1.8.0 @1.8.0
c) ^1.8.0 @1.8.0 @1.8.0 up ^1.15.0 @1.15.0
d) ^1.8.0 @1.8.0 up ^1.8.0 @1.15.0
e) ^1.15.0 @1.8.0 (舊) @1.15.0 i ^1.15.0 @1.15.0
f) ^1.15.0 @1.8.0 (舊) @1.15.0 up ^1.15.0 @1.15.0

與 npm 3 相比,在安裝和更新依賴版本上主要的區別為:

  • 無論何時執行 install, npm 都會優先按照 package-lock 中指定的版本來安裝 webpack; 避免了 npm 3 表中情形 b) 的狀況;
  • 無論何時完成安裝/更新, package-lock 檔案總會跟著 node_modules 更新 —— (因此可以視 package-lock 檔案為 node_modules 的 JSON 表述)
  • 已安裝 node_modules 後若執行 npm update,package.json 中的版本號也會隨之更改為 ^1.15.0

由此可見 npm 5.1 使得 package.json 和 package-lock.json 中所儲存的版本號更加統一,解決了 npm 之前的各種問題。只要遵循好的實踐習慣,團隊成員可以很方便地維護一套應用程式碼和 node_modules 依賴都一致的環境。

皆大歡喜。

4.3 最佳實踐

總結起來,在 2018 年 (node 9.8.0, npm 5.7.1) 時代,我認為的依賴版本管理應當是:

  • 使用 npm: >=5.1 版本, 保持 package-lock.json 檔案預設開啟配置

  • 初始化:第一作者初始化專案時使用 npm install <package> 安裝依賴包, 預設儲存 ^X.Y.Z 依賴 range 到 package.json中; 提交 package.json, package-lock.json, 不要提交 node_modules 目錄

  • 初始化:專案成員首次 checkout/clone 專案程式碼後,執行一次 npm install 安裝依賴包

  • 不要手動修改 package-lock.json

  • 升級依賴包:

    • 升級小版本: 本地執行 npm update 升級到新的小版本
    • 升級大版本: 本地執行 npm install <package-name>@<version> 升級到新的大版本
    • 也可手動修改 package.json 中版本號為要升級的版本(大於現有版本號)並指定所需的 semver, 然後執行 npm install
    • 本地驗證升級後新版本無問題後,提交新的 package.json, package-lock.json 檔案
  • 降級依賴包:

    • 正確: npm install <package-name>@<old-version> 驗證無問題後,提交 package.json 和 package-lock.json 檔案
    • 錯誤: 手動修改 package.json 中的版本號為更低版本的 semver, 這樣修改並不會生效,因為再次執行 npm install 依然會安裝 package-lock.json 中的鎖定版本
  • 刪除依賴包:

    • Plan A: npm uninstall <package> 並提交 package.jsonpackage-lock.json
    • Plan B: 把要解除安裝的包從 package.json 中 dependencies 欄位刪除, 然後執行 npm install 並提交 package.jsonpackage-lock.json
  • 任何時候有人提交了 package.json, package-lock.json 更新後,團隊其他成員應在 svn update/git pull 拉取更新後執行 npm install 指令碼安裝更新後的依賴包

恭喜你終於可以跟 rm -rf node_modules && npm install 這波操作說拜拜了(其實並不會)

5. npm scripts

5.1 基本使用

npm scripts 是 npm 另一個很重要的特性。通過在 package.json 中 scripts 欄位定義一個指令碼,例如:

{
    "scripts": {
        "echo": "echo HELLO WORLD"
    }
}
複製程式碼

我們就可以通過 npm run echo 命令來執行這段指令碼,像在 shell 中執行該命令 echo HELLO WORLD 一樣,看到終端輸出 HELLO WORLD.

—— npm scripts 的基本使用就是這麼簡單,它提供了一個簡單的介面用來呼叫工程相關的指令碼。關於更詳細的相關資訊,可以參考阮一峰老師的文章 npm script 使用指南 (2016年10月).

簡要總結阮老師文章內容:

  1. npm run 命令執行時,會把 ./node_modules/.bin/ 目錄新增到執行環境的 PATH 變數中,因此如果某個命令列包未全域性安裝,而只安裝在了當前專案的 node_modules 中,通過 npm run 一樣可以呼叫該命令。
  2. 執行 npm 指令碼時要傳入引數,需要在命令後加 -- 標明, 如 npm run test -- --grep="pattern" 可以將 --grep="pattern" 引數傳給 test 命令
  3. npm 提供了 pre 和 post 兩種鉤子機制,可以定義某個指令碼前後的執行指令碼
  4. 執行時變數:在 npm run 的指令碼執行環境內,可以通過環境變數的方式獲取許多執行時相關資訊,以下都可以通過 process.env 物件訪問獲得:
    • npm_lifecycle_event - 正在執行的指令碼名稱
    • npm_package_<key> - 獲取當前包 package.json 中某個欄位的配置值:如 npm_package_name 獲取包名
    • npm_package_<key>_<sub-key> - package.json 中巢狀欄位屬性:如 npm_pacakge_dependencies_webpack 可以獲取到 package.json 中的 dependencies.webpack 欄位的值,即 webpack 的版本號

5.2 node_modules/.bin 目錄

上面所說的 node_modules/.bin 目錄,儲存了依賴目錄中所安裝的可供呼叫的命令列包。

何謂命令列包?例如 webpack 就屬於一個命令列包。如果我們在安裝 webpack 時新增 --global 引數,就可以在終端直接輸入 webpack 進行呼叫。但如果不加 --global 引數,我們會在 node_modules/.bin 目錄裡看到名為 webpack 的檔案,如果在終端直接輸入 ./node_modules/.bin/webpack 命令,一樣可以執行。

這是因為 webpackpackage.json 檔案中定義了 bin 欄位為:

{
    "bin": {
        "webpack": "./bin/webpack.js"
    }
}
複製程式碼

bin 欄位的配置格式為: <command>: <file>, 即 命令名: 可執行檔案. npm 執行 install 時,會分析每個依賴包的 package.json 中的 bin 欄位,並將其包含的條目安裝到 ./node_modules/.bin 目錄中,檔名為 <command>。而如果是全域性模式安裝,則會在 npm 全域性安裝路徑的 bin 目錄下建立指向 <file> 名為 <command> 的軟鏈。因此,./node_modules/.bin/webpack 檔案在通過命令列呼叫時,實際上就是在執行 node ./node_modules/.bin/webpack.js 命令。

正如上一節所說,npm run 命令在執行時會把 ./node_modules/.bin 加入到 PATH 中,使我們可直接呼叫所有提供了命令列呼叫介面的依賴包。所以這裡就引出了一個最佳實踐:

將專案依賴的命令列工具安裝到專案依賴資料夾中,然後通過 npm scripts 呼叫;而非全域性安裝

舉例而言 webpack 作為前端工程標配的構建工具,雖然我們都習慣了全域性安裝並直接使用命令列呼叫,但不同的專案依賴的 webpack 版本可能不同,相應的 webpack.config.js 配置檔案也可能只相容了特定版本的 webpack. 如果我們僅全域性安裝了最新的 webpack 4.x 並使用 webpack 命令呼叫,在一個依賴 webpack 3.x 的工程中就會無法成功執行構建。

但如果這類工具總是本地安裝,我們要呼叫一個命令,要手動新增 ./node_modules/.bin 這個長長的字首,未免也太麻煩了,我們 nodejs 開發者都很懶的。於是 npm 從5.2 開始自帶了一個新的工具 npx.

5.3 npx

npx 的使用很簡單,就是執行 npx <command> 即可,這裡的 <command> 預設就是 ./node_modules 目錄中安裝的可執行指令碼名。例如上面本地安裝好的 webpack 包,我們可以直接使用 npx webpack 執行即可。

除了這種最簡單的場景, npm cli 團隊開發者 Kat Marchán 還在這篇文章中介紹了其他幾種 npx 的神奇用法: Introducing npx: an npm package runner, 國內有位開發者 robin.law 將原文翻譯為中文 npx是什麼,為什麼需要npx?.

有興趣的可以戳連結瞭解,懶得點連結的,看總結:

場景a) 一鍵執行遠端 npm 源的二進位制包

除了在 package 中執行 ./node_modules/.bin 中已安裝的命令, 還可以直接指定未安裝的二進位制包名執行。例如我們在一個沒有 package.json 也沒有 node_modules 的目錄下,執行:

npx cowsay hello
複製程式碼

npx 將會從 npm 源下載 cowsay 這個包(但並不安裝)並執行:

 _______ 
< hello >
 ------- 
        \   ^__^
         \  (oo)\_______
            (__)\       )\/\
                ||----w |
                ||     ||
複製程式碼

這種用途非常適合 1. 在本地簡單測試或除錯 npm 源上這些二進位制包的功能;2. 呼叫 create-react-app 或 yeoman 這類往往每個專案只需要使用一次的腳手架工具

PS: 此處有彩蛋,執行這條命令試試:

npx workin-hard
複製程式碼

場景b) 一鍵執行 GitHub Gist

還記得前面提到的 [2.1 package定義] 麼,npm install <package> 可以是包含了有效 package.json 的 git url.

剛好 GitHub Gist 也是 git 倉庫 的一種,集合 npx 就可以方便地將簡單的指令碼共享給其他人,擁有該連結的人無需將指令碼安裝到本地工作目錄即可執行。將 package.json 和 需執行的二進位制指令碼上傳至 gist, 在執行 npx <gist url> 就可以方便地執行該 gist 定義的命令。

原文作者 Kat Marchán 提供了這個示例 gist, 執行:

npx https://gist.github.com/zkat/4bc19503fe9e9309e2bfaa2c58074d32
複製程式碼

可得到一個來自 GitHubGist 的 hello world 問候。

場景c) 使用不同版本 node 執行命令

將 npx 與 Aria Stewart 建立的 node 包 (https://www.npmjs.com/package/node) 結合,可以實現在一行命令中使用指定版本的 node 執行命令。

例如先後執行:

npx node@4 -e "console.log(process.version)"
npx node@6 -e "console.log(process.version)"
複製程式碼

將分別輸出 v4.8.7v6.13.0.

往常這種工作是由 nvm 這類 node 版本管理工具來做的,但 npx node@4 這種方式免去 nvm 手動切換配置的步驟,更加簡潔簡單。

6. npm 配置

6.1 npm config

npm cli 提供了 npm config 命令進行 npm 相關配置,通過 npm config ls -l 可檢視 npm 的所有配置,包括預設配置。npm 文件頁為每個配置項提供了詳細的說明 https://docs.npmjs.com/misc/config .

修改配置的命令為 npm config set <key> <value>, 我們使用相關的常見重要配置:

  • proxy, https-proxy: 指定 npm 使用的代理
  • registry 指定 npm 下載安裝包時的源,預設為 https://registry.npmjs.org/ 可以指定為私有 Registry 源
  • package-lock 指定是否預設生成 package-lock 檔案,建議保持預設 true
  • save true/false 指定是否在 npm install 後儲存包為 dependencies, npm 5 起預設為 true

刪除指定的配置項命令為 npm config delete <key>.

6.2 npmrc 檔案

除了使用 CLI 的 npm config 命令顯示更改 npm 配置,還可以通過 npmrc 檔案直接修改配置。

這樣的 npmrc 檔案優先順序由高到低包括:

  • 工程內配置檔案: /path/to/my/project/.npmrc
  • 使用者級配置檔案: ~/.npmrc
  • 全域性配置檔案: $PREFIX/etc/npmrc (即npm config get globalconfig 輸出的路徑)
  • npm內建配置檔案: /path/to/npm/npmrc

通過這個機制,我們可以方便地在工程跟目錄建立一個 .npmrc 檔案來共享需要在團隊間共享的 npm 執行相關配置。比如如果我們在公司內網環境下需通過代理才可訪問 registry.npmjs.org 源,或需訪問內網的 registry, 就可以在工作專案下新增 .npmrc 檔案並提交程式碼庫。

proxy = http://proxy.example.com/
https-proxy = http://proxy.example.com/
registry = http://registry.example.com/
複製程式碼

因為專案級 .npmrc 檔案的作用域只在本專案下,所以在非本目錄下,這些配置並不生效。對於使用筆記本工作的開發者,可以很好地隔離公司的工作專案、在家學習研究專案兩種不同的環境。

將這個功能與 ~/.npm-init.js 配置相結合,可以將特定配置的 .npmrc 跟 .gitignore, README 之類檔案一起做到 npm init 腳手架中,進一步減少手動配置。

6.3 node 版本約束

雖然一個專案的團隊都共享了相同的程式碼,但每個人的開發機器可能安裝了不同的 node 版本,此外伺服器端的也可能與本地開發機不一致。

這又是一個可能帶來不一致性的因素 —— 但也不是很難解決,宣告式約束+指令碼限制即可。

宣告:通過 package.jsonengines 屬性宣告應用執行所需的版本執行時要求。例如我們的專案中使用了 async, await 特性,查閱相容性表格得知最低支援版本為 7.6.0,因此指定 engines 配置為:

{
    "engines": { "node": ">=7.6.0"}
}
複製程式碼

強約束(可選):在 npm 中以上欄位內容僅作為建議欄位使用,若要在私有專案中新增強約束,需要自己寫指令碼鉤子,讀取並解析 engines 欄位的 semver range 並與執行時環境做對比校驗並適當提醒。

7. 小結 npm 最佳實踐

  • 使用 npm-init 初始化新專案
  • 統一專案配置: 需團隊共享的 npm config 配置項,固化到 .npmrc 檔案中
  • 統一執行環境,統一 package.json,統一 package-lock 檔案
  • 合理使用多樣化的源安裝依賴包: npm install <git url>|<local file>
  • 使用 npm: >=5.2 版本
  • 使用 npm scripts 與 npx (npm: >=5.2) 指令碼管理應用相關指令碼

8. 更多資料

參考

文件

延伸閱讀

相關文章