Quantcast
Channel: Node.jsタグが付けられた新着記事 - Qiita
Viewing all articles
Browse latest Browse all 9008

JSエコシステムぶらり探訪(3): npmとyarnとnode_modules

$
0
0

前回は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の相対パスはリダイレクト先のファイル名を基準にして解決されます。たとえば、以下のような構成を考えます。

./main.js
require('./some-library');
./some-library/package.json
{"name":"some-library","main":"./dist/index.js"}
./some-library/dist/index.js
require('./internal-util.js');
./some-library/dist/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フォルダです。 fsurlが代表的です。

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/barnode_modules/barの両方を参照しうる」という性質は、後述するnpmの実装を説明する上でとても重要なポイントです。

ライブラリ内の別ファイルからのインポート

かつてはhistoryやuuidなど著名なライブラリが、以下のようなインポートを推奨していることがありました。

// historyライブラリのうち、createBrowserHistoryという機能だけを狙ってインポートするconstcreateBrowserHistory=require('history/createBrowserHistory');// uuidライブラリのうち、v4という機能だけを狙ってインポートするconstuuidv4=require('uuid/v4');

この場合、 package.jsonmainフィールドは無視される2ため、 ./node_modules/history/createBrowserHistory.js./node_modules/uuid/v4.jsが参照されることになります。この挙動との一貫性を保つため、こういったパッケージでは通常 index.jsをパッケージルートに置いていました。

そもそもこのようなインポートが好まれていたのはwebpackなどのJavaScriptバンドラー(後述)を使った場合のバンドルサイズの削減を狙ってのことです。Tree shaking(後述) が発達した現在では利点が薄く、historyやuuidの場合も現在は以下のようなインポートが推奨されています。

constcreateBrowserHistory=require('history').createBrowserHistory;constuuidv4=require('uuid').v4;

シンボリックリンクの解決

Node.jsはシンボリックリンクを解決します。このとき、

  • モジュールの同一性は解決後のパスに基づいて判定されます。
  • requireの相対パスは解決後のパスに基づいて行われます。
main.js
constmodule1=require('./module1');// => Loading: module1.jsconstmodule2=require('./module2');// Prints nothingmodule1.printPath();// => __filename = module1.jsmodule2.printPath();// => __filename = module1.js
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.jsonpackage-lock.jsonを使う一方、yarnは yarn.lockを使います。
  • 読み取り用のデフォルトレジストリ。npmは registry.npmjs.orgを使いますが、yarnはそのレプリカである registry.yarnpkg.comからパッケージを取得します。 (レプリカなのでユーザーから見た挙動は同じです)

図: npmレジストリとyarnレジストリの関係

パッケージ

パッケージとは 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段階に分けられます。

  1. 依存解決により、 package.jsonから依存グラフを作成します。
  2. 依存グラフを展開して、依存グラフから node_modulesツリーを作成します。

そうして作成したnode_modulesツリーを書き出すことでnpm install / yarn installの仕事は完了します。

npm install / yarn installの流れ

依存解決

依存解決では、バージョン制約に具体的なバージョンを割り当てていきます。

図: バージョンを選択する

バージョンが割り当てられたら、解決先パッケージの依存をさらに再帰的に解決していきます。

図: 依存を再帰的に解決する

必要なパッケージの依存が全て解決されたら終わりです。

複数のパッケージが同じ制約を持っていたり、複数の制約が同じバージョンに解決されることもあります。そのため、依存解決の結果は木ではなくDAG (無閉路有向グラフ) になります。これをここでは依存グラフと呼びます。

図: 依存グラフの例

依存グラフの展開

npm / yarnの場合、依存グラフができただけでは終わりではありません。Node.jsはnpm / yarnに依存せず動作するので、Node.jsが意図通りのモジュール解決をするようにあらかじめ node_modulesにパッケージを展開しておく必要があります。このとき、依存グラフをどのように node_modulesに展開するかは自明ではありません。

先ほどの依存グラフの例を考えます。

図: 依存グラフの例

npm@2までの展開

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ではこの依存グラフを以下のように展開します。

npmバージョン3以降と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)

この場合 mainbar1のバージョン1に依存しているため、 mainfoo1bar1の依存関係 (バージョン2を指定している) は巻き上げられません。つまり bar1は真の間接依存関係になります。

では bar1の依存関係である baz1baz2はどうなるでしょうか。npm v3以降では、これらはそれぞれ以下のように巻き上げられます。

  • mainbaz1の異なるバージョンに依存しているため、 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.0bar@2.0.0はそれぞれ1回ずつ読み込まれることが保証できます。

