この記事では、npm install
やnpm ci
を実行したときにどのようにパッケージがインストールされるのか、依存パッケージにバージョンのコンフリクトが発生した際にどのように処理されるのか、などを見ていく。必要に応じて Yarn での挙動にも触れる。
動作確認に使った npm のバージョンは6.14.5
。Yarn は1.22.4
。
特に npm はバージョンによって動作が大きく異なるので、注意する。
package-lock.json によるバージョンの固定
package.json
だけではインストールするパッケージのバージョンを固定できず、package-lock.json
(Yarn の場合はyarn.lock
)によってバージョンを固定する。
多くの人が知っている話ではあるが、重要な機能なので改めて触れておく。
package.json
のdependencies
やdevDependencies
は、デフォルトでは^
を使ってバージョンを指定している。
そのため、インストールを行うタイミングによってパッケージのバージョンが変わってしまう。
例として、redux-thunk@2.1.0
をインストールしてみる。
普通にインストールするとpackage-lock.json
が作られてしまうので、--no-package-lock
オプションをつけて検証する。こうすると、package-lock.json
が作られない。
以下のコマンドを実行すると、redux-thunk@2.1.0
がインストールされる(i
はinstall
のエイリアス)。
$ npm i redux-thunk@2.1.0 --no-package-lock
npm ls
で確認しても、間違いなくインストールされている。
$ npm ls | grep 'redux-thunk' └── redux-thunk@2.1.0
この状態で、node_modules
を削除する。
$ rm -rf node_modules/
今、node_modules
もpackage-lock.json
も存在しない状態になっている。この状態で$ npm i
すると、どうなるか。
$ npm i $ npm ls | grep 'redux-thunk' └── redux-thunk@2.3.0
redux-thunk@2.3.0
がインストールされてしまっている。
$ npm i
のタイミングで作成されたpackage-lock.json
のdependencies
にも、2.3.0
で記録されている。
"dependencies": { "redux-thunk": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.3.0.tgz", "integrity": "sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw==" } }
これは、package.json
のdependencies
が^
を使っているため。
"dependencies": { "redux-thunk": "^2.1.0" }
^2.1.0
は、「2.x
の最新バージョン」を意味する。そのため、記事執筆時の最新バージョンである2.3.0
がインストールされた。
今後、例えば2.3.1
や2.4.0
がインストールされれば、もちろんそれがインストールされる。
つまり、インストールするタイミングによってパッケージのバージョンが異なってしまい、バージョンを固定できない。
最初にredux-thunk@2.1.0
をインストールした際に--no-package-lock
をつけなければ、その時点で以下の内容のpackage-lock.json
が作られる。
そうすると、$ npm i
した際にこの内容に基づいてインストールを行うため、必ずredux-thunk@2.1.0
がインストールされるようになる。
"dependencies": { "redux-thunk": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.1.0.tgz", "integrity": "sha1-xyS/7nXb41LaLjupvBQwK63Ympg=" } }
これがpackage-lock.json
の機能であり、この仕組みによって、全ての開発者に対して同じ環境を提供することが可能になる。
package.json で ^ を使わなくても問題の解決にはならない
だがそもそも、package.json
で^
を使っているのが悪いのであって、2.1.0
のように厳密にバージョンを指定すればよいのでは?
そうすればpackage-lock.json
が無くてもバージョンを固定できるのでは?
そう思う人もいるかもしれないが、結論を言うと、その方法では上手くいかない。これからそれを確認していく。
まず、^
をつけずにインストールする方法だが、install
コマンドに-E
、もしくは--save-exact
オプションをつければよい。
そのため、以下のコマンドを実行すると、package.json
のdependencies
に^
なしで記録しつつ、package-lock.json
は作成しない。
$ npm i redux-thunk@2.1.0 -E --no-package-lock
"dependencies": { "redux-thunk": "2.1.0" }
この状態でnode_modules
を削除してからnpm install
を実行してみる。
$ rm -rf node_modules/ $ npm i
すると、期待した通りredux-thunk@2.1.0
がインストールされる。
$ npm ls | grep 'redux-thunk' └── redux-thunk@2.1.0
つまり、このケースなら上手くいくのである。package-lock.json
がなくても、インストールするパッケージのバージョンを固定できる。
このような言い方をしていることから分かるとは思うが、上手くいかないケースもあり、次はそのようなケースを見ていく。
一度プロジェクトを綺麗にして、node_modules
やpackage-lock.json
が存在しない状態にする。package.json
のdependencies
も空にする。
"dependencies": {}
この状態で今度は、react@16.13.1
をインストールしてみる。
$ npm i react@16.13.1 -E --no-package-lock
こうするとreact@16.13.1
がインストールされるのだが、$ npm ls
で確認すると、他のパッケージもインストールされている。
└─┬ react@16.13.1 ├─┬ loose-envify@1.4.0 │ └── js-tokens@4.0.0 ├── object-assign@4.1.1 └─┬ prop-types@15.7.2 ├── loose-envify@1.4.0 deduped ├── object-assign@4.1.1 deduped └── react-is@16.13.1
node_modules
の中を確認しても、確かに入っている。
$ ls node_modules/ js-tokens loose-envify object-assign prop-types react react-is
これは、react
がこれらのパッケージを必要としているため、つまりこれらのパッケージに依存しているためである。
具体的には、node_modules/react/package.json
のdependencies
に書かれているパッケージが、react
が依存しているパッケージである。
"dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", "prop-types": "^15.6.2" },
redux-thunk
のときに他のパッケージがインストールされなかったのは、node_modules/redux-thunk/package.json
には、dependencies
が何も指定されていなかったからである。
devDependencies
は指定されているが、これらは開発時に必要なパッケージであり、パッケージの動作には必要ない。そのため、ライブラリとしてインストールされる際にはdependencies
に指定されているパッケージのみがインストールされる。
react
のインストールに話を戻すと、必要としている 3 つのパッケージをインストールしたあと、今度はそれらが必要としているパッケージのインストールを開始する。
例えば、loose-envify
のpackage.json
のdependencies
フィールドは、以下のようになっている。
"dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" },
そのため、js-tokens
もnode_modules
にインストールされる。
このように依存パッケージのインストールは連鎖していき、全ての依存パッケージがインストールされるまで繰り返される。
そして、既に気付いているかもしれないが、node_modules
以下にある各種package.json
のdependencies
は、^
付きでバージョンを指定している。
つまり、依存パッケージのバージョンは固定されていない、ということである。
例えば、prop-types
。
改めてnode_modules/react/package.json
のdependencies
を確認してみると、^15.6.2
と指定されている。
"dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", "prop-types": "^15.6.2" },
これは、15.x
の最新バージョンを意味する。
実際にどのバージョンがインストールされているのか確認してみると、15.7.2
である。
$ npm ls | grep 'prop-types' └─┬ prop-types@15.7.2
これは、記事執筆時点での15.x
の最新バージョンが15.7.2
だからそうなったに過ぎない。
仮に明日15.8.0
がリリースされた場合、それ以降は15.8.0
がインストールされるようになる。
このように、-E
オプションを使ったところで、固定できるのは直接インストールしたパッケージ(この例ではreact
)だけであり、そのパッケージの依存パッケージのバージョンまでは、固定できないのである。
以上のことから、全ての開発者に同じ環境を用意するためには、素直にpackage-lock.json
を使うべきという結論になる。
install
コマンドではなく後述するci
コマンドを使えば、より確実である。
Yarn の場合、バージョンを指定してインストールすると、特にオプションをつけなくても^
がつかない。
$ yarn add react@16.13.1
"dependencies": { "react": "16.13.1" }
だが、npm の場合と同様、これでバージョンが固定されるのはreact
だけであり、依存パッケージのバージョンは固定されない。固定するためにはやはりロックファイル、つまりyarn.lock
が必要になる。
ちなみに、ライブラリとして配布する際にpackage-lock.json
を含めることはできない。そのためnode_modules
以下にはpackage-lock.json
は存在しない。
また、手動でnode_modules/react/package-lock.json
のようなファイルを用意したとしても、プロジェクトのルートディレクトリ以外の場所にあるpackage-lock.json
は無視されるため、意味がない。
これは npm の仕様上そうなっている。
Yarn の場合も同様で、プロジェクトのルートディレクトリ以外にあるyarn.lock
は無視される。
依存パッケージにバージョンのコンフリクトが発生した場合の挙動
依存パッケージが増えていくと、複数のパッケージが依存しているパッケージ、というものが発生する。
実はreact
のときも発生していたので、確認してみる。
今度は余計なオプションをつけずにインストールする。
$ npm i react
以下のグラフは、このときの依存関係を示している。
例えばloose-envify
が重複している(上のグラフの緑の部分)。
react
が依存しているのだが、同じくreact
が依存しているprop-types
も、loose-envify
に依存している。
しかし、npm は可能な限り重複を排除しようとするので、loose-envify
がnode_modules
に二重にインストールされてしまうことはない。
それぞれが必要としているloose-envify
のバージョンを見ると、react
は^1.1.0
、prop-types
は^1.4.0
になっている。
この場合、1.4.0
以上の1.x
系をインストールすれば、どちらの条件も満たせる。
このような場合は、条件を満たすバージョンのパッケージをnode_modules
直下にインストールするのである。
確認してみると、1.4.0
がインストールされている。
$ cat node_modules/loose-envify/package.json | grep '"version"' "version": "1.4.0"
この仕組みにより、node_modules
は以下のような構造になっている。
先程のグラフと照らし合わせてみると、全てのパッケージのバージョンが条件を満たしていることを確認できる。
だが、重複を排除できないこともある。
両立できないようなバージョン指定がなされているケースでは、それぞれのバージョンのパッケージをインストールしなければならない。
次はそのようなケースを見てみる。
react
を削除し、babel-loader
をインストールする(un
はuninstall
のエイリアス)。
$ npm un react && npm i babel-loader
記事執筆時点での最新バージョンであるbabel-loader@@8.1.0
がインストールされた。
依存の依存、も含めて多くのパッケージがインストールされたが、今回重要なのは、babel-loader
が直接依存しているloader-utils
である。
babel-loader
は^1.4.0
を指定しており、node_modules
のなかを調べてみると、1.4.0
がインストールされている。
$ cat node_modules/loader-utils/package.json | grep '"version"' "version": "1.4.0"
次に、style-loader
をインストールする。
style-loader@1.2.1
がインストールされたが、このパッケージも、依存パッケージを持っている。
そのひとつがloader-utils
なのだが、^2.0.0
でバージョン指定されている。node_modules/style-loader/package.json
のdependencies
を見れば分かる。
ここで、問題が発生する。loader-utils
は既にbabel-loader
の依存パッケージとしてインストールされているが、そのバージョンは1.4.0
である。
style-loader
が欲しいのはloader-utils@^2.0.0
なので、条件が合わない。
この場合、npm はnode_modules/style-loader
にnode_modules
を作り、そのなかに条件を満たすloader-utils
をインストールするようになっている。
確認してみると、確かにnode_modules/style-loader/node_modules/
にloader-utils@2.0.0
がインストールされている。
$ cat node_modules/style-loader/node_modules/loader-utils/package.json | grep '"version"' "version": "2.0.0"
つまり、node_modules
は以下のような構造になっている。
package-lock.json
にも、そのように記述されている。
dependencies
直下に1.4.0
のloader-utils
が記述されており、それとは別に、dependencies.style-loader.dependencies
に2.0.0
のloader-utils
が記述されている。
ここまでの説明から分かるように、node_modules
やpackage-lock.json
の構造は、パッケージをインストールする順番等によって変化する。
試しに、先程とは順番を逆にして、まずstyle-loader
をインストールする。
そうすると、node_modules
直下にloader-utils@2.0.0
がインストールされる。
$ cat node_modules/loader-utils/package.json | grep '"version"' "version": "2.0.0"
この状態でbabel-loader
をインストールすると、node_modules/babel-loader/
直下にnode_modules
が作られ、loader-utils@1.4.0
がそこにインストールされる。
$ cat node_modules/babel-loader/node_modules/loader-utils/package.json | grep '"version"' "version": "1.4.0"
package-lock.json
は以下のような構造になる。
loader-utils@2.0.0
とloader-utils@1.4.0
の位置が先程とは逆になっているのが分かる。
このように、最終的なpackage.json
の内容が全く一緒だとしても、そこに至るまでにどのような作業をしたかによって、package-lock.json
の内容は変わり得るのである。
Yarn の場合
Yarn の場合も、基本的には同じ挙動になる。ただし、パッケージをインストールする順序に依存せず、常に同じ構造になる。
先程のbabel-loader
とstyle-loader
のケースでは、どちらを先にインストールしても、node_modules
の直下にloader-utils@1.4.0
がインストールされ、node_modules/style-loader/node_modules
にloader-utils@2.0.0
がインストールされる。
そのため、style-loader
を先にインストールした場合、まずnode_modules
直下にloader-utils@2.0.0
がインストールされる。
その後、babel-loader
をインストールすると、node_modules/loader-utils
は一旦削除され、そこにloader-utils@1.4.0
がインストールされる。そして、node_modules/style-loader/node_modules
に、loader-utils@2.0.0
をインストールするのである。
yarn.lock
の構造もpackage-lock.json
とは異なり、入れ子にはならず、全てが並列的に記述される。
npm update
パッケージをインストールしたりアンイストールしたりすることでpackage-lock.json
は変化していくが、update
コマンドを実行することでも変化する。
まず、新規のプロジェクトにreact@16.0.0
をインストールする。
$ npm i react@16.0.0
react@16.0.0
の依存パッケージのひとつにfbjs
があるが、このパッケージには多くの依存パッケージがあり、それらも当然インストールされてpackage-lock.json
に記録される。
次に、以下のコマンドを実行する。
$ npm update react
こうすると、16.x
の記事執筆時点での最新バージョンであるreact@16.13.1
にアップデートされる。
そしてそれだけでなく、依存パッケージの整理も行ってくれる。
このバージョンではfbjs
への依存はないため、fbjs
自身やその依存パッケージはnode_modules
から削除され、package-lock.json
にもそれが反映される。
npm ci
パッケージを指定せずにinstall
コマンドを実行すると、package.json
やpackage-lock.json
の内容に基づいてパッケージをnode_modules
にインストールする。
同様のコマンドとしてci
があり、これもnode_modules
にパッケージをインストールしてくれる。
だが両者にはいくつかの違いがある。
まず、ci
はinstall
と違い、既存のnode_modules
を一度削除してからインストールを行う。
確認のため、まず以下のコマンドを実行する。
$ npm i redux-thunk && touch node_modules/redux-thunk/foo
こうすると、node_modules/redux-thunk/foo
というファイルが作られる。
$ ls node_modules/redux-thunk/ LICENSE.md README.md dist es foo index.d.ts lib package.json src
この状態で$ npm i
を実行しても、このファイルは残っている。
次に、$ npm ci
を実行してからnode_modules/redux-thunk
を確認する。
そうするとfoo
が消えているのが分かる。
$ ls node_modules/redux-thunk/ LICENSE.md README.md dist es index.d.ts lib package.json src
そしてci
は、package-lock.json
がないとエラーになり、あったとしてもpackage.json
の内容と矛盾があればやはりエラーになる。
この特徴によって、予期せぬ形でパッケージのインストールやpackage-lock.json
の更新を防ぐことができる。
冒頭の例で示したように、package-lock.json
が存在しなくても$ npm i
によるインストールは行われてしまうし、そうするとインストールされるパッケージのバージョンは固定されない。
また、package.json
とpackage-lock.json
の内容に矛盾があっても、$ npm i
によるインストールはそのまま行われ、package.json
の内容に基づいてpackage-lock.json
が上書きされてしまう。
例えば、package.json
とpackage-lock.json
が以下の内容だったとする。
"dependencies": { "redux-thunk": "^1.0.0" }
"dependencies": { "redux-thunk": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.3.0.tgz", "integrity": "sha512-km6dclyFnmcvxhAcrQV2AkZmPQjzPDjgVlQtR0EQjxZPyJ0BnMf3in1ryuR8A2qU0HldVRfxYXbFSKlI3N7Slw==" } }
この状態で$ npm i
すると、package.json
の内容に基づき1.x
の最新バージョンがインストールされ、package-lock.json
は以下のように上書きされてしまう。
"dependencies": { "redux-thunk": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-1.0.3.tgz", "integrity": "sha1-d4qgCZ7qBZUDGrazkWX2Zw2NJr0=" } }
だがci
の場合、package.json
とpackage-lock.json
に矛盾があればエラーが発生し、インストールは中止される。
先程の状況で$ npm ci
を実行すると、以下のエラーが出る。
npm ERR! Invalid: lock file's redux-thunk@2.3.0 does not satisfy redux-thunk@^1.0.0
このエラーを解消するには、package.json
とpackage-lock.json
の内容を同期させる必要がある。
この仕組みによって、ci
によるインストールは必ずpackage-lock.json
の内容に基づいて行われることになり、ci
でインストールする限り確実に同じ環境が構築されることを保証できる。また、予期せぬ形でpackage-lock.json
が上書きされてしまうことも防げる。
yarn --frozen-lockfile
Yarn には npm ci
と同じように動作するコマンドはない。
ただし、$ yarn
(もしくは$ yarn install
)を実行する際に--frozen-lockfile
オプションをつけると、yarn.lock
の更新が必要になった場合、つまりpackage.json
とyarn.lock
の間に矛盾があった場合に、エラーを出すようになる。
しかし、npm ci
と違い、yarn.lock
がなくてもエラーにならない。この場合、package.json
に基づいてインストールが行われ、yarn.lock
は生成されない。
あくまでも「yarn.lock
を生成せず、既存のyarn.lock
に対して更新が発生した場合はエラーを出す」というオプションである。