探索 JavaScript 中的依賴管理及迴圈依賴

前端新能源發表於2019-03-01

我們通常會把專案中使用的第三方依賴寫在 package.json 檔案裡,然後使用 npm 、cnpm 或者 yarn 這些流行的依賴管理工具來幫我們管理這些依賴。但是它們是如何管理這些依賴的、它們之間有什麼區別,如果出現了迴圈依賴應該怎麼解決。

在回答上面幾個問題之前,先讓我們瞭解下語義化版本規則。

語義化版本

使用第三方依賴時,通常需要指定依賴的版本範圍,比如

"dependencies": {
    "antd": "3.1.2",
    "react": "~16.0.1",
    "redux": "^3.7.2",
    "lodash": "*"
  }
複製程式碼

上面的 package.json 檔案表明,專案中使用的 antd 的版本號是 3.1.2,但是 3.1.1 和 3.1.2、3.0.1、2.1.1 之間有什麼不同呢。語義化版本規則規定,版本格式為:主版本號.次版本號.修訂號,並且版本號的遞增規則如下:

  • 主版本號:當你做了不相容的 API 修改
  • 次版本號:當你做了向下相容的功能性新增
  • 修訂號:當你做了向下相容的問題修正

主版本號的更新通常意味著大的修改和更新,升級主版本後可能會使你的程式報錯,因此升級主版本號需謹慎,但是這往往也會帶來更好的效能和體驗。次版本號的更新則通常意味著新增了某些特性,比如 antd 的版本從 3.1.1 升級到 3.1.2,之前的 Select 元件不支援搜尋功能,升級之後支援了搜尋。修訂號的更新則往往意味著進行了一些 bug 修復。因此次版本號和修訂號應該保持更新,這樣能讓你之前的程式碼不會報錯還能獲取到最新的功能特性。

但是,往往我們不會指定依賴的具體版本,而是指定版本範圍,比如上面的 package.json 檔案裡的 react、redux 以及 lodash,這三個依賴分別使用了三個符號來表明依賴的版本範圍。語義化版本範圍規定:

  • ~:只升級修訂號
  • ^:升級次版本號和修訂號
  • *:升級到最新版本

因此,上面的 package.json 檔案安裝的依賴版本範圍如下:

  • react@~16.0.1:>=react@16.0.1 && < react@16.1.0
  • redux@^3.7.2:>=redux@3.7.2 && < redux@4.0.0
  • lodash@*:lodash@latest

語義化版本規則定義了一種理想的版本號更新規則,希望所有的依賴更新都能遵循這個規則,但是往往會有許多依賴不是嚴格遵循這些規定的。因此,如何管理好這些依賴,尤其是這些依賴的版本就顯得尤為重要,否則一不小心就會陷入因依賴版本不一致導致的各種問題中。

依賴管理

在專案開發中,通常會使用 npmyarn 或者 cnpm 來管理專案中的依賴,下面我們就來看看它們是如何幫助我們管理這些依賴的。

npm

npm 發展到今天,可以說經歷過三個重大的版本變化。

npm v1

最早的 npm 版本在管理依賴時使用了一種很簡單的方式。我們稱之為巢狀模式。比如,在你的專案中有如下的依賴。

"dependencies": {
    A: "1.0.0",
    C: "1.0.0",
    D: "1.0.0"
}
複製程式碼

這些模組都依賴 B 模組,而且依賴的 B模組的版本還不同。

A@1.0.0 -> B@1.0.0
C@1.0.1 -> B@2.0.0
D@1.0.0 -> B@1.0.0
複製程式碼

通過執行 npm install 命令,npm v1 生成的 node_modules目錄如下:

node_modules
├── A@1.0.0
│   └── node_modules
│       └── B@1.0.0
├── C@1.0.0
│   └── node_modules
│       └── B@2.0.0
└── D@1.0.0
    └── node_modules
        └── B@1.0.0
複製程式碼

