npm install 原理分析

  • 2019 年 12 月 18 日
  • 筆記

這是ConardLi的第 71 篇原創,謝謝你的支援!

開門見山,npm install 大概會經過上面的幾個流程,本篇文章來講一講各個流程的實現細節、發展以及為何要這樣實現。

嵌套結構

我們都知道,執行 npm install 後,依賴包被安裝到了 node_modules ,下面我們來具體了解下,npm 將依賴包安裝到 node_modules 的具體機制是什麼。

npm 的早期版本, npm 處理依賴的方式簡單粗暴,以遞歸的形式,嚴格按照 package.json 結構以及子依賴包的 package.json 結構將依賴安裝到他們各自的 node_modules 中。直到有子依賴包不在依賴其他模組。

舉個例子,我們的模組 my-app 現在依賴了兩個模組:bufferignore

{    "name": "my-app",    "dependencies": {      "buffer": "^5.4.3",      "ignore": "^5.1.4",    }  }

ignore是一個純 JS 模組,不依賴任何其他模組,而 buffer 又依賴了下面兩個模組:base64-jsieee754

{    "name": "buffer",    "dependencies": {      "base64-js": "^1.0.2",      "ieee754": "^1.1.4"    }  }

那麼,執行 npm install 後,得到的 node_modules 中模組目錄結構就是下面這樣的:

這樣的方式優點很明顯, node_modules 的結構和 package.json 結構一一對應,層級結構明顯,並且保證了每次安裝目錄結構都是相同的。

但是,試想一下,如果你依賴的模組非常之多,你的 node_modules 將非常龐大,嵌套層級非常之深:

  • 在不同層級的依賴中,可能引用了同一個模組,導致大量冗餘。
  • Windows 系統中,文件路徑最大長度為260個字元,嵌套層級過深可能導致不可預知的問題。

扁平結構

為了解決以上問題,NPM3.x 版本做了一次較大更新。其將早期的嵌套結構改為扁平結構:

  • 安裝模組時,不管其是直接依賴還是子依賴的依賴,優先將其安裝在 node_modules 根目錄。

還是上面的依賴結構,我們在執行 npm install 後將得到下面的目錄結構:

此時我們若在模組中又依賴了 [email protected] 版本:

{    "name": "my-app",    "dependencies": {      "buffer": "^5.4.3",      "ignore": "^5.1.4",      "base64-js": "1.0.1",    }  }
  • 當安裝到相同模組時,判斷已安裝的模組版本是否符合新模組的版本範圍,如果符合則跳過,不符合則在當前模組的 node_modules 下安裝該模組。

此時,我們在執行 npm install 後將得到下面的目錄結構:

對應的,如果我們在項目程式碼中引用了一個模組,模組查找流程如下:

  • 在當前模組路徑下搜索
  • 在當前模組 node_modules 路徑下搜素
  • 在上級模組的 node_modules 路徑下搜索
  • 直到搜索到全局路徑中的 node_modules

假設我們又依賴了一個包 buffer2@^5.4.3,而它依賴了包 [email protected],則此時的安裝結構是下面這樣的:

所以 npm 3.x 版本並未完全解決老版本的模組冗餘問題,甚至還會帶來新的問題。

試想一下,你的APP假設沒有依賴 [email protected] 版本,而你同時依賴了依賴不同 base64-js 版本的 bufferbuffer2。由於在執行 npm install 的時候,按照 package.json 里依賴的順序依次解析,則 bufferbuffer2package.json 的放置順序則決定了 node_modules 的依賴結構:

先依賴buffer2

先依賴buffer

另外,為了讓開發者在安全的前提下使用最新的依賴包,我們在 package.json 通常只會鎖定大版本,這意味著在某些依賴包小版本更新後,同樣可能造成依賴結構的改動,依賴結構的不確定性可能會給程式帶來不可預知的問題。

Lock文件

為了解決 npm install 的不確定性問題,在 npm 5.x 版本新增了 package-lock.json 文件,而安裝方式還沿用了 npm 3.x 的扁平化的方式。

package-lock.json 的作用是鎖定依賴結構,即只要你目錄下有 package-lock.json 文件,那麼你每次執行 npm install 後生成的 node_modules 目錄結構一定是完全相同的。

例如,我們有如下的依賴結構:

