90
82

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-10-03

前回は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という機能だけを狙ってインポートする
const createBrowserHistory = require('history/createBrowserHistory');
// uuidライブラリのうち、v4という機能だけを狙ってインポートする
const uuidv4 = require('uuid/v4');

この場合、 package.jsonmain フィールドは無視される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 の相対パスは解決後のパスに基づいて行われます。
main.js
const module1 = require('./module1'); // => Loading: module1.js
const module2 = require('./module2'); // Prints nothing

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

  7. 1.22.5 で確認。

  8. 7.0.0-beta.12 で確認。

90
82
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
90
82

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?