npm#
npm は最初の依存関係インストールコマンドラインツールであり、以下は npm が依存関係をインストールする手順です:
npm install
コマンドを発行- npm は registry にモジュールの圧縮パッケージの URL を問い合わせる
- 圧縮パッケージをダウンロードし、~/.npm ディレクトリに保存
- 圧縮パッケージを現在のプロジェクトの
node_modules
ディレクトリに解凍する。
注意が必要なのは、npm2 と npm3 の間にはいくつかの違いがあります。
npm2 のネスト地獄#
npm2 は依存関係のインストールが比較的シンプルで直接的であり、パッケージの依存ツリー構造に従ってローカルディレクトリ構造をダウンロードして埋め込みます。つまり、ネストされたnode_modules
構造です。直接の依存項目はnode_modules
の下に置かれ、子依存項目はその直接依存項目のnode_modules
にネストされます。
例えば、プロジェクトが A と C に依存し、A と C が同じバージョンの B@1.0 に依存し、C が D@1.0.0 にも依存している場合、node_modules
の構造は以下のようになります:
node_modules
├── [email protected]
│ └── node_modules
│ └── [email protected]
└── [email protected]
└── node_modules
└── [email protected]
└── [email protected]
同じバージョンの B が A と C によってそれぞれ 2 回インストールされていることがわかります。
依存関係の階層が増え、依存パッケージの数が増えると、次第にネスト地獄が形成されます:
npm3#
フラットネスのネスト
npm2 の問題を解決するために、npm3 は新しい解決策を提案しました。それは依存項目をフラットにすること、つまりフラット化です。
npm v3 は子依存を「昇格」させてフラットなnode_modules
構造を採用し、主依存項目が存在するディレクトリにできるだけ子依存項目をインストールします。
例えば、プロジェクトが A と C に依存し、A が B@1.0.0 に依存し、C が B@2.0.0 に依存している場合、次のようになります:
node_modules
├── [email protected]
├── [email protected]
└── [email protected]
└── node_modules
└── [email protected]
A の子依存項目 B@1.0 はもはや A のnode_modules
の下にはなく、A と同じレベルにあります。バージョン番号の理由から、C が依存する B@2.0 は依然として C のnode_modules
の中にあります。
これにより、大量のパッケージの重複インストールを避けることができ、依存関係の階層が深くなることもなく、依存地獄の問題を解決しました。
では、なぜ B@2.0 をnode_modules
に昇格させず、B@1.0 を昇格させるのでしょうか?B を直接node_modules
に抽出することは、コード内で B パッケージを直接参照できることを意味するのでしょうか?これが次の問題を引き起こします:
不確実性
この処理方法について、私たちは簡単に疑問を持つことができます:もし同じパッケージの異なるバージョンを同時に参照した場合、どのパッケージが抽出されるのでしょうか?npm i
を実行するたびに抽出されるパッケージのバージョンは常に同じですか?これは、同じpackage.json
ファイルを使用しても、依存項目をインストールした後に異なるnode_modules
ディレクトリ構造を得る可能性があることを意味します。
例えば:
- A@1.0.0: B@1.0.0
- C@1.0.0: B@2.0.0
インストール後、B の 1.0 を昇格させるべきか、それとも 2.0 を昇格させるべきか?
node_modules
├── [email protected]
├── [email protected]
└── [email protected]
└── node_modules
└── [email protected]
node_modules
├── [email protected]
│ └── node_modules
│ └── [email protected]
├── [email protected]
└── [email protected]
多くの人は、package.json
の順序に基づいてどのパッケージを抽出するかを決定すると考え、前に出ているパッケージが先に抽出されると考えています。しかし実際には、ソースコードを確認すると、npm は依存項目をソートするためにlocaleCompare
というメソッドを使用します。実際には、辞書順で前にある npm パッケージの下位依存項目が優先的に抽出されます。
幽霊依存
幽霊依存とは、package.json
ファイルにその依存項目がリストされていないが、実際にはプロジェクトでその依存項目が使用されており、フラットネスのネストによりその依存項目に直接アクセスできることを指します。これは不正なアクセス方法です。その中で、dayjs パッケージが最も一般的なケースです。
例えば、私のプロジェクトが arco を使用しているが、arco の子依存項目には dayjs が含まれています。フラット化のルールに従って、dayjs はnode_modules
のトップレベルに配置されます。しかし、これは大きな問題を引き起こします:一旦 arco が dayjs の子依存項目を削除すると、私たちのコードは直接エラーを報告します。
依存分身
依存 B@1.0 の D モジュールと依存 @B2.0 の E モジュールをさらにインストールすると、次のようになります:
- A と D は B@1.0 に依存
- C と E は B@2.0 に依存
以下は B@1.0 を昇格させたnode_modules
の構造です:
node_modules
├── [email protected]
├── [email protected]
├── [email protected]
├── [email protected]
│ └── node_modules
│ └── [email protected]
└── [email protected]
└── node_modules
└── [email protected]
B@2.0 が 2 回インストールされたことがわかります。実際には、B@1.0 を昇格させるか B@2.0 を昇格させるかにかかわらず、重複してインストールされた B バージョンが存在することになります。これらの重複インストールされた B は「doppelgangers」と呼ばれます。
さらに、モジュール C と E は B@2.0 に依存しているように見えますが、実際には同じ B を参照しているわけではありません。もし B がエクスポートされる前に何らかのキャッシュや副作用の処理を行った場合、そのプロジェクトを使用するユーザーはエラーを引き起こす可能性があります。
npm install
npm3 以降のバージョンで依存関係をインストールする手順:
- 設定の確認:npm config と.npmrc 設定を読み取ります。例えば、ミラーソースの設定など。
- 依存バージョンの決定、依存ツリーの構築:
package-lock.json
が存在するか確認します。存在する場合、バージョンを比較し、処理方法は npm のバージョンに依存します。最新の npm バージョンの処理ルールに従い、バージョンが互換性がある場合は package-lock のバージョンでインストールし、そうでない場合は package.json のバージョンでインストールします。存在しない場合は、package.json に基づいて依存パッケージ情報を決定します。 - キャッシュの確認またはダウンロード:キャッシュが存在するか判断します。存在する場合、対応するキャッシュを
node_modules
に解凍し、package-lock.json
を生成します。存在しない場合は、リソースパッケージをダウンロードし、パッケージの完全性を検証してキャッシュに追加し、その後node_modules
に解凍し、package-lock.json
を生成します。
yarn#
並行インストール#
npm または yarn を使用してパッケージをインストールする必要がある場合、一連のタスクが発生します。npm を使用する場合、これらのタスクはパッケージの順序に従って順次実行され、1 つのパッケージが完全にインストールされるまで次のパッケージはインストールされません。
Yarn は並行操作を通じてリソースの利用率を最大限に高めるため、再ダウンロード時のインストール時間は以前よりも速くなります。一方、npm5 以前は、1 つのパッケージがインストールされるのを待ってから次のパッケージをインストールする直列ダウンロード方式を採用していました。
yarn.lock#
私たちは、npm のpackage.json
ファイルがパッケージの構造やバージョンを常に一貫しているわけではないことを知っています。なぜなら、package.json
ファイルの書き方はセマンティックバージョン管理(semantic versioning)に基づいているからです:リリースされたパッチは実質的に変更されていない内容のみを含むべきです。しかし残念ながら、これは常に事実とは一致しません。npm の戦略は、2 つのデバイスが同じpackage.json
ファイルを使用しているが、異なるバージョンのパッケージをインストールすることを引き起こす可能性があり、これが故障を引き起こすことがあります。
異なるバージョンのパッケージを引き出すのを防ぐために、yarn はロックファイル(lock file)を使用して正確にインストールされたモジュールのバージョン番号を記録します。モジュールを追加するたびに、yarn はyarn.lock
という名前のファイルを作成(または更新)します。これにより、同じプロジェクトの依存関係を引き出すたびに、同じモジュールバージョンを使用することが保証されます。
yarn.lock
ファイルはバージョンロックのみを含み、不確実な依存関係の構造を持ち、依存構造を決定するためにpackage.json
ファイルと組み合わせて使用する必要があります。インストールプロセス中に詳細な説明が行われます。
yarn.lock
ロックファイルはすべての依存パッケージをフラットに表示し、同名のパッケージだが互換性のない semver を異なるフィールドとしてyarn.lock
の同じレベルの構造に配置します。
yarn install#
yarn install を実行すると、5 つの段階を経ます:
- package.json の検証(Validating package.json):システムの実行環境をチェックします。OS、CPU、engines などの情報を含みます。
- パッケージの解決(Resolving packages):依存情報を統合します。
- パッケージの取得(Fetching packages):まずキャッシュディレクトリにキャッシュリソースがあるか判断し、次にファイルシステムを読み取ります。どちらも存在しない場合は、Registry からダウンロードします。
- 依存関係のリンク(Linking dependencies):依存関係を node_modules にコピーします。まず peerDependencies 情報を解析し、その後フラット化の原則に基づいて(yarn のフラット化ルールは npm とは異なり、使用頻度の高いバージョンがトップレベルディレクトリにインストールされる。このプロセスは dedupe と呼ばれます)、キャッシュから依存関係を現在のプロジェクトの node_modules ディレクトリにコピーします。
- 新しいパッケージの構築(Building fresh packages):このプロセスでは、install 関連のフックを実行します。preinstall、install、postinstall を含みます。
パッケージの解決(resolving packages):まずプロジェクトのpackage.json
内の dependencies、devDependencies、optionalDependencies フィールドに基づいて最上位の依存集合を形成し、その後ネストされた依存関係を逐次的に再帰的に解析します(解析済みと現在解析中のパッケージを Set データ構造に保存し、同じバージョン範囲内のパッケージが重複して解析されないようにします)。yarn.lock と Registry を組み合わせて、パッケージの具体的なバージョン、ダウンロードアドレス、ハッシュ値、子依存などの情報を取得します(この過程では yarn.lock 優先の原則に従います)。最終的に依存バージョン情報、ダウンロードアドレスを確定します。
プロセスは 2 つの部分にまとめられます:
- 最上位の依存を収集し、package.json 内の dependencies、devDependencies、optionalDependencies の依存リストと workspaces 内のトップレベルパッケージリストを「パッケージ名 @バージョン範囲」の形式で統合して最上位の依存集合を形成します。これは文字列配列として具体化できます。
- すべての依存を遍歴し、依存の具体情報を収集します。最上位の依存集合から出発し、yarn.lock と Registry を組み合わせてパッケージの具体的なバージョン、ダウンロードアドレス、ハッシュ値、子依存などの情報を取得します。
pnpm#
pnpm は performant(高性能な)npm を意味します。pnpm の公式紹介によれば、これは:速度が速く、ディスクスペースを節約するパッケージ管理ツールです。pnpm は本質的にパッケージ管理ツールであり、その 2 つの利点は:
- パッケージインストールの速度が非常に速い
- ディスクスペースの利用が非常に効率的
link メカニズム#
ハードリンク
では、pnpm はどのようにしてこれほどの性能向上を実現しているのでしょうか?これは、コンピュータ内に「ハードリンク」(hard link)というメカニズムが存在するためです。ハードリンクは、ユーザーが異なるパス参照方法で特定のファイルを見つけることを許可します。pnpm はプロジェクトのnode_modules
ディレクトリ内のハードリンクファイルをグローバルストアディレクトリに保存します。
ハードリンクは、ソースファイルの副本と理解できますが、実際にはプロジェクトにインストールされているのはこれらの副本です。これにより、ユーザーはパス参照を通じてソースファイルを見つけることができます。同時に、pnpm はグローバルストアにハードリンクを保存し、異なるプロジェクトがグローバルストアから同じ依存項目を見つけることができるため、ディスクスペースを大幅に節約します。
ハードリンクはインデックスノードを介して接続されます。Linux ファイルシステムでは、ディスクパーティションに保存されている各ファイルには番号が割り当てられ、これをインデックスノード番号(inode index)と呼びます。Linux では、複数のファイル名が同じインデックスノードを指すことができます。例えば、A が B のハードリンクである場合(A と B はファイル名)、A のディレクトリエントリ内の inode ノード番号は B のディレクトリエントリ内の inode ノード番号と同じです。つまり、1 つの inode ノードが 2 つの異なるファイル名に対応し、2 つのファイル名が同じファイルを指します。ファイルシステムにとって、A と B は完全に平等です。どちらかを削除しても、もう一方のアクセスには影響しません。
シンボリックリンク
シンボリックリンクはソフトリンクとも呼ばれ、ショートカットとして理解できます。pnpm はシンボリックリンクを介して対応するディスクディレクトリ内の依存項目のアドレスを見つけることができます。シンボリックリンクファイルは、ソースファイルのマークに過ぎません。ソースファイルを削除すると、リンクファイルは独立して存在できなくなります。ファイル名は保持されますが、シンボリックリンクファイルの内容を見ることはできません。
ファイルを削除すると symlink の内容に影響を与え、ファイルを削除した後に内容を復元しても、symlink と同期を保ちます。リンクファイルは存在しないファイルをリンクすることもでき、これが一般に「断リンク」と呼ばれる現象を引き起こします。
pnpm のリンク
この全く新しいメカニズム設計は非常に巧妙で、node の依存解析を互換性を持たせるだけでなく、以下の問題を解決します:
-
幽霊依存の問題:直接依存項目のみが
node_modules
ディレクトリに展開され、子依存項目は昇格されないため、幽霊依存が発生しません。 -
依存分身の問題:同じ依存項目はグローバルストレージに 1 回だけインストールされます。プロジェクトにはソースファイルの副本のみが含まれ、ほとんどスペースを占有しないため、依存分身の問題はありません。
-
最大の利点はディスクスペースを節約することです。各パッケージはグローバルストアに 1 つだけ保存され、残りはソフトリンクまたはハードリンクです。
不足の点
-
グローバルハードリンクは一部の問題を引き起こす可能性があります。例えば、リンクされたコードを変更すると、すべてのプロジェクトに影響が及びます。また、postinstall(自動インストール後)操作には不向きです。postinstall でコードを変更すると、他のプロジェクトに問題が発生する可能性があります。pnpm はデフォルトで cow(Copy on Write、書き時コピー)戦略を採用していますが、この設定は Mac では機能しません。これは実際には node がサポートしていないためであり、関連する issue を参照できます。
-
pnpm が作成する
node_modules
依存項目はソフトリンクであるため、ソフトリンクをサポートしていない環境では pnpm を使用できません。例えば、Electron アプリケーションなどです。