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
(どちらもそんなに頻繁に確認してるわけではないのでしばらく気がつかなかったらスミマセン🙏)