React + TypeScript + Webpack5なStorybookをバージョン7にアップグレードしてみた

# はじめに
trocco® プロダクトチームではコンポーネントカタログとしてStorybookを使用しています。
デザイナーやエンジニアだけでなく、あらゆる職種の方がコンポーネントの仕様について理解し統一感を持ったUIを作るのにStorybookは欠かせません。
共通で使われるコンポーネントについては、`.stories.mdx`形式でコンポーネントのパターンを展開しつつ、ドキュメントで利用方法や注意点を補足しています。

今年の4月にStorybook7が発表され、このアップグレードによってWebpack4依存がなくなりいくつかのライブラリの脆弱性の解消もなされることになりました。

# Storybook7における変更点
詳しくは公式の記事を参照していただくのが一番ですが、ViteサポートやUIの変更、Docs周りの自動生成など多数の機能が盛り込まれているようです。

https://storybook.js.org/blog/storybook-7-0/

本記事では、私たちのチームでStorybookを6.5から7.0にアップグレードするにあたって実施したことや、理解する必要があった点についてピックアップしたいと思います。

# Automigration以外に実施したこと
## babel-loader → esbuild-loaderへの移行
Storybookの強みは "Zero-config" = 開発者が設定を二重にしなくてもそのまま動作することにあり、Storybook 6.0ですでに内部的なBabelによるトランスパイルを用いたTypeScriptのサポートが実現されていました。今回7.0からNextJSやSvelteKitといったフレームワークもZero-configでサポートされるようになったようです。

Storybookは独自のビルドプロセスを持っており、プロジェクトでカスタマイズしない限りはWebpackのコンフィグが不要です。

詳しくは以下の記事がわかりやすかったです。

https://qiita.com/zaki-yama/items/ef2b05a6e60f37701c0d

記事に記載の通り、プロジェクトのwebpack.config.js設定の流用は可能なもののjsのローダーはデフォルトではbabelを使用する設定になっています。

Storybook7から、プロジェクトでbabelを使用している場合は`.babelrc`を共有する設定になっており、`.babelrc`が存在しない場合はデフォルトの設定ファイルを生成するオプションが選べるようになっていました。

今回、babel-loaderを使用する形でAutomigrationを実行するだけではビルドの時間が長くなってしまいCIでVRT用に実行しているStoryCapのタイムアウト時間(外部からオプションを渡して変更することができない)に抵触してしまうことが判明しました。

ビルド高速化について調べていたときに発見したのが**storybook-addon-turbo-build**でした。storybook-addon-turbo-buildではbabel-loaderの代わりにesbuild-loaderを使用してビルドを行ってくれる設定があります。私たちのプロジェクトでは元々babel-loaderではなくts-loaderを使用、Storybookのみbabel-loaderという状態だったため、Automigrationで聞かれるbabelの設定をオプトアウトし、esbuild-loaderを使用する形に変更しました。

アドオンのインストール後、特別な設定をしなくともTypeScriptのビルドやトランスパイルも行ってくれました。
他にもSourceMapの生成を抑止することでビルドパフォーマンスを向上させています。

https://storybook.js.org/addons/storybook-addon-turbo-build

今回は`optimizationLevel: 3`にすることでpreview（コンポーネントのレンダリング）にかかる時間を短縮し、

```js
// .storybook/main.js
module.exports = {
  addons: [
    // ...,
    {
      name: "storybook-addon-turbo-build",
      options: {
        optimizationLevel: 3,
      },
    },
  ],
};
```
導入前に比べて大幅に起動時間を短縮することができました。

#### Before 
```
╭─────────────────────────────────────────────────╮
│                                                 │
│   Storybook 7.0.18 for react-webpack5 started   │
│   4.95 s for manager and 22 s for preview       │
│                                                 │
│    Local:            http://localhost:6006/     │
│    On your network:  http://172.18.0.4:6006/    │
│                                                 │
╰─────────────────────────────────────────────────╯
```

#### After
```
╭─────────────────────────────────────────────────╮
│                                                 │
│   Storybook 7.0.18 for react-webpack5 started   │
│   1.37 s for manager and 14 s for preview       │
│                                                 │
│    Local:            http://localhost:6006/     │
│    On your network:  http://172.18.0.4:6006/    │
│                                                 │
╰─────────────────────────────────────────────────╯
```