很明顯,每個模組下面都會有一個 node_modules 目錄存放該模組的直接依賴。模組的依賴下面還會存在一個 node_modules 目錄來存放模組的依賴的依賴。很明顯這種依賴管理簡單明瞭,但存在很大的問題,除了 node_modules 目錄長度的巢狀過深之外,還會造成相同的依賴儲存多份的問題,比如上面的 B@1.0.0 就存放了兩份,這明顯也是一種浪費。於是在 npm v3 釋出後,npm 的依賴管理做出了重大的改變。

npm v3

對於同樣的上述依賴,使用 npm v3 執行 npm install 命令後生成的 node_modules 目錄如下:

node_modules
├── A@1.0.0
├── B@1.0.0
└── C@1.0.0
    └── node_modules
        └── B@2.0.0
├── D@1.0.0
複製程式碼

顯而易見,npm v3 使用了一種扁平的模式,把專案中使用的所有的模組和模組的依賴都放在了 node_modules 目錄下的頂層,遇到版本衝突的時候才會在模組下的 node_modules 目錄下存放該模組需要用到的依賴。之所以能這麼實現是基於包搜尋機制的。包搜尋機制是指當你在專案中直接 require('A') 時,首先會在當前路徑下搜尋 node_modules 目錄中是否存在該依賴,如果不存在則往上查詢也就是繼續查詢該路徑的上一層目錄下的 node_modules。正因為此,npm v3 才能把之前的巢狀結構拍平,把所有的依賴都放在專案根目錄的 node_modules,這樣就避免了 node_modules 目錄巢狀過深的問題。此外,npm v3 還會解析模組的依賴的多個版本為一個版本,比如 A依賴 B@^1.0.1,D 依賴 B@^1.0.2,則只會有一個 B@1.0.2 的版本存在。雖然 npm v3 解決了這兩個問題,但是此時的 npm 仍然存在諸多問題,被人詬病最多的應該就是它的不確定性了。

npm v5

什麼是確定性。在 JavaScript 包管理的背景下,確定性是指在給定的 package.json 和 lock 檔案下始終能得到一致的 node_modules 目錄結構。簡單點說就是無論在何種環境下執行 npm install 都能得到相同的 node_modules 目錄結構。npm v5 正是為解決這個問題而產生的,npm v5 生成的 node_modules 目錄和 v3 是一致的,區別是 v5 會預設生成一個 package-lock.json 檔案,來保證安裝的依賴的確定性。比如,對於如下的一個 package.json 檔案

"dependencies": {
    "redux": "^3.7.2"
  }
複製程式碼

對應的 package-lock.json 檔案內容如下:

{
  "name": "test",
  "version": "1.0.0",
  "lockfileVersion": 1,
  "requires": true,
  "dependencies": {
    "js-tokens": {
      "version": "3.0.2",
      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz",
      "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls="
    },
    "lodash": {
      "version": "4.17.4",
      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz",
      "integrity": "sha1-eCA6TRwyiuHYbcpkYONptX9AVa4="
    },
    "lodash-es": {
      "version": "4.17.4",
      "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.4.tgz",
      "integrity": "sha1-3MHXVS4VCgZABzupyzHXDwMpUOc="
    },
    "loose-envify": {
      "version": "1.3.1",
      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz",
      "integrity": "sha1-0aitM/qc4OcT1l/dCsi3SNR4yEg=",
      "requires": {
        "js-tokens": "3.0.2"
      }
    },
    "redux": {
      "version": "3.7.2",
      "resolved": "https://registry.npmjs.org/redux/-/redux-3.7.2.tgz",
      "integrity": "sha512-pNqnf9q1hI5HHZRBkj3bAngGZW/JMCmexDlOxw4XagXY2o1327nHH54LoTjiPJ0gizoqPDRqWyX/00g0hD6w+A==",
      "requires": {
        "lodash": "4.17.4",
        "lodash-es": "4.17.4",
        "loose-envify": "1.3.1",
        "symbol-observable": "1.1.0"
      }
    },
    "symbol-observable": {
      "version": "1.1.0",
      "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.1.0.tgz",
      "integrity": "sha512-dQoid9tqQ+uotGhuTKEY11X4xhyYePVnqGSoSm3OGKh2E8LZ6RPULp1uXTctk33IeERlrRJYoVSBglsL05F5Uw=="
    }
  }
}
複製程式碼

