前回はNode.js単独での機能に焦点をあてて説明しましたが、Node.jsはnpm/yarnなどのパッケージ管理システムと組み合わせて使うことが想定されています。本稿ではライブラリの依存解決としての側面を中心に、npm/yarnの挙動を説明します。
モジュールからパッケージへ
モジュールは、JavaScriptプログラムを複数のファイルに分割し、必要に応じてロードする仕組みでした。 (1ファイル = 1モジュール)
一方パッケージは、複数のモジュールファイルをまとめて1つの独立した単位として扱う仕組みです。パッケージというより大きなまとまりを作ることで、バージョン番号を付与し、パッケージをパッケージレジストリに登録し、依存管理をすることができるようになります。
Node.jsと package.json
パッケージはおおよそ package.json
の存在によって特徴づけることができます。 package.json
は主に後述するNPMで使われるファイルで、そのほとんどの機能についてNode.js自身は関知しません。しかし一部のフィールドだけはNode.js自身によって処理されます。具体的には以下の2つのフィールドです。 1
{
"name": "some-library",
"main": "./lib/some-library.js"
}
これはNode.jsのrequireの発見のために使われます。たとえば require('./some-library')
を行った場合、以下のいずれかのファイルが使われます。
./some-library.js
./some-library.json
./some-library.node
./some-library/${main} (./some-library/package.jsonがある場合)
./some-library/index.js
./some-library/index.node
package.json
によるリダイレクトがあった場合、そこからの require
の相対パスはリダイレクト先のファイル名を基準にして解決されます。たとえば、以下のような構成を考えます。
require('./some-library');
{
"name": "some-library",
"main": "./dist/index.js"
}
require('./internal-util.js');
// ...
この場合、 ./some-library
のインポートは ./some-library/dist/index.js
に解決されます。そこから ./internal-util.js
への相対パスは ./some-library
ではなく ./some-library/dist/index.js
が基準になるので、正しく ./some-library/dist/internal-util.js
に解決されます。
パスからの発見
require
の引数はその接頭辞から次の3種類に分けられます。
-
./
または../
で始まる……相対パス (現在のモジュールファイルからの相対)- 典型的には、「パッケージ内のファイル」を参照するのに使う
-
/
で始まる……絶対パス - それ以外
- 典型的には、「別パッケージのファイル」を参照するのに使う
「それ以外」の場合、真っ先に検索されるのはcoreモジュール、つまりnode自身のlibフォルダです。 fs
や url
が代表的です。
coreモジュールでなかった場合、以下のように探索されます。
./node_modules/
../node_modules/
../../node_modules/
(...以下ルートに至るまでずっと)
$NODE_PATH/ ($NODE_PATH: NODE_PATH環境変数の要素)
$HOME/.node_modules ($HOME: ホームフォルダ)
$HOME/.node_libraries ($HOME: ホームフォルダ)
$PREFIX/lib/node ($PREFIX: Node.jsの定義するprefix)
$NODE_PATH
は $PATH
と同様、コロン (Windowsではセミコロン) 区切りで複数指定できます。
こんにちのNode.js開発ではライブラリをグローバルに入れることは推奨されていないため、 node_modules
以外のルールは基本的に忘れてOKです。
ここで大事なのは、Node.jsのパス解決は親ディレクトリに対して再帰的に検索をかけるという特徴です。「node_modules/foo
内のモジュールは node_modules/foo/node_modules/bar
と node_modules/bar
の両方を参照しうる」という性質は、後述するnpmの実装を説明する上でとても重要なポイントです。
ライブラリ内の別ファイルからのインポート
かつてはhistoryやuuidなど著名なライブラリが、以下のようなインポートを推奨していることがありました。
// historyライブラリのうち、createBrowserHistoryという機能だけを狙ってインポートする
const createBrowserHistory = require('history/createBrowserHistory');
// uuidライブラリのうち、v4という機能だけを狙ってインポートする
const uuidv4 = require('uuid/v4');
この場合、 package.json
の main
フィールドは無視される2ため、 ./node_modules/history/createBrowserHistory.js
や ./node_modules/uuid/v4.js
が参照されることになります。この挙動との一貫性を保つため、こういったパッケージでは通常 index.js
をパッケージルートに置いていました。
そもそもこのようなインポートが好まれていたのはwebpackなどのJavaScriptバンドラー(後述)を使った場合のバンドルサイズの削減を狙ってのことです。Tree shaking(後述) が発達した現在では利点が薄く、historyやuuidの場合も現在は以下のようなインポートが推奨されています。
const createBrowserHistory = require('history').createBrowserHistory;
const uuidv4 = require('uuid').v4;
シンボリックリンクの解決
Node.jsはシンボリックリンクを解決します。このとき、
- モジュールの同一性は解決後のパスに基づいて判定されます。
-
require
の相対パスは解決後のパスに基づいて行われます。
const module1 = require('./module1'); // => Loading: module1.js
const module2 = require('./module2'); // Prints nothing
module1.printPath(); // => __filename = module1.js
module2.printPath(); // => __filename = module1.js
console.log(`Loading: ${__filename}`);
exports.printPath = () => {
console.log(`__filename = ${__filename}`);
};
ln -s module1.js module2.js
npm
以上はNode.jsの提供する機能ですが、それ単体ではあまり便利ではありません。これらの機能は、npm3と組み合わせることで真価を発揮します。
npmの仕事は、パッケージのバージョンと依存関係を管理し、それにもとづいて node_modules
以下にパッケージのコピーを配置 (ベンダリング) することです。
他のパッケージ管理ツールとの主な比較は以下の通りです:
npmの特徴1: プロジェクトローカルに依存解決を行う。
たとえばRubyのRubygemsやPythonのpipを単独で使った場合、依存関係はシステムディレクトリ (/usr/local/lib
など) やユーザーディレクトリ (/home/user/
内の隠しディレクトリなど) に保存され、そのバージョンがそのまま使われます。この方式では依存関係のバージョンに関して再現性が低かったり、複数のプロジェクトを同じ環境で同時に開発しようとしたときに無用なコンフリクトが起こるといった問題があります。
npmはRustのCargo, goのgo modulesなどと同じくプロジェクトごとに依存バージョンを管理します。
npmの特徴2: ベンダリングを行う。
RubyのBundlerやGoのgo modulesは(デフォルトでは)依存ライブラリのデータ自体はシステムディレクトリやユーザーディレクトリに保存し、実行時やビルド時に必要なバージョンを選んで使うようになっています。
npmはNode.jsの制約上、必ず依存関係をプロジェクト内の node_modules
ディレクトリにコピーして作業します。
npmの特徴3: 依存グラフ内に複数バージョンが共存可能。
たとえばRubyの場合全てのパッケージはグローバルの名前空間を汚染します。使われる名前(トップレベルのモジュール名)はパッケージ名と同じにする慣習があるため、複数バージョンを同時に利用してうまくいく可能性はほとんどありません。そういった理由もあってか、bundlerでは同じ名前のパッケージの複数バージョンを同時に使うことはできません。
Node.jsでは(特に意図して書かない限り)グローバルの名前空間は汚染せず、お互いのモジュールは互いに干渉せず存在できます。そのため、Rust(Cargo)などと同じく、同じ名前のパッケージの複数のバージョンが同時に存在できます。
(Go(go modules)も複数のメジャーバージョンが共存できるため、ある意味では同じ性質を持っていると考えることもできます。)
npmの特徴4: 中央集権的なレジストリを持つ。
Ruby, Python, Rustなどはそれぞれ中央集権的なレジストリがあり、 rails
, numpy
, serde
のようにシンプルな名前がつきます。npmも同様の仕組みをとっています。
Goは github.com/go-yaml/yaml
のようにURLのような形でパッケージ名を記述し、依存解決時は実際に対応するgitリポジトリからパッケージが取得されます。denoも https://deno.land/std@0.68.0/testing/asserts.ts
のようにURLで依存先モジュールを指定します。
中央集権的なレジストリではname squattingの問題が顕在化しやすいと考えられます。なお、npmはleft-pad問題を起こしたことがありますが、Goもgo-bindata問題を起こしたことがあり、必ずしも中央集権的なレジストリの問題とは言えないでしょう。
npmとyarn
Node.jsのパッケージ管理ツールはnpmだけではありません。ここではnpmと互換性の高く、広く使われているyarn (yarn v1, classic yarn)についても説明します。
yarnはFacebookが中心となって作っているnpmの代替パッケージ管理ツールです。yarnの仕事はnpmとほぼ同じです。つまり、パッケージのバージョンと依存関係を管理し、それにもとづいて node_modules
以下にパッケージのコピーを配置 (ベンダリング) します。以下が主な差異です。
- 依存解決時の詳細な挙動。
- ロックファイルの種類。npmは
npm-shrinkwrap.json
とpackage-lock.json
を使う一方、yarnはyarn.lock
を使います。 - 読み取り用のデフォルトレジストリ。npmは
registry.npmjs.org
を使いますが、yarnはそのレプリカであるregistry.yarnpkg.com
からパッケージを取得します。 (レプリカなのでユーザーから見た挙動は同じです)
パッケージ
パッケージとは package.json
を含むディレクトリまたはアーカイブファイル (tar.gz) です。 package.json
にはさまざまな情報を乗せられますが、パッケージ管理の観点から必要な情報だけを説明すると以下のようになります。
{
"name": "some-package",
"version": "0.1.0",
// 依存関係とそのバージョン制約
"dependencies": {
"library1": "^0.3.1",
"library2": ">= 1.2.0, < 3.0.0"
},
// このパッケージ自身を開発するときに必要な依存関係
"devDependencies": {
"testing-library1": "^1.2.3",
"linter1": "2.x"
},
// 同時に入れるべきパッケージとそのバージョン
"peerDependencies": {
"state-management-library1": ">= 10.2"
},
// dependenciesと似ているが、依存先のインストールが失敗しても無視する
"optionalDependencies": {
"windows-specific-library": "3.x"
}
}
他の多くのパッケージ管理ツールと同じく、依存関係にはバージョンの範囲 (バージョンの制約) を指定します。たとえば ^1.2.3
なら厳密にバージョン 1.2.3
である必要はなく、それより大きなバージョンでも (メジャーバージョンが1であれば) OKという指定になります。これは
Rust (Cargo) や Go (go modules)4と違い、npmでは単に 1.2.3
と書くと =1.2.3
の意味になってしまうので注意が必要です。npmのパッケージはsemantic versionに従っていることが期待されていますから、基本的にはキャレット制約 (^1.2.3
) を使っておけばいいでしょう。5
npm install / yarn installの流れ
npm / yarnの処理は大きく2段階に分けられます。
-
依存解決により、
package.json
から依存グラフを作成します。 - 依存グラフを展開して、依存グラフから node_modulesツリーを作成します。
そうして作成したnode_modulesツリーを書き出すことでnpm install / yarn installの仕事は完了します。
依存解決
依存解決では、バージョン制約に具体的なバージョンを割り当てていきます。
バージョンが割り当てられたら、解決先パッケージの依存をさらに再帰的に解決していきます。
必要なパッケージの依存が全て解決されたら終わりです。
複数のパッケージが同じ制約を持っていたり、複数の制約が同じバージョンに解決されることもあります。そのため、依存解決の結果は木ではなくDAG (無閉路有向グラフ) になります。これをここでは依存グラフと呼びます。
依存グラフの展開
npm / yarnの場合、依存グラフができただけでは終わりではありません。Node.jsはnpm / yarnに依存せず動作するので、Node.jsが意図通りのモジュール解決をするようにあらかじめ node_modules
にパッケージを展開しておく必要があります。このとき、依存グラフをどのように node_modules
に展開するかは自明ではありません。
先ほどの依存グラフの例を考えます。
npm@2までの展開
npmのバージョン2まではこの依存グラフを以下のように展開します:
node_modules
ディレクトリの中にまた node_modules
ディレクトリがあることに注意してください。依存グラフに同じパッケージの複数のバージョンがある場合、全てのバージョンをルートの node_modules
にそのまま入れることはできません。そこで、npmのバージョン2までは、間接依存関係は全てルートではなく対応するパッケージの node_modules
に個別に展開していました。
この方法はいわゆる依存関係地獄(複数のバージョンが共存できない)を解決しますが、同じパッケージの同じバージョンが複数回展開されるという問題があります。これは以下の弊害をもたらします:
-
ただでさえ大きい
node_modules
のサイズがさらに大きくなってしまう。 - Node.jsから見て同じモジュールにならないため、副作用のあるモジュールやオブジェクトの同一性が重要になるモジュールでは期待しない挙動になる可能性がある。
npm@3以降とyarnの展開: パッケージの巻き上げ
残念ながら最新のnpm/yarnでも上記の問題は解決されていませんが、npmバージョン3以降とyarnでは緩和策が実装されています。これらのバージョンのnpm/yarnではこの依存グラフを以下のように展開します。
つまり、間接依存パッケージから1つのバージョンを選んで、より上位の node_modules
に配置することができます。これにより多くの場合でパッケージが共有されるようになります。Node.jsの require
はよりインポート元ファイルに近い node_modules
から順番に探索するので、依然として依存グラフ通りのバージョンがrequireされます。
この方法をパッケージの巻き上げ (hoisting) と呼びます。
パッケージの巻き上げの問題として、依存グラフの展開の非決定性があります。これについては後述します。
間接依存の巻き上げ
間接依存も可能な限り巻き上げられます。たとえば、npm v2で以下のように展開されるような依存グラフを考えます。
main
|- foo1 (1.0.0)
| |- bar1 (2.0.0)
| |- baz1 (1.0.0)
| |- baz2 (2.0.0)
|- bar1 (1.0.0)
|- baz2 (1.0.0)
この場合 main
は bar1
のバージョン1に依存しているため、 main
→foo1
→bar1
の依存関係 (バージョン2を指定している) は巻き上げられません。つまり bar1
は真の間接依存関係になります。
では bar1
の依存関係である baz1
と baz2
はどうなるでしょうか。npm v3以降では、これらはそれぞれ以下のように巻き上げられます。
-
main
がbaz1
の異なるバージョンに依存しているため、baz2
は2段階は巻き上げられません。しかし、1段階の巻き上げは可能です。 -
baz1
は2段階巻き上げが可能なので、2段階巻き上げられます。
結果として以下のようなnode_modulesツリーが生成されます。
main
|- foo1 (1.0.0)
| |- bar1 (2.0.0)
| |- baz2 (2.0.0)
|- bar1 (1.0.0)
|- baz1 (1.0.0)
|- baz2 (1.0.0)
おまけ: シンボリックリンクを使った展開
同じパッケージの同じバージョンが複数回インストールされる問題を解決することは論理的には可能で、シンボリックリンクを使った方法があります。この方法はNode.jsのドキュメントに「OSのパッケージ管理ツール向けの方法」として紹介されていますが、 node_modules
にも適用可能なはずです。
シンボリックリンクがある場合は、シンボリックリンクの解決後のパスに基づいて同一性が判定されるため、このようにしておけば bar@1.0.0
と bar@2.0.0
はそれぞれ1回ずつ読み込まれることが保証できます。
npmやyarnがこの方法を使わない理由は不明です。もしかしたら筆者の知らない問題点があるのかもしれませんし、何かシンボリックリンクを使いたくない理由があるのかもしれません6。ただ、現在はtinkやberry (yarn v2)のように node_modules
へのベンダリングを行わない方式のほうが有望視されているので、今から node_modules
をよりうまく使う方法を実装する利点は少なそうです。
ロックファイル
npm/yarnに限らず、パッケージ管理はそのままでは非決定的なのが普通です。npm/yarnの場合は以下の2つの要因で非決定的になります。
- 依存解決の非決定性。go modulesを除くほとんどのパッケージ管理ツールはできるだけ大きいバージョンに解決するため、
^1.2.3
という指定が何に解決されるかはレジストリの状態に依存します。1.2.3
と1.3.0
しかなければ1.3.0
に解決されますが、1.4.0
がリリースされた後に再度解決を試みた場合は1.4.0
に解決されます。 - 展開の非決定性。間接依存関係を上位の
node_modules
に引き上げるとき、どのバージョンが採択されるかは単純なルールでは決まりません。このため後述するような理由で結果が変わる可能性があります。
これらの問題を解決するため、npm/yarnともにロックファイルの仕組みを提供しています。しかし、npmの提供するロックファイルとyarnの提供するロックファイルは少しだけ役割が異なります。
yarn.lock
yarn.lock
はyarnが生成するロックファイルで、依存解決の結果、つまり依存グラフを記録します。以下がその例です。
ajv@^5.3.0:
version "5.5.2"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-5.5.2.tgz#73b5eeca3fab653e3d3f9422b341ad42205dc965"
integrity sha512-Ajr4IcMXq/2QmMkEmSvxqfLN5zGmJ92gHXAeOXq1OekoH2rfDNsgdDoL2f7QaRCy7G/E6TpxBVdRuNraMztGHw==
dependencies:
co "^4.6.0"
fast-deep-equal "^1.0.0"
fast-json-stable-stringify "^2.0.0"
json-schema-traverse "^0.3.0"
ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2:
version "6.10.2"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.2.tgz#d3cea04d6b017b2894ad69040fec8b623eb4bd52"
integrity sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==
dependencies:
fast-deep-equal "^2.0.1"
fast-json-stable-stringify "^2.0.0"
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"
ajv@^6.12.0:
version "6.12.0"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.0.tgz#06d60b96d87b8454a5adaba86e7854da629db4b7"
integrity sha512-D6gFiFA0RRLyUbvijN74DWAjXSFxWKaWP7mldxkVhyhAV3+SWA9HEJPHQ2c9soIeTFJqcSdFDGFgdqs1iUU2Hw==
dependencies:
fast-deep-equal "^3.1.1"
fast-json-stable-stringify "^2.0.0"
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"
ajv@^6.12.2:
version "6.12.3"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.3.tgz#18c5af38a111ddeb4f2697bd78d68abc1cabd706"
integrity sha512-4K0cK3L1hsqk9xIb2z9vs/XU+PGJZ9PNpJRDS9YLzmNdX6jmVPfamLvTJr0aDAusnHyCHO6MjzlkAsgtqp9teA==
dependencies:
fast-deep-equal "^3.1.1"
fast-json-stable-stringify "^2.0.0"
json-schema-traverse "^0.4.1"
uri-js "^4.2.2"
実際にはこのようなエントリがたくさん並んでいます。
ハッシュ値なども記録されていますが本質的には {バージョン制約→解決されたバージョン}
のマップになっています。これは依存グラフを1つ指定していることにほかなりません7。
つまり yarn.lock
自体は依存解決 (依存グラフの生成)までの結果を保証しますが、それ自体では依存グラフの展開までの結果は保証しません。yarnは「決定論的で信頼できるアルゴリズム (deterministic and reliable algorithm)」を使うことで後半の再現性を保証するアプローチを取っています。
npm-shrinkwrap.json / package-lock.json
一方npmは npm-shrinkwrap.json
/ package-lock.json
のどちらかを生成します。この2つは同じフォーマットで同じ情報を記録します。
{
"name": "main",
"version": "0.1.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"ajv": {
"version": "6.10.2",
"requires": {
"fast-deep-equal": "^2.0.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
},
"dependencies": {
"fast-deep-equal": {
"version": "2.0.1"
}
}
},
// ...
"babel-loader": {
"version": "8.1.0",
"requires": { /* ... */ },
"dependencies": {
"ajv": {
"version": "6.12.0",
"requires": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"uri-js": "^4.2.2"
}
},
// ...
}
},
// ...
"har-validator": {
"version": "5.1.0",
"requires": { /* ... */ },
"dependencies": {
"ajv": {
"version": "5.5.2",
"requires": {
"co": "^4.6.0",
"fast-deep-equal": "^1.0.0",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.3.0"
}
},
// ...
}
},
}
}
yarn.lockがフラットな一覧であったのに対して、こちらは入れ子構造になっています。これは node_modules
に展開したときの構造をそのまま記録しているからです。yarn.lockと違い、 ./node_modules/ajv
にどのバージョンがインストールされたかも一目瞭然です。
つまり、 npm-shrinkwrap.json
/ package-lock.json
は ./node_modules
以下の構造を再現するに足りる情報を持っていることになります。
ただし、歴史的には npm-shrinkwrap.json
を使っても ./node_modules
に再現性がないと言われていた時期がありました。これはおそらく npm-shrinkwrap.json
の読み取りが正しく行われていなかったか、 npm-shrinkwrap.json
が自動で書き出されない挙動になっていたかのどちらかだと思うのですが、これは調査しても正確なところがわからなかったので保留します。
npm-shrinkwrap.json
と package-lock.json
の違いは以下の通りです。
-
npm-shrinkwrap.json
はnpmの初期から存在したが、package-lock.json
はnpmバージョン5で登場した。 - 両方存在する場合は
npm-shrinkwrap.json
が優先される。 - どちらも存在しない場合は
package-lock.json
が自動的に生成される。 (npmバージョン5以降) -
package-lock.json
はレジストリに公開するパッケージには含められない。npm-shrinkwrap.json
は含められる。 - トップレベル以外のパッケージに
package-lock.json
があっても無視される。npm-shrinkwrap.json
は考慮される。
devDependencies
, optionalDependencies
, acceptDependencies
devDependencies
は自分自身がトップレベルパッケージである場合のみ指定される依存関係です。
ライブラリの場合、そのライブラリ自体を開発するときにしか使わないものは通常、 devDependencies
に指定します。よくあるのは、リントツール (eslint), フォーマッター (prettier), テスト (jest), トランスパイラ (typescript), 型情報 (@types/*
) などです。ただし、 devDependencies
に入れるべきかどうかは、ライブラリの種類によって決まるのではなく、そのライブラリをどう使うかによって決まることに注意が必要です。たとえば eslint
を呼び出すラッパーライブラリであれば eslint
は実行時にも必要になるため、 dependencies
に入れるほうが適切だと考えられます。
npm経由で配布するアプリケーションの場合、そのアプリケーションを起動するのに必要ないものは devDependencies
に置くとよいでしょう。npm経由ではなく、常にソースツリーから扱うアプリケーションの場合も同様のルールに従うのが望ましいですが、実際のところはどちらに置いても実効的な違いはなさそうです。
optionalDependencies
は dependencies
と似ていますが、依存先パッケージのインストール失敗を許容します。たとえばchokidar
はファイルシステムの変更を監視するライブラリで、macOSでは fsevents
ライブラリの機能を利用します。 macOS以外では fsevents
のインストールに失敗しますが、chokidar
→fsevents
の依存指定は optionalDependencies で行われているため、 npm install
には失敗しません。
acceptDependencies
はnpm v7で追加される新しい依存指定です。 devDependencies
と同様、トップレベルパッケージかどうかで挙動を変える効果があります。npm v6以前との互換性を保ちつつ、 engine
に関する制約検証が厳しすぎる問題を回避するために実装されたようです。
peerDependencies
peerDependencies
は自分自身の依存ではなく、依存元パッケージの依存を指定する機能です。
典型的なパターンはプラグインからフレームワークへの依存です。たとえば有名なJavaScriptバンドラーであるWebpackはその機能を webpack
パッケージに集約せず、多数のプラグインプラグインパッケージに分けて提供しています。たとえばWebpackにCSSを読み込ませるには css-loader
が必要なので、 webpack
と css-loader
の両方への依存を書くことになりますが、このバージョンの組み合わせによっては正しく動かない可能性があります。 (webpack
だけ古すぎる or css-loader
だけ古すぎる)
そこで、 css-loader
側の package.json
には、そのバージョンの css-loader
と組み合わせて使うことができる webpack
のバージョンが記載されています。たとえば css-loader
4.3.0 の場合
"peerDependencies": {
"webpack": "^4.27.0 || ^5.0.0"
}
と書かれているため、バージョン 4.27.0以上6未満のwebpackと組み合わせることが想定されていることになります。これは css-loader
自身の依存ではなく、 css-loader
を利用する側のパッケージからの webpack
の依存に関する指定ととらえることができます。
意図自体は以上の通りなのですが、peerDependenciesが指定されたときの実際の挙動はnpmのバージョンによって異なります。
主な違い
- npm v1~v2では、peerDependenciesに記載されたパッケージは自動的にインストールされ、バージョン制約上不可能な場合はエラーになります。
- npm v3~v6およびyarn v18では、peerDependenciesの記載は警告にのみ使われます。 (指定したパッケージが存在しない場合と、バージョン制約が正しくないときに警告が出る)
- npm v79では、peerDependenciesに記載されたパッケージは自動的にインストールされ、バージョン制約上不可能な場合 (=別の理由で別のバージョンが既にインストールされている場合) は警告が表示されます。
peerDependenciesで指定したパッケージが存在しなかったときの挙動
「webpack
への依存を書き忘れた場合」に相当する挙動は、npmのバージョンによって異なります。
npm v1~v2は、peerDependenciesの内容は自動的にインストールされます。 css-loader
への依存を書けば、 webpack
も自動的にインストールされることになります。
npm v3~v6では、peerDependenciesの内容は自動的にはインストールされません。存在チェックだけが行われ、存在しない場合は警告が表示されます。
npm v7-betaでは、peerDependenciesの内容は自動的にインストールされます。 --legacy-peer-deps
を指定するとv3~v6と同様の挙動になります。
yarn v1はnpm v3~v6と同様です。
peerDependenciesのバージョン制約が満たされなかったときの挙動
「webpack
の依存だけ古すぎる場合」に相当する挙動は、npmのバージョンによって異なります。
npm v1~v2ではエラーになります。
npm v3以降とyarn v1では警告が発生し、peerDependenciesに指定したバージョンのインストールは諦められます。
peerDependenciesで指定したパッケージが存在しなかったが、別の間接依存からの巻き上げによって偶然制約が満たされた場合の挙動
「webpack
への依存を書き忘れたが、たまたま他の依存からの巻き上げでwebpackが降りてきた場合」に相当する挙動は、npmのバージョンによって異なります。
npm v1~v2では巻き上げが存在しません。
npm v3~では制約が充足されたものとして扱われ、警告は発生しません。
yarn v1では警告が発生します。
peerDependenciesで指定したパッケージが存在しなかったが、別の間接依存からの巻き上げによって制約を満たさないバージョンが含まれた場合の挙動
「webpack
への依存を書き忘れたが、たまたま他の依存からの巻き上げでwebpackが降りてきた場合」に相当する挙動は、npmのバージョンによって異なります。
npm v1~v2では巻き上げが存在しません。
npm v3~v6とyarn v1では警告が発生し、peerDependenciesに指定したバージョンのインストールは諦められます。
npm v7では巻き上げより先にpeerDependenciesのほうが解決されるため、巻き上げが発生しません。
bundledDependencies
bundledDependencies
は他の *Dependencies
系のフィールドと異なり、パッケージ名の配列です。 (つまり、他の dependencies
系のフィールドと併用します)
通常 npm pack
は ./node_modules
以下をアーカイブに含めませんが、当該パッケージが bundledDependencies
で指定されている場合はそのディレクトリに限り含まれるようです。
使う機会は多くないようなので詳細は省きます。
スコープ
npmバージョン2以降ではスコープがサポートされています。通常npmのパッケージは babel-core
のようにスラッシュを含まない名前が使われますが、 @
で始まる名前はスコープと呼ばれ、スラッシュ区切りの接頭辞として扱われます。たとえば @babel/core
は @babel
というスコープに含まれるパッケージになります。
スコープは主に以下のような目的で使われます。
- npm公式レジストリ上で、まとまった名前空間の利用権を予約する。
- npm公式レジストリ上でプライベートパッケージを公開する。 (プライベートパッケージ用のスコープが必要になる)
- 特定のスコープだけ、別のレジストリを参照させる。
スコープは依存解決の観点からは特別な挙動はないですが、ファイルシステム上に展開するときには名前の通り2層のディレクトリに展開されます。たとえば babel-core
と babel-runtime
に依存する場合には以下のように展開されますが、
main/
|- node_modules/
|- babel-core/
|- package.json
|- babel-runtime/
|- package.json
@babel/core
と @babel/runtime
に依存する場合は以下のようになります。
main/
|- node_modules
|- @babel/
|- core/
|- package.json
|- runtime/
|- package.json
Node.jsから見れば、 require("babel-core")
が require("@babel/core")
になるだけですが、TypeScriptがDefinitelyTypedの型定義を参照するときなど、この構造が利用されることもあります。
存在しないパッケージのrequire
npmは packages.json
に書いたパッケージが(そのパッケージ内から) require
可能なようにパッケージを展開しますが、Node.jsの動作原理上、 packages.json
に書いていないパッケージも偶然 require
できてしまう場合があります。
- 間接依存が巻き上げられた場合。
- 直接または間接的な依存元が、そのパッケージに依存している場合。
- 複数の
package.json
からなるプロジェクトで、親ディレクトリでもnpm install
が行われている場合。
もし "Cannot find module" が特定の環境でのみ起こる場合、こういったケースを疑ってみるとよさそうです。
まとめ
- Node.js自身は
package.json
をrequire
のリダイレクトに用いる以外はパッケージシステムに関知せず、npm/yarnなどのパッケージマネージャとの併用が想定されている。 - 現在主流の方式 (npm, yarn v1など) では、
./node_modules
ディレクトリに依存パッケージをベンダリングする方式が採られている。この制約上、npm/yarnは単に依存グラフの生成を行うだけではなく、それをファイルシステム上に展開するために配置方法を決定する責務も負っている。 - このとき、依存地獄を防ぎつつモジュールツリーの爆発を防ぐ(またできるだけ同一パッケージのモジュール同一性を担保する)ために、巻き上げという方法が使われる。巻き上げはnpm v3以降で導入された。
- npmとyarnは異なるロックファイルの形式を採用しており、これらは上記の2段階解決 (依存グラフの生成→ファイルシステム上の配置方法の決定) の1段階目の結果と2段階目の結果にそれぞれ対応している。
- npmはv5以降では自動的にロックファイルを生成するようになり、ロックファイルの信頼性が向上した。
- npmではdependencies, devDependenciesのほかにpeerDependenciesという特殊な依存記述がある。これは依存元の依存関係を追加指定する効果があるが、挙動がバージョンによって異なる。
- npm v2以前ではpeerDependenciesの記述は強制されていた。
- npm v3~v6およびyarnではpeerDependenciesの記述は警告のためだけに使われていた。
- npm v7ではpeerDependenciesの記述は可能な限り尊重されるが、制約充足に失敗してもエラーにはならない。
- 存在しないパッケージのrequireが成功してしまうケースがいくつかある。特に複数の
package.json
をもつプロジェクトでは注意が必要。
本稿ではnpm/yarnのライブラリベンダリングに注目してまとめましたが、npm/yarnにはコマンドラインアプリケーションを管理する機能もあります。次回はnpm/yarnをアプリケーションの観点からまとめます。
-
Node.jsのES Modulesサポートではこれに加えて、typeフィールドも参照します。 ↩
-
仮に
node_modules/history/createBrowserHistory/package.json
をわざわざ設置すればそれは参照されると思いますが、これはpackage.json
の濫用と言ってしまっていいでしょう。 ↩ -
Node Package Managerの略に由来していると考えられます。古いnpmのFAQには反語的にそのことに触れられています。また、公式サイトのトップには "We're npm, Inc., the company behind Node package manager, the npm Registry, and npm CLI." と記載されています。 ↩
-
go modulesでは指定できるのは「あるバージョン以上」という制約のみだが、semantic import pathの規則により事実上のキャレット制約 (
^1.2.3
) として機能する ↩ -
キャレット制約では、「指定したバージョン以上かつ、同じメジャーバージョン」であることを要求します。semverではメジャーバージョンが上がるときは互換性のない変更が許されているので、次のメジャーバージョンは自動的には含まれません。マイナーバージョンには機能追加、パッチバージョンにはバグ修正が含まれているため、指定したバージョンより小さいバージョンも含まれません。 ↩
-
Windowsのようにシンボリックリンクが比較的最近導入され、必ずしもエコシステムとして洗練されていないプラットフォームのためかもしれません。また、webpackなど他のツールで、高速化のためにシンボリックリンクの解決を行わないオプションを持っているものがあるため、そういったツールを使いやすいようにという考えかもしれません。 ↩
-
正確には、「同じバージョン制約でも、依存元パッケージによって異なるバージョンに解決される」という状況は排除されていることになります。
foo
とbar
がそれぞれbaz@^1.2.3
に依存しているのに、foo
からはbaz v1.2.3
が見えていてbar
からはbaz v1.3.0
が見えているような状態です。このようなケースを考える必要性は薄いので、以降ではこういうケースはないものとして考えます。 ↩ -
1.22.5 で確認。 ↩
-
7.0.0-beta.12 で確認。 ↩