{    "name": "my-app",    "dependencies": {      "buffer": "^5.4.3",      "ignore": "^5.1.4",      "base64-js": "1.0.1",    }  }

在執行 npm install 後生成的 package-lock.json 如下:

{    "name": "my-app",    "version": "1.0.0",    "dependencies": {      "base64-js": {        "version": "1.0.1",        "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.0.1.tgz",        "integrity": "sha1-aSbRsZT7xze47tUTdW3i/Np+pAg="      },      "buffer": {        "version": "5.4.3",        "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.4.3.tgz",        "integrity": "sha512-zvj65TkFeIt3i6aj5bIvJDzjjQQGs4o/sNoezg1F1kYap9Nu2jcUdpwzRSJTHMMzG0H7bZkn4rNQpImhuxWX2A==",        "requires": {          "base64-js": "^1.0.2",          "ieee754": "^1.1.4"        },        "dependencies": {          "base64-js": {            "version": "1.3.1",            "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz",            "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g=="          }        }      },      "ieee754": {        "version": "1.1.13",        "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz",        "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg=="      },      "ignore": {        "version": "5.1.4",        "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.4.tgz",        "integrity": "sha512-MzbUSahkTW1u7JpKKjY7LCARd1fU5W2rLdxlM4kdkayuCwZImjkpluF9CM1aLewYJguPDqewLam18Y6AU69A8A=="      }    }  }

我們來具體看看上面的結構:

最外面的兩個屬性 nameversionpackage.json 中的 nameversion ,用於描述當前包名稱和版本。

dependencies 是一個對象,對象和 node_modules 中的包結構一一對應,對象的 key 為包名稱,值為包的一些描述資訊:

  • version:包版本 —— 這個包當前安裝在 node_modules 中的版本
  • resolved:包具體的安裝來源
  • integrity:包 hash 值,基於 Subresource Integrity 來驗證已安裝的軟體包是否被改動過、是否已失效
  • requires:對應子依賴的依賴,與子依賴的 package.jsondependencies的依賴項相同。
  • dependencies:結構和外層的 dependencies 結構相同,存儲安裝在子依賴 node_modules 中的依賴包。

這裡注意,並不是所有的子依賴都有 dependencies 屬性,只有子依賴的依賴和當前已安裝在根目錄的 node_modules 中的依賴衝突之後,才會有這個屬性。

例如,回顧下上面的依賴關係:

我們在 my-app 中依賴的 [email protected] 版本與 buffer 中依賴的 base64-js@^1.0.2 發生衝突,所以 [email protected] 需要安裝在 buffer 包的 node_modules 中,對應了 package-lock.jsonbufferdependencies 屬性。這也對應了 npm 對依賴的扁平化處理方式。

所以,根據上面的分析, package-lock.json 文件 和 node_modules 目錄結構是一一對應的,即項目目錄下存在 package-lock.json 可以讓每次安裝生成的依賴目錄結構保持相同。

另外,項目中使用了 package-lock.json 可以顯著加速依賴安裝時間。

我們使用 npm i --timing=true --loglevel=verbose 命令可以看到 npm install 的完整過程,下面我們來對比下使用 lock 文件和不使用 lock 文件的差別。在對比前先清理下npm 快取。

不使用 lock 文件:

使用 lock 文件:

可見, package-lock.json 中已經快取了每個包的具體版本和下載鏈接,不需要再去遠程倉庫進行查詢,然後直接進入文件完整性校驗環節,減少了大量網路請求。

使用建議

開發系統應用時,建議把 package-lock.json 文件提交到程式碼版本倉庫,從而保證所有團隊開發者以及 CI 環節可以在執行 npm install 時安裝的依賴版本都是一致的。

在開發一個 npm包 時,你的 npm包 是需要被其他倉庫依賴的,由於上面我們講到的扁平安裝機制,如果你鎖定了依賴包版本,你的依賴包就不能和其他依賴包共享同一 semver 範圍內的依賴包,這樣會造成不必要的冗餘。所以我們不應該把package-lock.json 文件發布出去( npm 默認也不會把 package-lock.json 文件發布出去)。

快取

在執行 npm installnpm update命令下載依賴後,除了將依賴包安裝在node_modules 目錄下外,還會在本地的快取目錄快取一份。