不難看出,package-lock.json 檔案裡記錄了安裝的每一個依賴的確定版本,這樣在下次安裝時就能通過這個檔案來安裝一樣的依賴了。

image

yarn

yarn 是在 2016.10.11 開源的,yarn 的出現是為了解決 npm v3 中的存在的一些問題,那時 npm v5 還沒釋出。yarn 被定義為快速、安全、可靠的依賴管理。

  • 快速:全域性快取、並行下載、離線模式
  • 安全:安裝包被執行前校驗其完整性
  • 可靠:lockfile檔案、確定性演算法

yarn 生成的 node_modules 目錄結構和 npm v5 是相同的,同時預設生成一個 yarn.lock 檔案。對於上面的例子,只安裝 redux 的依賴生成的 yarn.lock 檔案內容如下:

# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1

js-tokens@^3.0.0:
  version "3.0.2"
  resolved "http://registry.npm.alibaba-inc.com/js-tokens/download/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"

lodash-es@^4.2.1:
  version "4.17.4"
  resolved "http://registry.npm.alibaba-inc.com/lodash-es/download/lodash-es-4.17.4.tgz#dcc1d7552e150a0640073ba9cb31d70f032950e7"

lodash@^4.2.1:
  version "4.17.4"
  resolved "http://registry.npm.alibaba-inc.com/lodash/download/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae"

loose-envify@^1.1.0:
  version "1.3.1"
  resolved "http://registry.npm.alibaba-inc.com/loose-envify/download/loose-envify-1.3.1.tgz#d1a8ad33fa9ce0e713d65fdd0ac8b748d478c848"
  dependencies:
    js-tokens "^3.0.0"

redux@^3.7.2:
  version "3.7.2"
  resolved "http://registry.npm.alibaba-inc.com/redux/download/redux-3.7.2.tgz#06b73123215901d25d065be342eb026bc1c8537b"
  dependencies:
    lodash "^4.2.1"
    lodash-es "^4.2.1"
    loose-envify "^1.1.0"
    symbol-observable "^1.0.3"

symbol-observable@^1.0.3:
  version "1.1.0"
  resolved "http://registry.npm.alibaba-inc.com/symbol-observable/download/symbol-observable-1.1.0.tgz#5c68fd8d54115d9dfb72a84720549222e8db9b32"
複製程式碼

不難看出,yarn.lock 檔案和 npm v5 生成的 package-lock.json 檔案有如下幾點不同:

  1. 檔案格式不同,npm v5 使用的是 json 格式,yarn 使用的是一種自定義格式
  2. package-lock.json 檔案裡記錄的依賴的版本都是確定的,不會出現語義化版本範圍符號(~ ^ *),而 yarn.lock 檔案裡仍然會出現語義化版本範圍符號
  3. package-lock.json 檔案內容更豐富,npm v5 只需要 package.lock 檔案就可以確定 node_modules 目錄結構,而 yarn 卻需要同時依賴 package.json 和 yarn.lock 兩個檔案才能確定 node_modules 目錄結構

關於為什麼會有這些不同、yarn 的確定性演算法以及和 npm v5 的區別,yarn 官方的一篇文章詳細介紹了這幾點。由於篇幅有限,這裡就不再贅述,感興趣的可以移步到我的翻譯文章 Yarn 確定性去看。