## `.stories.mdx` から CSF + MDXへの移行
元々私たちのプロジェクトでは`.stories.mdx`という形式で、storiesの描画とドキュメントを同時に行っていたのですが、こちらがStorybook7から非推奨になりました。
CSFを使用することで、Storybookのさまざまな機能やツール（自動ドキュメンテーション生成、ダイナミックなプレビュー、自動テストなど）との統合が強化されるため、今後Storybookを活用していくにはCSF + MDXの形式に分割した方がよさそうです。
また、Storybook7にアップグレードしてから`.stories.mdx`で描画しているコンポーネントのpropsにUTF-8文字列を渡した時に正しくデコードしてくれないという事象を観測したため、このタイミングで一気に対応することにしました。

https://storybook.js.org/docs/react/migration-guide#storiesmdx-to-mdxcsf

```
npx storybook@latest migrate mdx-to-csf --glob "src/**/*.stories.mdx"
```

マイグレーションのオプションに、`.stories.mdx` を CSF + MDXのファイルに分割してくれる便利なコマンドがありました。ただ適用するのみでは`.stories.js`への変換になってしまうため、自動変換後は別途変換用スクリプトを生成し、TypeCheckが通るように拡張子や`react`モジュールのインポートの一括変換をかけていきました。（`react`モジュールのインポートはReact 17以降であれば不要な作業です）

また、babel-loader使用時にTypeCheckしていなかったことで発生していたコンポーネントのprops指定漏れなどがあったので、それらを一件一件手で修正していきました。

```sh:stories.jsをstories.tsxに変換する
find src -name "*.stories.js" -exec sh -c 'mv "$0" "${0%.js}.tsx"' {} \;
```

```sh:reactモジュールをインポート
find src -name "*.stories.tsx" -exec sed -i "1i import React from 'react'" {} \;
```

例えば、以下のような`Badge.stories.mdx`があったとしたら、

```md:Badge.stories.mdx
import { Canvas, Meta, Story } from '@storybook/addon-docs'

import { Badge } from './Badge'

<Meta title="atoms/Badge" component={Badge} />

# Badge

件数表示などに使用する。

## default

<Canvas>
  <Story name="Default">
    <Badge>default</Badge>
  </Story>
</Canvas>

## サイズ指定

サイズ指定が可能。左から `large`, `medium`, `small`。

<Canvas>
  <Story name="AllSize">
    <Badge size="large">12</Badge>
    <Badge size="medium">12</Badge>
    <Badge size="small">12</Badge>
  </Story>
</Canvas>

## 色指定

色を指定できる。

<Canvas>
  <Story name="Color">
    <Badge color="gray">gray</Badge>
  </Story>
</Canvas>
	
```
以下のように`Badge.mdx` `Badge.stories.tsx`の2ファイルに分割されます。
```md:Badge.mdx
import { Canvas, Meta, Story } from '@storybook/blocks'
import { Badge } from './Badge'
import * as BadgeStories from './Badge.stories';


<Meta title="atoms/Badge" component={Badge} />

# Badge

件数表示などに使用する。

## default

<Canvas>
  <Story of={BadgeStories.Default} />
</Canvas>

## サイズ指定

サイズ指定が可能。左から `large`, `medium`, `small`。

<Canvas>
  <Story of={BadgeStories.AllSize} />
</Canvas>

## 色指定

色を指定できる。

<Canvas>
  <Story of={BadgeStories.Color} />
</Canvas>
```
```tsx:Badge.stories.tsx
import React from 'react'

import { Badge } from './Badge'

export default {
  title: 'atoms/Badge',
  component: Badge,
}

export const Default = {
  render: () => <Badge>default</Badge>,

  name: '基本',
}

export const AllSize = {
  render: () => (
    <>
      <Badge size="large">12</Badge>
      <Badge size="medium">12</Badge>
      <Badge size="small">12</Badge>
    </>
  ),

  name: 'AllSize',
}

export const Color = {
  render: () => <Badge color="gray">gray</Badge>,

  name: 'Color',
}
```

# 今後の見通し

これを機に、プロジェクトのWebpackと合わせてStorybookのビルドもViteなどに移行し、さらなるビルドの高速化ができるといいなと思っています。

古い記載、誤った記載等がありましたらご指摘いただけますと幸いです。