Node.js を用いたシェルスクリプト
本書のオフライン版(HTML、PDF、EPUB、MOBI)を購入して、無料のオンライン版をサポートいただけます。
(広告です。ブロックしないでください。)

5 パッケージ:JavaScript のソフトウェア配布単位



本章では、npm パッケージとは何か、そしてそれらが ESM モジュールとどのように相互作用するかを説明します。

必要な知識:ECMAScript モジュールの構文についてある程度理解していることを前提としています。理解していない場合は、「JavaScript for impatient programmers」の「モジュール」の章をお読みください。

5.1 パッケージとは何か?

JavaScript エコシステムにおいて、パッケージとはソフトウェアプロジェクトを整理する方法です。標準化されたレイアウトを持つディレクトリです。パッケージにはあらゆる種類のファイルを含めることができます。例えば

パッケージは他のパッケージに依存することができます(これは依存関係と呼ばれます)。それらには以下が含まれます。

パッケージの依存関係は、そのパッケージ内にインストールされます(すぐに方法を説明します)。

パッケージ間の一般的な違いの1つは、

次の小節では、パッケージを公開する方法について説明します。

5.1.1 パッケージの公開:パッケージレジストリ、パッケージマネージャー、パッケージ名

パッケージを公開する主な方法は、オンラインソフトウェアリポジトリであるパッケージレジストリにアップロードすることです。事実上の標準はnpm レジストリですが、唯一の選択肢ではありません。例えば、企業は独自の内部レジストリをホストできます。

パッケージマネージャーとは、レジストリ(またはその他のソース)からパッケージをダウンロードし、ローカルまたはグローバルにインストールするコマンドラインツールです。パッケージに bin スクリプトが含まれている場合、それらをローカルまたはグローバルに利用可能にします。

最も人気のあるパッケージマネージャーはnpmと呼ばれ、Node.js にバンドルされています。その名前は当初「Node Package Manager」を意味していました。その後、npm と npm レジストリが Node.js パッケージだけでなく使用されるようになると、定義は「npm はパッケージマネージャーではない」に変更されました(ソース)。

yarn や pnpm など、他にも人気のあるパッケージマネージャーがあります。これらのパッケージマネージャーはすべて、デフォルトで npm レジストリを使用します。

npm レジストリの各パッケージには名前があります。2種類の名前があります。

5.2 パッケージのファイルシステムレイアウト

パッケージmy-packageが完全にインストールされると、ほとんどの場合、次のようになります。

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

これらのファイルシステムエントリの目的は何ですか?

一部のパッケージには、package.jsonの隣にpackage-lock.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"
}

プロパティは、パッケージの名前とバージョンの制約の両方を記録します。

バージョン自体はセマンティックバージョニング標準に従います。最大3つの数値(2番目と3番目の数値はオプションで、デフォルトは0)がドットで区切られます。

  1. メジャーバージョン:パッケージが非互換な方法で変更された場合、この数値が変わります。
  2. マイナーバージョン:下位互換性のある方法で機能が追加された場合、この数値が変わります。
  3. パッチバージョン:下位互換性のあるバグ修正が行われた場合、この数値が変わります。

Node のバージョン範囲については、semver リポジトリで説明されています。例としては、

5.2.3 package.json の "bin" プロパティ

npm にモジュールをシェルスクリプトとしてインストールするように指示するには、次のようになります。

"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コマンドを使用して、2つのコマンドを使用できます。

文字列も"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ライセンスIDを含む文字列です。例えば、次の値は、他の人がどのような条件下でもパッケージを使用する権利を拒否します(パッケージが未公開の場合に便利です)。

"license": "UNLICENSED"

SPDX のウェブサイトには、利用可能なすべてのライセンス ID がリストされています。選択が難しい場合は、「Choose an open source license」のウェブサイトが役立ちます。例えば、「シンプルで寛容なものを求める」場合のアドバイスは次のとおりです。

MITライセンスは短くて簡潔です。これにより、クローズドソースバージョンの作成と配布など、プロジェクトでほとんど何でも行うことができます。

Babel、.NET、Rails は MIT ライセンスを使用しています。

そのライセンスを次のように使用できます。

"license": "MIT"

5.3 パッケージのアーカイブとインストール

npm レジストリのパッケージは、通常、2つの異なる方法でアーカイブされます。

いずれの場合も、パッケージは依存関係なしにアーカイブされます。使用するには、事前に依存関係をインストールする必要があります。

パッケージがGitリポジトリに保存されている場合

パッケージがnpmレジストリに公開されている場合

開発依存関係(package.jsondevDependencies プロパティ)は、開発中はインストールされますが、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 新しいパッケージの作成と依存関係のインストール

新しいパッケージを設定するためのツールやテクニックはたくさんあります。これは簡単な方法の1つです。

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);
});

静的インポートと動的インポートの両方で、*モジュール指定子*を使用してモジュールを参照します。

モジュール指定子には3種類あります。

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"を介して指定され、2つの重要な機能をサポートします。

ベアメ指定子の3つのスタイルを思い出してください。

パッケージエクスポートは、これら3つのスタイルすべてに役立ちます。

5.5.2.1 スタイル1:パッケージを表す(ベアメ指定子)ファイルの設定

package.json:

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

(古いバンドラとNode.js 12以前との)下位互換性のために"main"のみを提供します。それ以外の場合、"."のエントリで十分です。

これらのパッケージエクスポートを使用すると、次のように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/internal/を除いて、my-package/dist/src/内のすべてを公開します。

"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"条件は、他のキーと一致しない場合に一致し、最後に配置する必要があります。プラットフォームを区別する場合、新規または未知のプラットフォームにも対応できるため、推奨されます。

開発環境と本番環境 条件付きパッケージエクスポートのもう1つのユースケースは、「開発」環境と「本番」環境を切り替えることです。

"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モジュールの両方として利用できます。これらに関する1つの問題は、node_modulesにインストールされたモジュールによって上書きされる可能性があることです。これは、(誤って発生した場合)セキュリティリスクであり、Node.jsが将来新しい組み込みモジュールを導入しようとした場合に、その名前がnpmパッケージによって既に使用されている場合にも問題となります。

組み込みモジュールをインポートしたいことを明確にするために、node:プロトコルを使用できます。たとえば、次の2つのインポートステートメントはほぼ同等です('fs'という名前のnpmモジュールがインストールされていない場合)。

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

node:プロトコルを使用するもう1つの利点は、インポートされたモジュールが組み込みモジュールであることがすぐにわかることです。組み込みモジュールは非常に多いため、コードを読む際に役立ちます。

node:指定子はプロトコルを持つため、絶対パスと見なされます。そのため、node_modulesでは検索されません。