node_modules
node_modulesは何のためにある?
node_modulesは、npm installでインストールされたパッケージが保存されるディレクトリです。プロジェクトのルートディレクトリに自動的に作成されます。
shellnpm install
shellnpm install
このコマンドを実行すると、package.jsonに記載されたパッケージがダウンロードされ、node_modulesディレクトリの中に配置されます。TypeScriptやJavaScriptのコードでimportを使ってパッケージを読み込むとき、このディレクトリからパッケージが探されます。
ts// node_modulesの中にあるzodパッケージを読み込むimport {z } from "zod";
ts// node_modulesの中にあるzodパッケージを読み込むimport {z } from "zod";
つまり、node_modulesはプロジェクトが利用するパッケージの保管場所です。本棚のようなもので、必要なパッケージ(本)がすべてこの中に並んでいます。
node_modulesのgit管理
gitにはコミットしない
node_modulesはgitにコミットしてはいけません。.gitignoreファイルにnode_modules/を追加して、gitの管理対象から除外します。
.gitignoreに次の1行を追加してください。
.gitignoretextnode_modules/
.gitignoretextnode_modules/
なぜgitにコミットしないのか
node_modulesをgitにコミットしない理由は主に3つあります。
- サイズが非常に大きい: 小さなプロジェクトでも
node_modulesは数百MBになることがあります。gitリポジトリにこれを含めると、クローンやプルに時間がかかり、リポジトリのサイズも肥大化します。 - ロックファイルから再現できる: ロックファイルがあれば
npm installを実行するだけで、まったく同じ内容のnode_modulesを再現できます。わざわざgitで管理する必要がありません。 - OS依存のバイナリが含まれることがある: 一部のパッケージはOS(Windows, macOS, Linuxなど)に応じた実行ファイル(バイナリ)を含みます。あるOSで生成された
node_modulesを別のOSで使おうとすると、正しく動作しないことがあります。
プロジェクトを新しくクローンしたときや、node_modulesを削除してしまったときは、npm installを実行すれば復元できます。慌てる必要はありません。
複数のパッケージが入る構造
node_modulesの中には、インストールしたパッケージごとにディレクトリが作られます。
textnode_modules/├── zod/│ ├── package.json│ ├── lib/│ └── ...├── express/│ ├── package.json│ ├── lib/│ └── ...└── typescript/├── package.json├── bin/└── ...
textnode_modules/├── zod/│ ├── package.json│ ├── lib/│ └── ...├── express/│ ├── package.json│ ├── lib/│ └── ...└── typescript/├── package.json├── bin/└── ...
各パッケージのディレクトリの中にもpackage.jsonがあります。このpackage.jsonには、そのパッケージ自身の名前やバージョン、さらにそのパッケージが依存している別のパッケージの情報が記載されています。
推移的依存関係
パッケージAがパッケージBに依存し、パッケージBがパッケージCに依存しているということがあります。このように、直接インストールしたパッケージが間接的に依存しているパッケージのことを推移的依存関係(transitive dependencies)と呼びます。
たとえば、あなたがexpressをインストールしたとします。expressは内部で複数のパッケージに依存しています。それらのパッケージもまた別のパッケージに依存しています。こうした推移的依存関係にあるパッケージもすべてnode_modulesの中にインストールされます。
そのため、package.jsonに書かれているパッケージは数個でも、node_modulesの中には数十から数百のパッケージが入っていることは珍しくありません。
ホイスティングとファントム依存
npmは推移的依存関係のパッケージもnode_modulesの直下に配置します。この動作をホイスティング(hoisting: 巻き上げ)と呼びます。ホイスティングにより、同じパッケージの重複インストールを減らし、ディスク容量を節約できます。
textnode_modules/├── express/ ← 自分がインストールしたパッケージ├── body-parser/ ← expressの推移的依存関係(ホイスティングされた)├── cookie/ ← 同上└── ...
textnode_modules/├── express/ ← 自分がインストールしたパッケージ├── body-parser/ ← expressの推移的依存関係(ホイスティングされた)├── cookie/ ← 同上└── ...
しかし、ホイスティングには副作用があります。node_modulesの直下にパッケージが並ぶため、package.jsonに書いていないパッケージでもimportできてしまうのです。このように、宣言していない推移的依存関係にアクセスできてしまう問題をファントム依存(phantom dependency)と呼びます。
ts// package.jsonにはexpressだけ書いてあり、cookieは書いていない// しかし、npmではこのimportが動いてしまうimport cookie from "cookie";
ts// package.jsonにはexpressだけ書いてあり、cookieは書いていない// しかし、npmではこのimportが動いてしまうimport cookie from "cookie";
これは一見便利ですが、expressがバージョンアップでcookieを使わなくなると、突然importが失敗するリスクがあります。
pnpmでは、node_modulesの直下に直接依存のシンボリックリンクだけを配置し、推移的依存関係は内部ディレクトリに隔離することで、ファントム依存を防止しています。Bunでも設定を変更すると、pnpmと同様のシンボリックリンクベースの構造でファントム依存を防止できます。
Yarn(Plug'n'Play(PnP)モード)は、これらとは異なるアプローチをとっています。そもそもnode_modulesを作らず、専用のローダーが依存関係を管理します。宣言されていないパッケージへのアクセスはローダーが拒否するため、ファントム依存が起きません。
いずれの場合も、package.jsonに書いていないパッケージをimportしようとするとエラーになるため、依存関係の問題に早い段階で気付けます。
なぜnode_packagesではなくnode_modulesなのか
「パッケージを入れるディレクトリなのに、なぜnode_packagesではなくnode_modulesなのか」と疑問に思うかもしれません。
これはNode.jsにおける「モジュール」と「パッケージ」の違いに関係しています。Node.jsでは、node_modulesディレクトリに置かれたrequire()で読み込めるもの(ファイルやディレクトリ)をモジュールと呼びます。一方、パッケージはpackage.jsonを持つディレクトリのことです。
実はnode_modulesには、パッケージだけでなく単一のJavaScriptファイルも置けます。たとえばnode_modules/foo.jsというファイルを作れば、require('foo')で読み込めます。このファイルはpackage.jsonを持たないので「パッケージ」ではなく「モジュール」です。つまり、node_modulesは「モジュールを格納するディレクトリ」であり、パッケージはモジュールの一形態にすぎないため、node_packagesではなくnode_modulesと名付けられています。
npmの公式ドキュメント「How npm Works - File and Directory Names」で、モジュールとパッケージの違い、node_modulesの命名理由が解説されています。
学びをシェアする
・node_modulesはパッケージの保管ディレクトリ。gitには含めず.gitignoreで除外する
・ロックファイルからnpm installでいつでも再現可能
・推移的依存もすべて格納され、ホイスティングでファントム依存が起きうる
・pnpmやYarn PnPはファントム依存を防止できる
『サバイバルTypeScript』より