yarnのv3がそろそろ来そうな気配を感じるものの、恐らくまだしばらくはnode_modules
と付き合う事になりそうなので、今一度yarn
におけるnode_modules
のhoisting
について理解しなおそう、ということを試みた記事です。
前半はhoist
ってそもそも何?的な話で、後半は仕組みの話です。
後半まで飛ばしたいかたはこちらからどぞ。
yarn
におけるhoisting
とは
transitive dependencies
をより上位のディレクトリにhoist
(巻き上げ)すること。
transitive dependenies
って?
推移的である依存関係のこと。(日本語の正式名称的なものがあるのかどうかは知らない。たぶんなさそう)
推移的であるとはどういうことか、一階述語論理で書くと以下のようになる。
Note: 一階述語論理とは?については以前記事を書いたので参考にどぞ
をパッケージの集合上の依存関係としたとき、
という関係のこと。離散数学にでてくる「関係の推移律」というやつ。
やわらかい言い方(?)をすると依存関係の依存関係もまた依存関係であるということ。
というパッケージが というパッケージに依存していて、さらに が というパッケージに依存しているとき、 は に依存していると言え、そのような依存の依存という関係にあるパッケージのことを transitive dependency
と呼ぶ。
シンプルに、トップレベルの
dependencies
以外はtransitive dependencies
であるというのがわかりやすいかも。
例えばトップレベルでは と に依存しており、さらに は 、 は 、 そして が 、 が に依存していた場合に、 と と がtransitive dependencies
にあたる。
なぜhoisting
するのか
まず大前提として、Nodeはパッケージの探索時、パッケージが見つかるまで親ディレクトリのnode_modules
を再帰的に探しに行くという仕組みであることを抑えておきたい。
このNodeの性質を利用し、依存の依存として複数存在する同一パッケージを、より上位のディレクトリに一つだけ配置することで重複を減らそう、そしてnode_modules
の容量を減らそうというのがhoisting
をする主な理由である。
また、同一性が求められるようなパッケージについては、同じパッケージが複数存在していると同一性が満たされないため、そのような場合にもhoisting
が重要になる。
同一性が必要なもので有名なのは
react
など。例えば一つのアプリの中でreact-dom
とreact
を使っている場合に、import react
したものとreact-dom
の中で使われているreact
が同じものであることが求められる。
何をhoist
するかの判定の仕組み
というわけで前置きはこのくらいにして、本題はここ。
この部分を書きたかった。
yarn
のソースコードを見てみると、package-hoister.js
あたりにパッケージのhoist
に関してが書かれていて、なかでも特に世の中にあまり解説がなくブラックボックス化している同一パッケージの複数バージョンがどう判定されるかについては prepass
あたりで決定されているっぽいことがわかる。
classicだがberryでnode_modules使う場合も同じハズ。たぶん。
で、ユーザー側が知っておきたいレベルでどういう仕組みなのかを大雑把に書いてみる。
まず、基本的にすべてのtransitive dependencies
を可能な限りhoist
しようとする。
可能な限りと書いた通り、すべてがhoist
されるかというとそうではない。
hoist
されないものは大きく分けて、2種類ある。
一つは、nohoist
設定しているもの。(これはworkspaces
を使っていない場合は関係ない。)
後述するが、
nohoist
はv2以降でnmHoistingLimits
という設定に置き換わっている。
そしてもう一つは、同一パッケージで複数バージョン存在する場合の、一つのバージョンを除いた複数バージョン達。
どういうことかというと、そもそもの前提として、同一パッケージでhoist
できるのは基本的に一つのバージョンのみであり、それ以外のバージョンはhoist
されない。(できない)
では、その一つのバージョンがどうやって選ばれるのかというと、最もoccurenceCount
の多いバージョンがhoist
される。
ここで、occurenceCount
とは、依存されている数のこと。別の言い方をすると、hoist
しなかったとしてnode_modules
内すべてで合計何個存在するか、みたいなもの。
この処理は前述の prepass
あたりでされていて、まず依存グラフを探索して、各パッケージの各バージョンについて**occurenceCount
** を数え、同一パッケージでバージョンが複数存在する場合、最もoccurenceCount
の多いバージョンのみをhoist
するということを決定している。
最大のoccurenceCount
のものが複数バージョン存在した場合はどうなるのかというと、昇順でソートして最初にくるバージョン(つまりより低いバージョン)がhoist
される。(ハズ)
また、トップレベルの依存としてもともとnode_modules
直下に存在するパッケージと同じパッケージもhoist
されない。
これらと同じパッケージを
hoist
すると、ルートのpackage.json
に書かれた依存を勝手に置き換えることになってしまうので当然hoist
しない。(ただし、トップレベル に既に存在しているものとバージョンが同じtransitive dependencies
は不要になるので消える。)
大体こんな感じ。
補足
そもそもの前提として、同一パッケージで
hoist
できるのは基本的に一つのバージョンのみ
これについて一応理由を補足しておくと、言うまでもないが同じディレクトリ名は同じ階層に複数存在できないため。
より詳しく書くと、node_modules
ではパッケージ名がディレクトリ名になり、バージョンはディレクトリ名に含まれないため、あるパッケージをある階層に巻き上げようとしたときに一つのバージョンだけしか巻き上げることができない。
hoist
有り無しの比較
参考までにhoist
の有り無しを図で書くとこんな感じ。(実際には普通にインストールすると勝手にhoist
されるのでhoist
なしのほうはあくまでもイメージ。)
hoist
なし
project-root
└ node_modules/
├ A(v1.0.0)
│ └ node_modules/
│ └ B(v1.0.0)
├ C(v1.0.0)
│ └ node_modules/
│ └ A(v1.0.0)
│ └ node_modules/
│ └ B(v1.0.0)
└ D(v1.0.0)
└ node_modules/
└ B(v2.0.0)
hoist
あり
project-root
└ node_modules/
├ A(v1.0.0)
├ B(v1.0.0)
├ C(v1.0.0)
└ D(v1.0.0)
└ node_modules/
└ B(v2.0.0)
Note: Bはv1.0.0
とv2.0.0
が存在するが、v2.0.0
は1つ、v1.0.0
は2つあるのでv1.0.0
が優先的に巻き上げられる。
Workspacesにおける同一性
workspaces
を使っていてかつ同一性が求められるライブラリを使っている場合、個々のworkspace
内だけでなく、workspaces
全体でtransitive dependency
も含めてバージョンを気にする必要がある。
複数のworkspace
をまたいで、同一性が求められるライブラリを使いたい場合はworkspaces
全体でtransitive dependency
も含めてバージョンを揃える必要がある、というのはそれはそうとして、そうではなく__workspace
間をまたがず一つのworkspace
内でのみ同一性が保たれていればよいという場合であっても、workspaces
全体を気にする必要がでてくる__ということに注意したい。
nohoist
について
nohoist
はその動作から多くのバグや混乱を巻き起こしているようで、公式ドキュメントにすら
no one being really sure which patterns needed to be set.
と書かれており、berryではnmHoistingLimitsに置き換わっている。なのでnohoist
を使いたい場合は、node_modules
を使っていきたいとしてもv2以上にあげて、nmHoistingLimits
を使うようにしたほうがよいかもしれない。
オワリ
何か間違ってたら教えてください。
- Matrix: @tars0x9752:matrix.org
- Discord: tars0x9752#8569
(どちらもそんなに頻繁に確認してるわけではないのでしばらく気がつかなかったらスミマセン🙏)