通過 npm config get cache 命令可以查詢到:在 LinuxMac 默認是用戶主目錄下的 .npm/_cacache 目錄。

在這個目錄下又存在兩個目錄:content-v2index-v5content-v2 目錄用於存儲 tar包的快取,而index-v5目錄用於存儲tar包的 hash

npm 在執行安裝時,可以根據 package-lock.json 中存儲的 integrity、version、name 生成一個唯一的 key 對應到 index-v5 目錄下的快取記錄,從而找到 tar包的 hash,然後根據 hash 再去找快取的 tar包直接使用。

我們可以找一個包在快取目錄下搜索測試一下,在 index-v5 搜索一下包路徑:

grep "https://registry.npmjs.org/base64-js/-/base64-js-1.0.1.tgz" -r index-v5

然後我們將json格式化:

{    "key": "pacote:version-manifest:https://registry.npmjs.org/base64-js/-/base64-js-1.0.1.tgz:sha1-aSbRsZT7xze47tUTdW3i/Np+pAg=",    "integrity": "sha512-C2EkHXwXvLsbrucJTRS3xFHv7Mf/y9klmKDxPTE8yevCoH5h8Ae69Y+/lP+ahpW91crnzgO78elOk2E6APJfIQ==",    "time": 1575554308857,    "size": 1,    "metadata": {      "id": "[email protected]",      "manifest": {        "name": "base64-js",        "version": "1.0.1",        "engines": {          "node": ">= 0.4"        },        "dependencies": {},        "optionalDependencies": {},        "devDependencies": {          "standard": "^5.2.2",          "tape": "4.x"        },        "bundleDependencies": false,        "peerDependencies": {},        "deprecated": false,        "_resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.0.1.tgz",        "_integrity": "sha1-aSbRsZT7xze47tUTdW3i/Np+pAg=",        "_shasum": "6926d1b194fbc737b8eed513756de2fcda7ea408",        "_shrinkwrap": null,        "bin": null,        "_id": "[email protected]"      },      "type": "finalized-manifest"    }  }

上面的 _shasum 屬性 6926d1b194fbc737b8eed513756de2fcda7ea408 即為 tar 包的 hashhash的前幾位 6926 即為快取的前兩層目錄,我們進去這個目錄果然找到的壓縮後的依賴包:

以上的快取策略是從 npm v5 版本開始的,在 npm v5 版本之前,每個快取的模組在 ~/.npm 文件夾中以模組名的形式直接存儲,儲存結構是{cache}/{name}/{version}。

npm 提供了幾個命令來管理快取數據:

  • npm cache add:官方解釋說這個命令主要是 npm 內部使用,但是也可以用來手動給一個指定的 package 添加快取。
  • npm cache clean:刪除快取目錄下的所有數據,為了保證快取數據的完整性,需要加上 --force 參數。
  • npm cache verify:驗證快取數據的有效性和完整性,清理垃圾數據。

基於快取數據,npm 提供了離線安裝模式,分別有以下幾種:

  • --prefer-offline:優先使用快取數據,如果沒有匹配的快取數據,則從遠程倉庫下載。
  • --prefer-online:優先使用網路數據,如果網路數據請求失敗,再去請求快取數據,這種模式可以及時獲取最新的模組。
  • --offline:不請求網路,直接使用快取數據,一旦快取數據不存在,則安裝失敗。

文件完整性

上面我們多次提到了文件完整性,那麼什麼是文件完整性校驗呢?

在下載依賴包之前,我們一般就能拿到 npm 對該依賴包計算的 hash 值,例如我們執行 npm info 命令,緊跟 tarball(下載鏈接) 的就是 shasum(hash) :

用戶下載依賴包到本地後,需要確定在下載過程中沒有出現錯誤,所以在下載完成之後需要在本地在計算一次文件的 hash 值,如果兩個 hash 值是相同的,則確保下載的依賴是完整的,如果不同,則進行重新下載。

整體流程

