Monorepoで異なるバージョンのReactを使う方法

(更新日: )

前置き

この記事を書こうと思った背景

カミナシさんの「フロントエンドの Monorepo をやめてリポジトリ分割したワケ」に

(Monorepo 構成で複数の React を共存させることができないため *1)

の記載があり、いやいやそんなことないですよ?っていうのとMonorepoだとできないんだっていうふうに誤解してほしくないので、一応ネット上にできるよっていう記事を残しておきたかったので書こうと思いました。

この記事を書いてる時点でわかるとは思うのですが、結論としてはもちろんMonorepoでも異なるバージョンのReactは共存できます

注釈先のReactのドキュメントの話は1つのビルド単位内で複数のバージョンが存在できないという話だと思うので、そのあたりの認識を間違えたんじゃないかなとも個人的には推察してます。

あとMonorepoをやめたところに関してはその組織の話で外部からとやかく言うのは野暮なので、特にこの記事では書いてないです。記事のタイトル通りの話を書いています。

※記事公開から数日経ったので、もしかするとn番煎じかもしれませんが、そのあたりはご了承ください。

この記事で使う用語定義

  • アプリケーション: サーバーやクライアントサイドで稼働するもの
  • モジュール: アプリケーションで利用されるコードのひとかたまり
  • ライブラリ: package.jsonのdependencesなどに記述される依存先

この記事で使うMonorepoについて

使用したもの

  • Node.js: v18.16.0
  • pnpm: v8.5.1

作成したリポジトリ: https://github.com/mya-ake/monorepo-with-pnpm

pnpmのWorkspaceの機能を使って、Monorepoを構成します。個人的にはパッケージマネージャーはpnpmを選択するのがいいのではないかと考えます。npmとYarnも試しましたが、そのどちらよりも使いやすく、Monorepoにした場合も依存の問題も起きないだろう(まだ遭遇していないだけかもしれない)と考えています。

どんなものを作ったか

今回は最新バージョンを使うアプリケーションと少し古いバージョンを使うアプリケーションの2つのアプリケーションが存在するMonorepoを作成しました。また、共通のライブラリとして、Reactのバージョンを表示するコンポートを作成しています。細かいディレクトリ構成などはリポジトリを直接見てください。

最新バージョンはvite@v4で作成し、少し古いバージョンはvite@v3で作成してます。vite@v3でもReactのバージョンはv18となりますが、今回は古いバージョンを使いたいので、v17をインストールし直しています。

※ちなみにtsconfig.jsonの共通化などはこのリポジトリでは行っていません。モジュールでもアプリケーション側からコピーしてきたものを使っていたりと、Monorepoの依存の管理意外のところについてはすごく雑なのでそのあたりは参考にしないのが無難です。

どのように依存を管理しているか

Monorepoのルートのpackage.jsonではなく、アプリケーションやモジュールのpackage.jsonで依存を管理しています。こうすることでそれぞれのアプリケーションやモジュール内で使用する依存を管理することができるので、アプリケーションごとに異なるReactのバージョンが利用できます。ルートには全体にフォーマットをかけるためのPrittierだけ書いてます。

これがこの記事のタイトルの解ではあるのですが、せっかくなのでMonorepoの構成方法についてもう少し掘り下げます。

Monorepoの構成方法

大きく次の2つです。

  • 集中型Monorepo: ルートにすべてのライブラリをインストールして、それぞれのアプリケーションやモジュールで利用
    • すべてのライブラリがルートのpackage.jsonに記述される
      • Monorepo全体で同じバージョンのライブラリが利用される
  • 独立型Monorepo: それぞれのアプリケーションやモジュールで必要なものをそれぞれでインストールする
    • それぞれのアプリケーション・モジュールが使うライブラリがそれぞれのpackage.jsonに記述される
      • それぞれのアプリケーション・モジュールで異なるバージョンのライブラリが利用できる

集約型や独立型は記事で話をする中で名前がついてた方がわかりやすいので、便宜上そう名付けているだけです。特にどこかで使われている名前ではないです。

個人的にはMonorepoを構成するアプリケーションやライブラリのライフサイクルで選ぶことになると考えています。集中型であれば全体で同じバージョンを使うことになるので、ライフサイクルを揃えたいライブラリ系に向いていますし、独立型はライフサイクルの異なる複数のアプリケーションを作成する場合などに向いていると思います。

Monorepoを作成するときにOSSのライブラリのリポジトリを参考にすることも多いと思うのですが、ほとんどが集中型のルートで管理する方法を取っていると思います。というのもOSSのライブラリはなにか1つ、またはそれを補強する機能を提供することを目的としています。例えばUI系のライブラリがわかりやすいかもしれません。Reactなどの特定のライブラリ向けに作成されているので、全体で依存が一致していないと不具合を起こしてしまう可能性が高いからです。そのためルートで管理するのが理に適っています。

対して、会社などの組織がMonorepo管理する対象はアプリケーションです。対象のアプリケーションが1つであれば集中型でも問題ないかもしれません。しかし、フロントエンド(ユーザー側と管理画面など)とバックエンドのアプリケーションが存在しているような複数のアプリケーションが存在するMonorepoではライフサイクルが異なっており、それぞれ独立した状態の方が柔軟性が高く、HotFixなどの緊急時にも対応がしやすいと考えます。そのため、アプリケーションをMonorepoで管理するのであれば独立型の方がよいのではないかと思います。また、独立型の方が組織の変更など様々な要因によりMonorepoを解体しなければならないとなった場合でも、分割しやい状態となっているので、ビジネス要件にも応えやすい柔軟性というのも持っておけるのではないかと考えます。