yarn 的出現除了帶來安裝速度的提升以外,最大的貢獻是通過 lock 檔案來保證安裝依賴的確定性,保證相同的 package.json 檔案,在何種環境何種機器上安裝依賴都會得到相同的結果也就是相同的 node_modules 目錄結構。這在很大程度上避免了一些“在我電腦上是正常的,在其他機器上失敗”的 bug。但是在使用 yarn 做依賴管理時,仍然需要注意以下3點。

  • 不要手動修改 yarn.lock 檔案
  • yarn.lock 檔案應該提交到版本控制的倉庫裡
  • 升級依賴時,使用yarn upgrade命令,避免手動修改 package.json 和 yarn.lock 檔案。

cnpm

cnpm 在國內的使用者應該還是蠻多的,尤其是對於有搭建私有倉庫需求的人來說。cnpm 在安裝依賴時使用的是 npminstall,簡單來說, cnpm 使用連結 link 的安裝方式,最大限度地提高了安裝速度,生成的 node_modules 目錄採用的是和 npm 不一樣的佈局。 用 cnpm 裝的包都是在 node_modules 資料夾下以 版本號 @包名 命名,然後再做軟連結到只以包名命名的資料夾上。同樣的例子,使用 cnpm 只安裝 redux 依賴時生成的 node_modules 目錄結構如下:

image

cnpm 和 npm 以及 yarn 之間最大的區別就在於生成的 node_modules 目錄結構不同,這在某些場景下可能會引發一些問題。此外也不會生成 lock 檔案,這就導致在安裝確定性方面會比 npm 和 yarn 稍遜一籌。但是 cnpm 使用的 link 安裝方式還是很好的,既節省了磁碟空間,也保持了 node_modules 的目錄結構清晰,可以說是在巢狀模式和扁平模式之間找到了一個平衡。

npm、yarn 和 cnpm 均提供了很好的依賴管理來幫助我們管理專案中使用到的各種依賴以及版本,但是如果依賴出現了迴圈呼叫也就是迴圈依賴應該怎麼解決呢?

迴圈依賴

迴圈依賴指的是,a 模組的執行依賴 b 模組,而 b 模組的執行又依賴 a 模組。迴圈依賴可能導致遞迴載入,處理不好的話可能使得程式無法執行。探討迴圈依賴之前,先讓我們瞭解一下 JavaScript 中的模組規範。因為,不同的規範在處理迴圈依賴時的做法是不同的。

目前,通行的 JavaScript 規範可以分為三種,CommonJSAMDES6

模組規範

CommonJS

從2009年 node.js 出現以來,CommonJS 模組系統逐漸深入人心。CommonJS 的一個模組就是一個指令碼檔案,通過 require 命令來載入這個模組,並使用模組暴漏出的介面。載入時執行是 CommonJS 模組的重要特性,即指令碼程式碼在 require 的時候就會執行模組中的程式碼。這個特性在服務端是沒問題的,但如果引入一個模組就要等待它執行完才能執行後面的程式碼,這在瀏覽器端就會有很大的問題了。因此出現了 AMD 規範,以支援瀏覽器環境。

AMD

AMD 是 “Asynchronous Module Definition” 的縮寫,意思就是“非同步模組定義”。它採用非同步載入方式載入模組,模組的載入不影響它後面語句的執行。所有依賴這個模組的語句,都定義在一個回撥函式中,等到載入完成之後,這個回撥函式才會執行。最有代表性的實現則是 requirejs

ES6

不同於 CommonJS 和 AMD 的模組載入方案,ES6 在 JavaScript 語言層面上實現了模組功能。它的設計思想是,儘量的靜態化,使得編譯時就能確定模組的依賴關係。在遇到模組載入命令 import 時,不會去執行模組,而是隻生成一個引用。等到真的需要用到時,再到模組裡面去取值。這是和 CommonJS 模組規範的最大不同。

CommonJS 中迴圈依賴的解法

請看下面的例子:

a.js

console.log('a starting');
exports.done = false;
const b = require('./b.js');
console.log('in a, b.done = %j', b.done);
exports.done = true;
console.log('a done');
複製程式碼

