使用 Node.js 進行殼層指令碼編寫
你可以購買此書的離線版本(HTML、PDF、EPUB、MOBI),並支持免費的線上版本。
(廣告,請不要封鎖。)

5 套件:JavaScript 的軟體散布單位



本章說明 npm 套件是什麼,以及它們如何與 ESM 模組互動。

必要的知識:我假設你對 ECMAScript 模組的語法略有了解。如果你不了解,你可以閱讀「JavaScript for impatient programmers」中的〈模組〉一章。

5.1 什麼是套件?

在 JavaScript 生態系統中,套件 是一種組織軟體專案的方式:它是一個具有標準化配置的目錄。套件可以包含各種檔案,例如

套件可以依賴其他套件(稱為其相依性),其中包含

套件的相依性安裝在該套件內部(我們很快就會看到如何執行)。

套件之間的一個常見區別是

下一個小節說明如何發布套件。

5.1.1 發布套件:套件註冊表、套件管理員、套件名稱

發布套件的主要方式是將其上傳到套件註冊表 – 一個線上軟體儲存庫。事實上的標準是npm 註冊表,但它並非唯一的選項。例如,公司可以主機它們自己的內部註冊表。

套件管理員是一個命令列工具,用於從註冊表(或其他來源)下載套件並在本地或全域安裝它們。如果套件包含 bin 腳本,它也會在本地或全域提供這些腳本。

最受歡迎的套件管理員稱為npm,並與 Node.js 捆綁在一起。它的名稱最初代表「Node 套件管理員」。後來,當 npm 和 npm 註冊表不僅用於 Node.js 套件時,定義被更改為「npm 不是套件管理員」(來源)。

還有其他流行的套件管理員,例如 yarn 和 pnpm。所有這些套件管理員預設使用 npm 註冊表。

npm 註冊表中的每個套件都有名稱。有兩種名稱

5.2 套件的檔案系統配置

一旦套件 my-package 完全安裝,它幾乎總是看起來像這樣

my-package/
  package.json
  node_modules/
  [More files]

這些檔案系統條目的用途是什麼?

有些套件也有檔案 package-lock.json,它位於 package.json 旁邊:它記錄已安裝相依性的確切版本,如果我們透過 npm 新增更多相依性,它會保持最新狀態。

5.2.1 package.json

這是透過 npm 建立的入門 package.json

