Zenn
💡

Storybookのstoryをnpmで自動で生成する方法

に公開

モチベーション

State of JSの調査によるとフロントエンド開発においてStorybookが使用されるケースは増加傾向にあります。

https://2024.stateofjs.com/en-US/libraries/?utm_source=chatgpt.com

僕個人の肌感でも参画したほとんどのプロジェクトでは使われており、僕自身もStorybookのおかげでUI開発が楽になっていると感じることが多々あります。
StorybookはUIコンポーネントをロジックから分離させて開発したり、挙動を確認することができるツールです。React, Vue, Angular, Svelteなどの様々なフレームワークやライブラリに対応しておりコンポーネントやページの様々な状態を確認することができます。

このコンポーネントをロジックから分離して管理、開発できることがこれほど開発を楽にしてくれるとは思っても見ませんでした。(学校の授業で学んだ時はこの良さを全く理解できず、実務にて利用し実感しました)

しかし、人によっては下記のような理由でStorybookの開発がスキップされることも多々あります。

  • コンポーネント開発時にStorybook開発もすることは余分な工数と捉えれれる
  • 繁忙期になると機能開発を優先してStorybookの開発が後回しになる

そこでStorybookの開発を出来る限り軽減したいと思い、Storyを自動生成する仕組みを作成しました。

この記事ではStorybookのファイル(.stories.tsx|jsx)のことをStoryと呼んでいます。

Storybookの活用例

StorybookはUIコンポーネントをロジックから分離させて開発したり、挙動を確認することができるツールです。React, Vue, Angular, Svelteなどの様々なフレームワークやライブラリに対応しておりコンポーネントやページの様々な状態を確認することができます。

i.e.

  • ページのログイン前後のUI
  • ボタンの活性、非活性

Storybookを利用するアドバンテージ

Storybookを使うことで享受できるアドバンテージは3つあります。

  1. UIコンポーネントの状態を可視化
    それぞれのUIコンポーネントの様々な状態をカタログのように閲覧することができます。これによりデザイナーとUIを確認することが効率的に行えるようになります。

  2. ロジックから分離したUI開発
    UIコンポーネントをロジックから分離して開発を行えるようになります。これによりユニットテストを書きやすくなります。また、Storyを書くことが難しいUIコンポーネントはかなり複雑化している可能性が高いので、コンポーネントを分けるべきかの判断軸にもなります。僕はReactを使用しているのでpropsによるUIコンポーネントの見た目を確認する際に活用しています。これによりローカルのDBにダミーデータを入れたり、React DevToolでpropsを操作することなくUI開発を行うことが可能になります。

  3. 仕様書としての利用
    最近(ようやく)気づいたことが、Storybookは仕様書としても利用することができます。例えば「この状態の際はUIはこのような見た目になる」といったものです。

感じていた課題

Storybookが導入されたものの、UIコンポーネントによってはStoryが作成されていないという課題がありました。マニュアルでの実装であったためUIコンポーネントが増えるに連れてStoryを作成することが各開発者の負担になっていました。

具体的には以下の3つのようなことでした。

  1. 手戻りの増加
    仕様が属人化していたり、それぞれの認識が異なるためやり直しが多くなったり確認のために余分なやりとりが増加していました。

  2. 同じ記述を書き続けることに対する抵抗感
    Storyの記述はほとんどが同じで、UIコンポーネントによってpropsとして渡す状態が異なるくらいです。そのためUIコンポーネントを実装するたびに同じ内容を書くことに対して億劫になり、その結果Storyが実装されなくなっていました。

  3. 開発速度の低下
    上記2つの課題が結果として開発速度の低下や余分な工数の発生に繋がっていました。

これらの課題を解決することでStoryの生成を自動化したいと思い取り組みました。

自動化できる内容

本記事で自動化できる内容は下記の通りです。

  1. スクリプトを実行すると、指定したコンポーネントのStoryが生成される
npm run 関数名 <Storyを作成したいcomponentの名前>
  1. 作成されるStoryの記述内容はテンプレートのみでpropsは各実装者に必要に応じて追加してもらう。

あくまでも生成するものはテンプレートなのでコンポーネントによってはStorybook上でエラーが発生しStoryが閲覧できないこともあるので、テンプレートはよしなに編集してください。(i.e. ReactのRSC)

実行結果

この例ではCancelButtonというコンポーネントのstoryを自動生成しています。

実装内容

使用技術とバージョン