b.js

console.log('b starting');
exports.done = false;
const a = require('./a.js');
console.log('in b, a.done = %j', a.done);
exports.done = true;
console.log('b done');
複製程式碼

main.js

console.log('main starting');
const a = require('./a.js');
const b = require('./b.js');
console.log('in main, a.done=%j, b.done=%j', a.done, b.done);
複製程式碼

在這個例子中,a 模組呼叫 b 模組,b 模組又需要呼叫 a 模組,這就使得 a 和 b 之間形成了迴圈依賴,但是當我們執行 node main.js 時程式碼卻沒有陷入無限迴圈呼叫當中,而是輸出瞭如下內容:

$ node main.js
main starting
a starting
b starting
in b, a.done = false
b done
in a, b.done = true
a done
in main, a.done=true, b.done=true
複製程式碼

為什麼程式沒有報錯,而是輸出如上的內容呢?這是因為 CommonJs 模組的兩個特性。第一,載入時執行;第二,已載入的模組會進行快取,不會重複載入。下面讓我們分析下程式的執行過程:

  1. main.js 執行,輸出 main starting
  2. main.js 載入 a.js,執行 a.js 並輸出 a starting,匯出 done = false
  3. a.js 載入 b.js,執行 b.js 並輸出 b starting,匯出 done = false
  4. b.js 載入 a.js,由於之前 a.js 已載入過一次因此不會重複載入,快取中 a.js 匯出的 done = false,因此,b.js 輸出 in b, a.done = false
  5. b.js 匯出 done = true,並輸出 b done
  6. b.js 執行完畢,執行權交回給 a.js,執行 a.js,並輸出 in a, b.done = true
  7. a.js 匯出 done = true,並輸出 a done
  8. a.js 執行完畢,執行權交回給 main.js,main.js 載入 b.js,由於之前 b.js 已載入過一次,不會重複執行
  9. main.js 輸出 in main, a.done=true, b.done=true

從上面的執行過程中,我們可以看到,在 CommonJS 規範中,當遇到 require() 語句時,會執行 require 模組中的程式碼,並快取執行的結果,當下次再次載入時不會重複執行,而是直接取快取的結果。正因為此,出現迴圈依賴時才不會出現無限迴圈呼叫的情況。雖然這種模組載入機制可以避免出現迴圈依賴時報錯的情況,但稍不注意就很可能使得程式碼並不是像我們想象的那樣去執行。因此在寫程式碼時還是需要仔細的規劃,以保證迴圈模組的依賴能正確工作(官方原文:Careful planning is required to allow cyclic module dependencies to work correctly within an application)。

除了仔細的規劃還有什麼辦法可以避免出現迴圈依賴嗎?一個不太優雅的方法是在迴圈依賴的每個模組中先寫 exports 語句,再寫 require 語句,利用 CommonJS 的快取機制,在 require() 其他模組之前先把自身要匯出的內容匯出,這樣就能保證其他模組在使用時可以取到正確的值。比如:

A.js

exports.done = true;

let B = require('./B');
console.log(B.done)
複製程式碼

B.js

exports.done = true;

let A = require('./A');
console.log(A.done)
複製程式碼

這種寫法簡單明瞭,缺點是要改變每個模組的寫法,而且大部分同學都習慣了在檔案開頭先寫 require 語句。

個人經驗來看,在寫程式碼中只要我們注意一下迴圈依賴的問題就可以了,大部分同學在寫 node.js 中應該很少碰到需要手動去處理迴圈依賴的問題,更甚的是很可能大部分同學都沒想過這個問題。

ES6 中迴圈依賴的解法

要想知道 ES6 中迴圈依賴的解法就必須先了解 ES6 的模組載入機制。我們都知道 ES6 使用 export 命令來規定模組的對外介面,使用 import 命令來載入模組。那麼在遇到 import 和 export 時發生了什麼呢?ES6 的模組載入機制可以概括為四個字一靜一動

  • 一靜:import 靜態執行
  • 一動:export 動態繫結