好了,我們再來整體總結下上面的流程:

  • 檢查 .npmrc 文件:優先順序為:項目級的 .npmrc 文件 > 用戶級的 .npmrc 文件> 全局級的 .npmrc 文件 > npm 內置的 .npmrc 文件
  • 檢查項目中有無 lock 文件。
  • lock 文件:
    • npm 遠程倉庫獲取包資訊
    • 根據 package.json 構建依賴樹,構建過程:
      • 構建依賴樹時,不管其是直接依賴還是子依賴的依賴,優先將其放置在 node_modules 根目錄。
      • 當遇到相同模組時,判斷已放置在依賴樹的模組版本是否符合新模組的版本範圍,如果符合則跳過,不符合則在當前模組的 node_modules 下放置該模組。
      • 注意這一步只是確定邏輯上的依賴樹,並非真正的安裝,後面會根據這個依賴結構去下載或拿到快取中的依賴包
    • 在快取中依次查找依賴樹中的每個包
      • 不存在快取:
        • npm 遠程倉庫下載包
        • 校驗包的完整性
        • 校驗不通過:
          • 重新下載
        • 校驗通過:
          • 將下載的包複製到 npm 快取目錄
          • 將下載的包按照依賴結構解壓到 node_modules

      存在快取:將快取按照依賴結構解壓到 node_modules

    • 將包解壓到 node_modules
    • 生成 lock 文件

lock 文件:

  • 檢查 package.json 中的依賴版本是否和 package-lock.json 中的依賴有衝突。
  • 如果沒有衝突,直接跳過獲取包資訊、構建依賴樹過程,開始在快取中查找包資訊,後續過程相同

上面的過程簡要描述了 npm install 的大概過程,這個過程還包含了一些其他的操作,例如執行你定義的一些生命周期函數,你可以執行 npm install package --timing=true --loglevel=verbose 來查看某個包具體的安裝流程和細節。

yarn

yarn 是在 2016 年發布的,那時 npm 還處於 V3 時期,那時候還沒有 package-lock.json 文件,就像上面我們提到的:不穩定性、安裝速度慢等缺點經常會受到廣大開發者吐槽。此時,yarn 誕生:

上面是官網提到的 yarn 的優點,在那個時候還是非常吸引人的。當然,後來 npm 也意識到了自己的問題,進行了很多次優化,在後面的優化(lock文件、快取、默認-s…)中,我們多多少少能看到 yarn 的影子,可見 yarn 的設計還是非常優秀的。

yarn 也是採用的是 npm v3 的扁平結構來管理依賴,安裝依賴後默認會生成一個 yarn.lock 文件,還是上面的依賴關係,我們看看 yarn.lock 的結構:

# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.  # yarn lockfile v1      [email protected]:    version "1.0.1"    resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.0.1.tgz#6926d1b194fbc737b8eed513756de2fcda7ea408"    integrity sha1-aSbRsZT7xze47tUTdW3i/Np+pAg=    base64-js@^1.0.2:    version "1.3.1"    resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1"    integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==    buffer@^5.4.3:    version "5.4.3"    resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.4.3.tgz#3fbc9c69eb713d323e3fc1a895eee0710c072115"    integrity sha512-zvj65TkFeIt3i6aj5bIvJDzjjQQGs4o/sNoezg1F1kYap9Nu2jcUdpwzRSJTHMMzG0H7bZkn4rNQpImhuxWX2A==    dependencies:      base64-js "^1.0.2"      ieee754 "^1.1.4"    ieee754@^1.1.4:    version "1.1.13"    resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"    integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==    ignore@^5.1.4:    version "5.1.4"    resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.4.tgz#84b7b3dbe64552b6ef0eca99f6743dbec6d97adf"    integrity sha512-MzbUSahkTW1u7JpKKjY7LCARd1fU5W2rLdxlM4kdkayuCwZImjkpluF9CM1aLewYJguPDqewLam18Y6AU69A8A==

可見其和 package-lock.json 文件還是比較類似的,還有一些區別就是:

  • package-lock.json 使用的是 json 格式,yarn.lock 使用的是一種自定義格式
  • yarn.lock 中子依賴的版本號不是固定的,意味著單獨又一個 yarn.lock 確定不了 node_modules 目錄結構,還需要和 package.json 文件進行配合。而 package-lock.json 只需要一個文件即可確定。

yarn 的緩策略看起來和 npm v5 之前的很像,每個快取的模組被存放在獨立的文件夾,文件夾名稱包含了模組名稱、版本號等資訊。使用命令 yarn cache dir 可以查看快取數據的目錄:

yarn 默認使用 prefer-online 模式,即優先使用網路數據,如果網路數據請求失敗,再去請求快取數據。