必要に応じてインストールしてください。

  • Node.js v21.6.1
  • TypeScript 5.6.2 or higher
  • React 19.0.0
  • Storybook ^8.6.11
  • vite ^6.0.3
  • ts-node ^10.9.2

ts-nodeはNode.js環境でTypeScritを実行するためのエンジンです。

https://nodejs.org/en/learn/typescript/run

ディレクトリ構成

├── .storybook
│   ├── main.ts
│   ├── preview.tsx
│   └── vitest.setup.ts
│
│
├── public
│   └── XXX.png
│
├── scripts
│   └── generate-story.ts
│
├── src
│   └── components
│           └── cancelButton
│               ├── CancelButton.story.tsx ← このストーリーファイルを自動生成する
│               ├── CancelButton.tsx
│               └── index.tsx
│		
├── package.json
├── tsconfig.json
├── tsconfig.node.json

Storybook関連のファイル

この辺りはStorybookをインストールした際に生成されたまま状態です。

.storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';

const config: StorybookConfig = {
  "stories": [
    "../src/**/*.mdx",
    "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"
  ],
  "addons": [
    "@storybook/addon-essentials",
    "@storybook/addon-onboarding",
    "@chromatic-com/storybook",
    "@storybook/experimental-addon-test"
  ],
  "framework": {
    "name": "@storybook/react-vite",
    "options": {}
  }
};
export default config;
.storybook/preview.ts
import type { Preview } from '@storybook/react'

const preview: Preview = {
  parameters: {
    controls: {
      matchers: {
       color: /(background|color)$/i,
       date: /Date$/i,
      },
    },
  },
};

export default preview;
.storybook/vitest.setup.ts
import { beforeAll } from 'vitest';
import { setProjectAnnotations } from '@storybook/react';
import * as projectAnnotations from './preview';

// This is an important step to apply the right configuration when testing your stories.
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
const project = setProjectAnnotations([projectAnnotations]);

beforeAll(project.beforeAll);

Storyを自動生成するスクリプト

scripts/generate-story.ts
import fs from "fs"
import path from "path"

interface ComponentConfig {
  srcDir: string
  componentDirs: string[]
}

// This script generates a Storybook story file for a given component.
// Usage: npm run generate-story <componentName>
// Example: npm run generate-story Button


const config: ComponentConfig = {
  srcDir: "./src",
  componentDirs: [
    // Add your component directories here if necessary
   "components",
  ],
}

// Grab the component name from the command line arguments
const componentName = process.argv[2]
if (!componentName) {
  console.error("コンポーネント名を指定してください")
  process.exit(1)
}

function generateStoryTemplate(componentName: string, componentPath: string): string {
  return `import type { Meta, StoryObj } from "@storybook/react"
// This is useful if you want to spy on the certain action in the story such as onClick
import { fn } from '@storybook/test';

import { ${componentName} } from "./${componentName}"

const meta = {
    title: "${componentPath.replace(/^src\//, "")}",
    component: ${componentName},
    parameters: {
    // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
    layout: 'centered',
    },
    // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
    tags:["autodocs"]
} satisfies Meta<typeof ${componentName}>

export default meta;
type Story = StoryObj<typeof ${componentName}>

export const Default: Story = {
    args: {
        // ここにpropsを追加してください
    }
}`
}

// Helper functions
// ディレクトリの中身を取得
function readDirSync(dirPath: string): string[] | null {
  try {
    return fs.readdirSync(dirPath)
  } catch {
    return null
  }
}

// ディレクトリの情報(メタデータ)を取得
function getStatSync(fullDirPath: string): fs.Stats | null {
  try {
    return fs.statSync(fullDirPath)
  } catch {
    return null
  }
}

// 大文字小文字を無視して文字列を比較
// プロジェクトによってはディレクトリ名は小文字スタート、ファイル名は大文字スタートのため
// 例: isCaseInsensitiveMatch("Foo", "foo") => true
function isCaseInsensitiveMatch(a: string, b: string): boolean {
  return a.toLowerCase() === b.toLowerCase()
}

function isPathValid(dirPath: string, componentName:string): boolean{
    const componentPath = path.join(dirPath, `${componentName}.tsx`)
    return fs.existsSync(componentPath)
}