こんな書き方をするとMonorepoじゃなくてもいいなじゃないみたいな風に見えますが、それは個々の考え方やシチュエーション次第かなと思います。ケースバイケースってやつです(議論は尽きないと思うのでこうさせてください)。

あとがき

この記事を書こうと思ったきっかけはカミナシさんの記事ですが、カミナシさんの意思決定に茶々を入れたいわけではないです(大事なのでもう一度)。冒頭にも書きましたがMonorepoについて誤解してほしくないと思って書きました。また、書いてるうちにMonorepoを採用してしまったがゆえに不幸になるケースっていうのを減らしたいなという思いもあり、Monorepoの構成方法についても軽くふれました。

個人的にMonorepoにはモジュール構造の導入容易性とnxなどを利用した依存関係の可視化などにメリットを見ているので、Monorepoを採用できるなら採用していきたい派です。アプリケーションは大きくなるに連れ共通化などが生まれ、依存関係が複雑化していきがちです(なんでもかんでも共通化しない方がいいみたいなところもあったりしますが、認証系などリスクのあるところは単一の共通コードを使う方がリスクマネジメントしやすいなど、共通化というのはアプリケーションを作っていく中でうまく付き合っていく必要のあるところだと思います)。その複雑化していく中で可視化というのは重要なポイントではないかと思います。なにかしら機能追加するときなど、コードを書く前に関連コードを読んで影響の調査するなど、書いてるよりも調査の方が長かったりするので、そういった時間を減らしていき、意思決定や価値提供のスピードっていうのを早めていけるのに貢献できるではないかと考えてます。(こうするためにもnxのドキュメントにあるようにアプリケーションを20%、モジュールを80%ぐらいで作っておくなど、作り方にも工夫は必要ですが)

なんかこの記事がタイトル以外のことの文量も多くなってきてしまったので、一旦ここで区切ろうと思います。Monorepoを採用するか迷ってる方に届いて、検討の参考になれば幸いです。


補足や蛇足

独立型Monorepoのライブラリアップデート

複数のpackage.jsonにバージョンが記載されているので、更新が大変に見えるかもしれませんがそんなことはありません。次のコマンドでリポジトリ内のライブラリを一括で最新にできます。

$ pnpm up -r --latest

もちろん単体のライブラリだけをアップデートすることもできます。

$ pnpm up -r react@latest

また特定のアプリケーションやモジュールだけをアップデート対象にしたい場合はpnpmのFilteringの機能が使えます。今回作成したリポジトリでlegacy-appだけ対象外にしたい場合は次のコマンドでアップデートできます。

$ pnpm -F=\!legacy-app up react@latest

このようにアップデートすることができるので、分かれているからと言ってすごく手間になるわけではないです。

Yarnの問題

筆者がVue.jsに向けに提供しているvue-window-sizeというライブラリがあるのですが、こちらはYarn v3を使ったモノレポ管理をしています。こちらは独立型Monorepoで作っています(どちらかというと集中型の方がいいのですが、そのときは独立型を試したかったので現状そうなってます)。しかし、ルートにも入れないと動かない依存関係も存在していました。そのため、happy-domなどのライブラリもルートに存在しています。Yarnではこのような各アプリケーションやモジュールのpackage.jsonに記述するだけでは依存の問題も発生するので、独立型Monorepoに利用するのには向いていないかもしれません。ただ、筆者の調査不足の可能性もあるので、できるよということであればvue-window-sizeにPRください。

モジュールをビルドするか否か

今回作成したリポジトリではモジュールはtsupを使いビルドして、アプリケーションで利用しています。今回は独立型であるということを強調したいのでこうしています。ここはTSであればtsconfigのpathsやincludesの設定をすることで、ビルドしなくとも利用する方法も存在しています。tsconfigで行う場合はビルドはアプリケーション側で行われるので、全体のバンドルサイズを小さくすることができたり(モジュールごとにビルドするより)とメリットもあります。ただし、モジュールを利用するときにpackage.jsonだけでなくtsconfig.jsonのアップデートも必要であったりと少し手間が生じます。ここは要件や好みによるところもあるので、こういう方法があるというのだけ知っておくとよいと思います。

nxやturborepoはどう?

どちらもビルドシステムと言われているものです。gitのログを見て差分だけ実行する(まだ試してないですが、これだけならpnpmのFilteringでもできるみたいです)ことやスクリプトの実行結果をキャッシュしておいて、ビルドを高速化させたりなどができます。アプリケーションの規模感にもよりますが、なにも対策せずに大きくなるとCIの実行時間が増えていくので、入れる余裕があるなら採用しておくほうがいいように思います。

どちらがよいかというとnxの方が歴史があり、機能も多いです。turborepoは後発であり設定はnxよりも容易に感じます。個人的には依存の可視化という部分を重視してるので、nxを使っています。turborepoはこの部分は強くないです。また、nxもPackage-Based Reposという方法とIntegrated Reposという方法があります。Integrated Reposは強くnxに依存し、nxの機能をフルに活用できます。ただ、依存が強く剥がすのが容易ではないので、それが許容できる場合のみ採用すべきかと思います。柔軟性を重視するならPackage-Based Reposがよいです。こちらは一部機能を使うだけなので、turborepoに乗り換えたい場合でもそこまで難しくなく移行できるのではないかと思います。

もちろん公式のドキュメントにもIntegrated Repos vs. Package-Based Repos vs. Standalone Appsというページがあるので公式の情報が見たい方はこちらをご覧ください。

Bitというコンポーネント駆動開発ソフトウェア

まだ詳しく見てないのですがBitというものがpnpmのworkspaceのページで紹介されていました。コンポーネント駆動での開発をサポートしてくれるもののようです。Monorepoでの開発がよりやりやすくなりそうな気配もするので、今度触ってみようかなと考えてます。