{
  "name": "my-package",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

這些屬性的用途是什麼?

其他有用的屬性

有關 package.json 的更多資訊,請參閱 npm 文件

5.2.2 package.json"dependencies" 屬性

以下是 package.json 檔案中相依性的外觀

"dependencies": {
  "minimatch": "^5.1.0",
  "mocha": "^10.0.0"
}

這些屬性會記錄套件名稱和其版本的限制條件。

版本本身遵循 語意化版本 標準。它們最多有三個數字(第二和第三個數字為選填,預設為零),並以點號分隔

  1. 主要版本:當套件以不相容的方式變更時,此數字會變更。
  2. 次要版本:當以向後相容的方式新增功能時,此數字會變更。
  3. 修補程式版本:當進行向下相容的錯誤修正時,此數字會變更。

Node 的版本範圍說明在 semver 儲存庫 中。範例包括

5.2.3 package.json 的屬性 "bin"

這是我們可以告訴 npm 將模組安裝為 shell 腳本的方式

"bin": {
  "my-shell-script": "./src/shell/my-shell-script.mjs",
  "another-script": "./src/shell/another-script.mjs"
}

如果我們使用此 "bin" 值在全球安裝套件,Node.js 會確保命令 my-shell-scriptanother-script 可在命令列中使用。

如果我們在本地安裝套件,我們可以在套件腳本或透過 npx 命令 使用這兩個命令。

字串也可以作為 "bin" 的值

{
  "name": "my-package",
  "bin": "./src/main.mjs"
}

這是縮寫

{
  "name": "my-package",
  "bin": {
    "my-package": "./src/main.mjs"
  }
}

5.2.4 package.json 的屬性 "license"

屬性 "license" 的值永遠是包含 SPDX 授權識別碼的字串。例如,下列值會拒絕其他人使用套件的任何條款(如果套件未發布,這很有用)

"license": "UNLICENSED"

SPDX 網站列出所有可用的授權識別碼。如果您發現很難選擇一個,網站「選擇開源授權」 可以提供協助,例如,如果您「想要簡單且寬鬆」時,這是一個建議

MIT 授權簡短且切中要點。它允許人們對您的專案做幾乎任何他們想做的事,例如製作和發行封閉原始碼版本。

Babel、.NET 和 Rails 使用 MIT 授權。

您可以這樣使用該授權

"license": "MIT"

5.3 封存和安裝套件

npm 儲存庫中的套件通常以兩種不同的方式封存

無論如何,套件都會在沒有其相依項目的情況下封存,我們在使用它之前必須安裝這些相依項目。

如果套件儲存在 git 儲存庫中

如果套件發布到 npm 註冊表

開發相依項目(package.json 中的屬性 devDependencies)只會在開發期間安裝,但在我們從 npm 註冊表安裝套件時不會安裝。

請注意,在開發期間,git 儲存庫中未發布的套件處理方式與已發布的套件類似。

5.3.1 從 git 安裝套件

要從 git 安裝套件 pkg,我們複製其儲存庫,然後

cd pkg/
npm install

然後執行下列步驟

如果根套件沒有 package-lock.json 檔案,它會在安裝期間建立(如前所述,相依項目沒有這個檔案)。

在相依項目樹中,同一個相依項目可能會存在多次,可能使用不同的版本。有方法可以將重複降到最低,但這超出了本章的範圍。

5.3.1.1 重新安裝套件

這是修正相依項目樹中問題的一個(有點粗糙的)方法

cd pkg/
rm -rf node_modules/
rm package-lock.json
npm install

請注意,這可能會導致安裝不同、較新的套件。我們可以透過不刪除 package-lock.json 來避免此情況。

5.3.2 建立新套件並安裝相依項

有許多工具和技術可供設定新套件。這是一個簡單的方法

mkdir my-package
cd my-package/
npm init --yes

之後,目錄會如下所示

my-package/
  package.json

package.json 具有我們已經看過的入門內容。

5.3.2.1 安裝相依項

現在,my-package 沒有任何相依項。假設我們想要使用函式庫 lodash-es。以下是我們將其安裝到套件中的方式

npm install lodash-es

此命令執行下列步驟

5.4 透過 指定符 參照模組

其他 ECMAScript 模組中的程式碼可透過 import 陳述式存取(A 行和 B 行)

// Static import
import {namedExport} from 'https://example.com/some-module.js'; // (A)
console.log(namedExport);

// Dynamic import
import('https://example.com/some-module.js') // (B)
.then((moduleNamespace) => {
  console.log(moduleNamespace.namedExport);
});

靜態匯入和動態匯入都使用 模組指定符 來參照模組

有三種模組指定符

5.4.1 模組指定符中的檔案名稱副檔名

樣式 3 裸指定符的注意事項:檔案名稱副檔名的解釋方式取決於依賴項,可能與匯入套件不同。例如,匯入套件可能會對 ESM 模組使用 .mjs,對 CommonJS 模組使用 .js,而依賴項匯出的 ESM 模組可能具有檔案名稱副檔名 .js 的裸路徑。

5.5 Node.js 中的模組指定符

讓我們看看模組指定符如何在 Node.js 中運作。

5.5.1 在 Node.js 中解析模組指定符

Node.js 解析演算法 的運作方式如下

以下是演算法

解析演算法的結果必須指向一個檔案。這說明了為什麼絕對規格符和相對規格符總是具有檔案名稱副檔名。裸規格符大多數沒有,因為它們是會在套件匯出中查詢的縮寫。

模組檔案通常具有這些檔案名稱副檔名

如果 Node.js 執行透過 stdin、--eval--print 提供的程式碼,我們會使用 下列命令列選項,以便將其解釋為 ES 模組

--input-type=module

5.5.2 套件匯出:控制其他套件看到什麼

在此小節中,我們使用具有下列檔案配置的套件

my-lib/
  dist/
    src/
      main.js
      util/
        errors.js
      internal/
        internal-module.js
    test/

套件匯出 是透過 package.json 中的屬性 "exports" 指定,並支援兩個重要功能

回想裸規格符的三種樣式

套件匯出有助於我們處理所有三種樣式

5.5.2.1 樣式 1:設定哪個檔案代表(套件的裸規格符)套件

package.json:

{
  "main": "./dist/src/main.js",
  "exports": {
    ".": "./dist/src/main.js"
  }
}

我們僅提供 "main" 以保持向後相容性(與較舊的打包器和 Node.js 12 及更早版本相容)。否則,"." 的輸入就足夠了。

有了這些套件匯出,我們現在可以從 my-lib 匯入,如下所示。

import {someFunction} from 'my-lib';

這會從這個檔案匯入 someFunction()

my-lib/dist/src/main.js
5.5.2.2 樣式 2:將沒有副檔名的子路徑對應到模組檔案

package.json:

{
  "exports": {
    "./util/errors": "./dist/src/util/errors.js"
  }
}

我們將規格說明子路徑 'util/errors' 對應到一個模組檔案。這會啟用以下匯入

import {UserError} from 'my-lib/util/errors';
5.5.2.3 樣式 2:為子樹提供沒有副檔名的更佳子路徑

前一個小節說明如何為沒有副檔名的子路徑建立單一對應。還有一種方法可以透過單一輸入建立多個此類對應

package.json:

{
  "exports": {
    "./lib/*": "./dist/src/*.js"
  }
}

現在可以匯入任何 ./dist/src/ 的後代檔案,而不需要檔案副檔名

import {someFunction} from 'my-lib/lib/main';
import {UserError}    from 'my-lib/lib/util/errors';

請注意此 "exports" 輸入中的星號

"./lib/*": "./dist/src/*.js"

這些是關於如何將子路徑對應到實際路徑的更多說明,而不是與檔案路徑片段相符的萬用字元。

5.5.2.4 樣式 3:將有副檔名的子路徑對應到模組檔案

package.json:

{
  "exports": {
    "./util/errors.js": "./dist/src/util/errors.js"
  }
}

我們將規格說明子路徑 'util/errors.js' 對應到一個模組檔案。這會啟用以下匯入

import {UserError} from 'my-lib/util/errors.js';
5.5.2.5 樣式 3:為子樹提供有副檔名的更佳子路徑

package.json:

{
  "exports": {
    "./*": "./dist/src/*"
  }
}

在這裡,我們縮短 my-package/dist/src 下整個子樹的模組規格說明

import {InternalError} from 'my-package/util/errors.js';

沒有匯出時,匯入陳述會是

import {InternalError} from 'my-package/dist/src/util/errors.js';

請注意此 "exports" 輸入中的星號

"./*": "./dist/src/*"

這些不是檔案系統 glob,而是關於如何將外部模組規格說明對應到內部規格說明的說明。

5.5.2.6 公開子樹,同時隱藏其部分

透過以下技巧,我們公開目錄 my-package/dist/src/ 中的所有內容,但 my-package/dist/src/internal/ 除外

"exports": {
  "./*": "./dist/src/*",
  "./internal/*": null
}

請注意,當匯出沒有檔案副檔名的子樹時,此技巧也適用。

5.5.2.7 條件套件匯出

我們也可以讓匯出 有條件:然後,給定的路徑會根據套件使用的環境對應到不同的值。

Node.js 與瀏覽器。例如,我們可以為 Node.js 和瀏覽器提供不同的實作

"exports": {
  ".": {
    "node": "./main-node.js",
    "browser": "./main-browser.js",
    "default": "./main-browser.js"
  }
}

"default" 條件在沒有其他金鑰相符時相符,並且必須放在最後。當我們區分平台時,建議有一個,因為它會處理新的和/或未知的平台。

開發與製作。條件套件匯出的另一個使用案例是在「開發」和「製作」環境之間切換

"exports": {
  ".": {
    "development": "./main-development.js",
    "production": "./main-production.js",
  }
}

在 Node.js 中,我們可以這樣指定環境

node --conditions development app.mjs

5.5.3 套件匯入

套件匯入讓套件定義其本身可內部使用的模組規格縮寫(套件匯出定義其他套件的縮寫)。以下是一個範例

package.json:

{
  "imports": {
    "#some-pkg": {
      "node": "some-pkg-node-native",
      "default": "./polyfills/some-pkg-polyfill.js"
    }
  },
  "dependencies": {
    "some-pkg-node-native": "^1.2.3"
  }
}

套件匯入#有條件的(具有與有條件套件匯出相同的功能)

(只有套件匯入可以參考外部套件,套件匯出無法這麼做。)

套件匯入有哪些使用案例?

使用套件匯入搭配打包器時要小心:這項功能相對較新,你的打包器可能不支援。

5.5.4 node: 協定匯入

Node.js 有許多內建模組,例如'path''fs'。這些模組都同時提供 ES 模組和 CommonJS 模組。這些模組的一個問題是,它們可能會被安裝在node_modules中的模組覆寫,這既是一個安全風險(如果意外發生),也是一個問題,如果 Node.js 想在未來推出新的內建模組,而其名稱已被 npm 套件使用。

我們可以使用node: 協定來清楚表示我們要匯入內建模組。例如,以下兩個匯入陳述式幾乎是等效的(如果沒有安裝名稱為'fs'的 npm 模組)

import * as fs from 'node:fs/promises';
import * as fs from 'fs/promises';

使用node: 協定的另一個好處是,我們可以立即看出匯入的模組是內建的。由於內建模組數量眾多,這在閱讀程式碼時很有幫助。

由於node: 規格具有協定,因此它們被視為絕對的。這就是為什麼它們不會在node_modules中被查詢。