// ディレクトリ内を再帰的に探索
function searchDirectoryPath(dirPath: string, componentName: string): string | null {
  const items = readDirSync(dirPath)
  
  if (!items) {
    console.error(`ディレクトリが見つかりません: ${dirPath}`)
    return null
  }

  for (const item of items) {
    const fullDirPath = path.join(dirPath, item)
    const stat = getStatSync(fullDirPath)

    // Skip if the item is not a directory or the component file with .tsx does not exist
    if (!stat) continue

    if(stat.isDirectory()){
        if(isCaseInsensitiveMatch(item, componentName) && isPathValid(fullDirPath, componentName)){
            return fullDirPath
        }

        const result = searchDirectoryPath(fullDirPath, componentName)
        if (result) return result
    }
  }

  return null
}

// コンポーネントのパスを検索
function searchComponentPath(componentName: string): string | null {
  for (const dir of config.componentDirs) {
    const fullDirPath = path.join(config.srcDir, dir)
    console.log(`Searching in directory: ${fullDirPath}`)

    if (!fs.existsSync(fullDirPath)) continue

    const result = searchDirectoryPath(fullDirPath, componentName)

    if (result) return result
  }

  return null
}

// Storyファイルを生成
function generateStory() {
  try {
    const componentDir = searchComponentPath(componentName)
    const items = readDirSync(componentDir!)
    if (!componentDir || !items?.includes(`${componentName}.tsx`)) {
      console.error(`コンポーネント${componentName}がsrc配下に見つかりません`)
      process.exit(1)
    }

    const storyBookPath = path.join(componentDir, `${componentName}.stories.tsx`)

    // 既にstoryが存在している場合は処理を終了
    if (fs.existsSync(storyBookPath)) {
      console.error(`既にstoryが存在しています: ${storyBookPath}`)
      process.exit(1)
    }

    // storyファイルを生成
    const storyContent = generateStoryTemplate(componentName, componentDir)
    fs.writeFileSync(storyBookPath, storyContent)
    console.log(`Storyファイルを生成しました: ${storyBookPath}`)
  } catch (error) {
    console.error('予期せぬエラーが発生しました:', error instanceof Error ? error.message : error)
    process.exit(1)
  }
}

console.log('Generating story for component:', componentName)
generateStory()
console.log('Story generation completed.')

generate-story.tsの内容

  • 初期設定

    • ソースコード内のディレクトリの情報を定義しています。config定数のcomponentDirs内にあるUIコンポーネントのStoryを自動生成できます。プロジェクトのディレクトリ構成に応じて編集してください。
  • 生成するstoryのテンプレート(generateStoryTemplate)

    • 必要なインポート、propsを基に型定義、storyのエクスポートを行っています
    • StoryのテンプレートをCSFで実装しています
    • この部分は必要に応じて編集してください

https://storybook.js.org/docs/api/csf

  • ヘルパー関数

    • readDirSync: ディレクトリ内に存在するファイル名を抽出します
    • getStatSync: ディレクトリの情報(メタデータ)を取得します
    • isCaseInsensitiveMatch: 大文字小文字を無視して文字列を比較します。プロジェクトによってはディレクトリ名は小文字スタートの場合があるため実装しました
    • isPathValid: UIコンポーネントのパスが存在するかを確認します
    • searchComponentPath: コンポーネントのパスを探索します
    • searchDirectoryPath: searchComponentPathのヘルパー関数です。ソースコードに上述のconfigで指定したcomponentDirs配下にstoryを作成したいUIコンポーネントのディレクトリのパスが存在するかを再帰的に確認します
  • Storyを生成する関数(generateStory)

    • npmスクリプトで渡させたコンポーネントのStoryを生成します
    • 既にstoryがある場合処理をスキップします
    • アプリ内のどこにでも作成できるのではなく、特定のディレクトリ配下にあるコンポーネントのstoryのみ作成可能です(UIコンポーネントのファイルを管理する場所の整理にもつなります)

package.json

スクリプトを実行するための記述です。
TS_NODE_PROJECT=tsconfig.node.jsonとして参照するconfigファイルをNode.jsのものに指定しています。

script{
    "generate-story": "TS_NODE_PROJECT=tsconfig.node.json NODE_OPTIONS='--loader ts-node/esm' node scripts/generate-story.ts"
}

まとめ

本記事ではStorybookのstoryを自動生成する方法を紹介しました。これが完成形ではなく、まだまだ改善の余地はあると思っています。(i.e. story生成時にBiome等のフォーマッターを走らせる)

何よりも「Storyを自動生成する」という当初の目標は達成できたので個人的には小さな達成感があります。これからも開発生産性が上がるようなStorybookの運用方法を考え、改善を続けていきたいと思います。

Discussion

ログインするとコメントできます