グローバルナビゲーションへ

本文へ

フッターへ

お役立ち情報Blog



monorepoを1つのStorybookで管理したい!その手順と遭遇した問題を解説します。

現在開発中のフロントエンドのプロジェクトではアプリケーションを横断して使用するコンポーネントが必要なこともあり、pnpm workspace を利用した monorepo 構成を取っています。

アプリケーション開発では、コンポーネント単位で細かく実装、確認、テストができると便利です。そう、Storybook ですね。

monorepo に Storybook を導入したい

まずは今回 Storybook を導入したい monorepo のディレクトリ構成です。

./
├── apps/
│   ├── app1/ # Viteのアプリケーション
│   └── app2/ # Next.jsのアプリケーション
├── packages/
│   └── ui/ # app1, app2から参照される共通のUIコンポーネント
├── package.json
├── pnpm-lock.yaml
└── pnpm-workspace.yaml

app1 には create-vite で作成した Vite のアプリケーション。app2 には create-next-app で作成した Next.js のアプリケーションがあり、monorepo のプロジェクトワークスペース直下の packages ディレクトリ配下にプロジェクトを横断して使用するパッケージを配置していきます。

Storybook の設定がつらい

この時 Storybook を app1, app2, ui のパッケージ毎に導入することもできますが、packages/** 以下に後々複数のパッケージを追加していくと、都度パッケージ毎に Storybook を導入する必要が出てきます。

./
├── apps/
│   ├── app1/
│   │   ├── .storybook/ # Storybookの設定
│   │   ├── src/
│   │   └── package.json
│   └── app2/
│   ├── .storybook/ # Storybookの設定
│   ├── src/
│   └── package.json
├── packages/
│   ├── package1/
│   │   ├── .storybook/ # Storybookの設定
│   │   ├── src/
│   │   └── package.json
│   ├── package2/
│   │   ├── .storybook/ # Storybookの設定
│   │   ├── src/
│   │   └── package.json
│   └── ui/
│   ├── .storybook/ # Storybookの設定
│   ├── src/
│   └── package.json
├── package.json
├── pnpm-lock.yaml
└── pnpm-workspace.yaml

それ以外にも、Storybook の dev サーバが複数に別れるため URL が以下のように別れてしまいます。

  • https://localhost:6006 # app1
  • https://localhost:6007 # app2
  • https://localhost:6008 # package1
  • https://localhost:6009 # package2
  • https://localhost:6010 # ui

monorepo のプロジェクトを横断して Storybook のコンポーネントを確認するのも難しくなるのもつらい。

Storybook を1つのパッケージ化する

Storybook を1つのアプリケーションとしてパッケージ化し、プロジェクトを横断して Storybook を1パッケージで管理してみます。

まずは Storybook 用のパッケージを/apps/storybookとして作成します。

# Storybook用のディレクトリを作成
mkdir apps/storybook

# 作成したディレクトリに移動
cd apps/storybook

# package.jsonの初期化
pnpm init

# Storybookのインストール
pnpm dlx storybook@latest init --type react_project

.storybook/main.js を編集します。

 /** @type { import('@storybook/react-vite').StorybookConfig } */
 const config = {
   stories: [
-    "../stories/**/*.mdx",
-    "../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)",
+    {
+      directory: "../../app1/src",
+      files: "**/*.stories.@(jsx|tsx|mdx)",
+      titlePrefix: "app1",
+    },
+    {
+      directory: "../../app2/src",
+      files: "**/*.stories.@(jsx|tsx|mdx)",
+      titlePrefix: "app2",
+    },
+    {
+      directory: "../../../packages/ui/src",
+      files: "**/*.stories.@(jsx|tsx|mdx)",
+      titlePrefix: "ui",
+    },
   ],
   addons: [

この設定で1つの Storybook でプロジェクトを横断してコンポーネントのカタログを確認できるようになります。

以下のコマンドで Storybook の dev サーバを起動して確認してみます。

# 事前にプロジェクトのルートワークスペースにcdで移動します

pnpm run -F storybook storybook

ui パッケージ

app1, app2 から参照される共通のパッケージの Story が確認できます。

app1 パッケージ

app1 の Top コンポーネントで ui パッケージのコンポーネントを使用した時の Story も確認できます。

app2 パッケージ

app2 の Top コンポーネントで ui パッケージのコンポーネントを使用した時の Story も確認できます。

Storybook を1つのパッケージ化した時に出た問題

Storybook ファイルを編集した時に HMR が動作しない

実際のプロジェクトで導入した際、特定の組み合わせでかなり特殊な例ですが HMR が動作しなかったことがありその時に対応を行ったワークアラウンドです。

monorepo のルートワークスペースで pnpm の peerDependencies のルールを厳密にする auto-install-peers=false を設定していて、Vite のバージョンが v5 の時、@storybook/react の composeStories 関数を使用して Story を再利用している Story で、参照先の Story(拡張子.stories.tsx ファイル)を更新しても HMR(ホットモジュールリロード)が効かない場面に遭遇しました。

.npmrc の設定

# ライブラリで設定している peerDependencies のバージョンと違う場合にエラーを発生させるもの
strict-peer-dependencies=true
# ライブラリで設定しているpeerDependenciesのライブラリを自動でインストールする設定
auto-install-peers=false

.npmrc に auto-install-peers=false を設定していない場合は @storybook/react-vite が依存する Vite のバージョンは当時 v4.9.5 で、手元の環境では Vite のバージョンが v5 といった違いによって、HMR が動作しなくなったと思い調べてみたところ、 Vite の v4 から v5 へのメジャーバージョンアップで HMR の挙動に一部変更が入っており、HMR の監視対象に vite.config.ts で設定する root より上のディレクトリが含まれなくなったと思われます。

本来の用途ではないですが、 envDir に monorepo のプロジェクトワークスペースのパスを指定することで HMR を動作させることができます。

apps/storybook/.storybook/main.js

+import { resolve } from "path";
+import { mergeConfig } from "vite";
+
 /** @type { import('@storybook/react-vite').StorybookConfig } */
 const config = {
   stories: [
@@ -30,6 +33,16 @@ const config = {
   docs: {
     autodocs: "tag",
   },
+  async viteFinal(config) {
+    return mergeConfig(config, {
+      // @storybook/react-vite のStorybookはStorybook構成ファイル(.storybook/main.ts)があるパッケージの親ディレクトリをViteの設定値 root として設定します
+      // デフォルトではこの root で設定したパスより上の階層のパスの一部ファイルでHMRが動作ません
+      // 本来の使用方法ではないですが、HMRを動作させるワークアラウンドとして envDir にルートワークスペースのパスを設定しています
+      envDir: resolve(__dirname, "../../../"),
+      // manually specify plugins to avoid conflict
+      plugins: [],
+    });
+  },
 };

 export default config;
注意事項

envDir は本来は Vite の dev サーバ、build 時に参照する .env ファイルの配置場所を指定するためのパスで、本来の使い方ではないです。実際にはルートワークスペース直下に .env ファイルは存在しません。

TypeScript + CSS Modules 構成の時、CSSModules の型生成がつらい

TypeScript の構成+スタイリングに CSS Modules を使用している場合、typed-css-modules などのライブラリを使用して TypeScript の型定義を生成していることがあります。

Storybook の dev サーバを起動しながら、各パッケージのコンポーネントの CSS を変更しながらスタイルを当てていく開発手法の場合、Storybook の起動前に CSS Modules の型定義の生成を行わなければ TypeScript のエラーが発生するため、Storybook の起動前にプロジェクトを横断して型定義の生成が必要です。

影響範囲がパッケージ(モジュール)に閉じていない形で何か気持ち悪さがあるのでココは要改善ポイントですね。

まとめ

monorepo を1つの Storybook で管理する方法の一例は以上です。

パッケージが増えるたびに Storybook の設定を行う手間は減るのは利点ですが、パッケージによって必要な Storybook の addon が違ったりすると、1つで管理する Storybook が肥大化してどのパッケージがどの addon に依存しているのか分からなくなるといった問題も出てくるかと思います。

参照:https://storybook.js.org/docs/sharing/storybook-composition

Storybook のドキュメントでは Story Composition といった形式で、複数の Storybook を組み合わせ(Composition )る例が記載されており、どちらかというと1パッケージ 1Storybook を推奨している印象を受けます。

※該当の issue か PR、discussions を見つけられませんでしたが、メンテナーがそのような発言と上記ドキュメントのリンクを貼っていたような記憶があります

Story Composition と1つの Storybook で複数のパッケージを管理する併用もできそうです。

万人受けする構成ではないですが、monorepo の構成で Storybook の管理が煩雑だと思った時には、今回の例のように Storybook をまとめてしまうのも手だと思います。

この記事を書いた人

美髭公
美髭公事業開発部 web application engineer
2013年にアーティスに入社。システムエンジニアとしてアーティスCMSを使用したWebサイトや受託システムの構築・保守に携わる。環境構築が好き。
この記事のカテゴリ

FOLLOW US

最新の情報をお届けします