npmやyarnがこの方法を使わない理由は不明です。もしかしたら筆者の知らない問題点があるのかもしれませんし、何かシンボリックリンクを使いたくない理由があるのかもしれません6。ただ、現在はtinkやberry (yarn v2)のように node_modulesへのベンダリングを行わない方式のほうが有望視されているので、今から node_modulesをよりうまく使う方法を実装する利点は少なそうです。

ロックファイル

npm/yarnに限らず、パッケージ管理はそのままでは非決定的なのが普通です。npm/yarnの場合は以下の2つの要因で非決定的になります。

  1. 依存解決の非決定性。go modulesを除くほとんどのパッケージ管理ツールはできるだけ大きいバージョンに解決するため、 ^1.2.3という指定が何に解決されるかはレジストリの状態に依存します。 1.2.31.3.0しかなければ 1.3.0に解決されますが、 1.4.0がリリースされた後に再度解決を試みた場合は 1.4.0に解決されます。
  2. 展開の非決定性。間接依存関係を上位の node_modulesに引き上げるとき、どのバージョンが採択されるかは単純なルールでは決まりません。このため後述するような理由で結果が変わる可能性があります。

これらの問題を解決するため、npm/yarnともにロックファイルの仕組みを提供しています。しかし、npmの提供するロックファイルとyarnの提供するロックファイルは少しだけ役割が異なります。

npm install / yarn installの流れ

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.jsonpackage-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経由ではなく、常にソースツリーから扱うアプリケーションの場合も同様のルールに従うのが望ましいですが、実際のところはどちらに置いても実効的な違いはなさそうです。

optionalDependenciesdependenciesと似ていますが、依存先パッケージのインストール失敗を許容します。たとえばchokidarはファイルシステムの変更を監視するライブラリで、macOSでは fseventsライブラリの機能を利用します。 macOS以外では fseventsのインストールに失敗しますが、chokidarfseventsの依存指定は optionalDependencies で行われているため、 npm installには失敗しません。

acceptDependenciesはnpm v7で追加される新しい依存指定ですdevDependenciesと同様、トップレベルパッケージかどうかで挙動を変える効果があります。npm v6以前との互換性を保ちつつ、 engineに関する制約検証が厳しすぎる問題を回避するために実装されたようです。

peerDependencies

peerDependencies自分自身の依存ではなく、依存元パッケージの依存を指定する機能です。

典型的なパターンはプラグインからフレームワークへの依存です。たとえば有名なJavaScriptバンドラーであるWebpackはその機能を webpackパッケージに集約せず、多数のプラグインプラグインパッケージに分けて提供しています。たとえばWebpackにCSSを読み込ませるには css-loaderが必要なので、 webpackcss-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~v6yarn 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-corebabel-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.jsonrequireのリダイレクトに用いる以外はパッケージシステムに関知せず、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をアプリケーションの観点からまとめます。

←前目次


  1. Node.jsのES Modulesサポートではこれに加えて、typeフィールドも参照します。 

  2. 仮に node_modules/history/createBrowserHistory/package.jsonをわざわざ設置すればそれは参照されると思いますが、これは package.jsonの濫用と言ってしまっていいでしょう。 

  3. Node Package Managerの略に由来していると考えられます。古いnpmのFAQには反語的にそのことに触れられています。また、公式サイトのトップには "We're npm, Inc., the company behind Node package manager, the npm Registry, and npm CLI." と記載されています。 

  4. go modulesでは指定できるのは「あるバージョン以上」という制約のみだが、semantic import pathの規則により事実上のキャレット制約 (^1.2.3) として機能する 

  5. キャレット制約では、「指定したバージョン以上かつ、同じメジャーバージョン」であることを要求します。semverではメジャーバージョンが上がるときは互換性のない変更が許されているので、次のメジャーバージョンは自動的には含まれません。マイナーバージョンには機能追加、パッチバージョンにはバグ修正が含まれているため、指定したバージョンより小さいバージョンも含まれません。 

  6. Windowsのようにシンボリックリンクが比較的最近導入され、必ずしもエコシステムとして洗練されていないプラットフォームのためかもしれません。また、webpackなど他のツールで、高速化のためにシンボリックリンクの解決を行わないオプションを持っているものがあるため、そういったツールを使いやすいようにという考えかもしれません。 

  7. 正確には、「同じバージョン制約でも、依存元パッケージによって異なるバージョンに解決される」という状況は排除されていることになります。 foobarがそれぞれ baz@^1.2.3に依存しているのに、 fooからは baz v1.2.3が見えていて barからは baz v1.3.0が見えているような状態です。このようなケースを考える必要性は薄いので、以降ではこういうケースはないものとして考えます。 

  8. 1.22.5 で確認。 

  9. 7.0.0-beta.12 で確認。 


Viewing all articles
Browse latest Browse all 9008

Trending Articles