import 靜態執行是指,import 命令會被 JavaScript 引擎靜態分析,優先於模組內的其他內容執行。
export 動態繫結是指,export 命令輸出的介面,與其對應的值是動態繫結關係,通過該介面可以實時取到模組內部的值。

讓我們看下面一個例子:

foo.js

console.log('foo is running');
import {bar} from './bar'
console.log('bar = %j', bar);
setTimeout(() => console.log('bar = %j after 500 ms', bar), 500);
console.log('foo is finished');
複製程式碼

bar.js

console.log('bar is running');
export let bar = false;
setTimeout(() => bar = true, 500);
console.log('bar is finished');
複製程式碼

執行 node foo.js 時會輸出如下內容:

bar is running
bar is finished
foo is running
bar = false
foo is finished
bar = true after 500 ms
複製程式碼

是不是和你想的不一樣呢?當我們執行 node foo.js 時第一行輸出的不是 foo.js 的第一個 console 語句,而是先輸出了 bar.js 裡的 console 語句。這就是因為 import 命令是在編譯階段執行,在程式碼執行之前先被 JavaScript 引擎靜態分析,所以優先於 foo.js 自身內容執行。同時我們也看到 500 毫秒之後也可以取到 bar 更新後的值也說明了 export 命令輸出的介面與其對應的值是動態繫結關係。這樣的設計使得程式在編譯時就能確定模組的依賴關係,這是和 CommonJS 模組規範的最大不同。還有一點需要注意的是,由於 import 是靜態執行,所以 import 具有提升效果即 import 命令的位置並不影響程式的輸出。

在我們瞭解了 ES6 的模組載入機制之後來讓我們來看一下 ES6 是怎麼處理迴圈依賴的。修改一下上面的例子:

foo.js

console.log('foo is running');
import {bar} from './bar'
console.log('bar = %j', bar);
setTimeout(() => console.log('bar = %j after 500 ms', bar), 500);
export let foo = false;
console.log('foo is finished');
複製程式碼

bar.js

console.log('bar is running');
import {foo} from './foo';
console.log('foo = %j', foo)
export let bar = false;
setTimeout(() => bar = true, 500);
console.log('bar is finished');
複製程式碼

執行 node foo.js 時會輸出如下內容:

bar is running
foo = undefined
bar is finished
foo is running
bar = false
foo is finished
bar = true after 500 ms
複製程式碼

foo.js 和 bar.js 形成了迴圈依賴,但是程式卻沒有因陷入迴圈呼叫報錯而是執行正常,這是為什麼呢?還是因為 import 是在編譯階段執行的,這樣就使得程式在編譯時就能確定模組的依賴關係,一旦發現迴圈依賴,ES6 本身就不會再去執行依賴的那個模組了,所以程式可以正常結束。這也說明了 ES6 本身就支援迴圈依賴,保證程式不會因為迴圈依賴陷入無限呼叫。雖然如此,但是我們仍然要儘量避免程式中出現迴圈依賴,因為可能會發生一些讓你迷惑的情況。注意到上面的輸出,在 bar.js 中輸出的 foo = undefined,如果沒注意到迴圈依賴會讓你覺得明明在 foo.js 中 export foo = false,為什麼在 bar.js 中卻是 undefined 呢,這就是迴圈依賴帶來的困惑。在一些複雜大型專案中,你是很難用肉眼發現迴圈依賴的,而這會給排查異常帶來極大的困難。對於使用 webpack 進行專案構建的專案,推薦使用 webpack 外掛 circular-dependency-plugin 來幫助你檢測專案中存在的所有迴圈依賴,儘早發現潛在的迴圈依賴可能會免去未來很大的麻煩。

小結

講了那麼多,希望此文能幫助你更好的瞭解 JavaScript 中的依賴管理,並且處理好專案中的迴圈依賴問題。

相關文章