ReactのコンポーネントからStorybookのファイルを自動生成してみた
はじめに
フロントエンドのプロジェクトでは、UIコンポーネントのカタログとしてStorybookが用いられるケースがあると思います。
StorybookはコンポーネントベースのUI開発の助けとなるツールで、Reactのコンポーネントを独立して視覚的に確認できます。
しかしながら繁忙時や規模の大きいプロジェクトになると、メンバーの増減や開発工数等でStorybookの開発コストが高く感じられる状況があると思います。
そこで本記事では、なるべくStorybookの開発コストを軽減して、Storybookのファイルを自動生成する仕組みを考案してみました。
Storybookとは
Storybookは、UIコンポーネントを独立して開発・表示するためのツールです。
React, Vue, Angularなど、様々なフレームワークに対応しており、各コンポーネントの異なる状態やバリエーションを一覧で見ることができます。例えば、ボタンコンポーネントであれば、活性状態、非活性状態、ローディング状態などを一つの画面で確認することができます。
storybookを導入するメリットについて
-
コンポーネントの再利用:
Storybookを使用すると、すでに開発されたコンポーネントを容易に再利用できます。これにより、再開発の必要が減少し、開発速度が向上します。 -
コンポーネントの状態の可視化:
各コンポーネントの異なる状態やバリエーションを一覧で確認できるため、デザイナーや他の開発者とのコミュニケーションが効率的になります。 -
独立した開発:
UIコンポーネントをアプリケーションから独立して開発することができるため、ユニットテストやChromaticを使用したビジュアルリグレッションテストが容易になります。 -
ドキュメントとしての利用:
Storybookはコンポーネントのカタログとしても機能します。また新しい開発者がチームに参加した際の既存コンポーネントの理解も円滑になります。
自動化する前の課題点
フロントエンド開発を進める中で、UIコンポーネントが増えるにつれ、Storybook用のファイルも同時に増加していきました。
当初はコンポーネントの数が少なかったのでファイルの管理もしやすく、手動での作成もそれほど負担に感じなかったのですが、プロジェクトの規模が大きくなるにつれ、以下のような課題が浮き彫りになってきました。
-
手間の増加:
新しいコンポーネントを作成するたびに、ストーリーファイルの作成やそれに伴うStorybookで必要な設定の記述が必要になり、同様の記述を書き続けることが億劫に感じる瞬間が増加していました。 -
一貫性の欠如:
手動でのファイル作成は、特にテンプレートやガイドラインが不足していると、開発者によってファイルの記述内容が異なる場合がありました。
これにより、プロジェクト全体の一貫性が低下し、後からコードを見直す際に混乱が生じる可能性が想定されました。 -
ミスの増加:
手動での作業は、どれだけ慎重に行ってもヒューマンエラーが発生する可能性があります。
特に繁忙な開発状況下では、ファイルの作成忘れや、必要な設定の見落としなどのミスが発生しやすくなることが想定されました。 -
開発速度の低下:
上記の手間やミスを修正する時間が増加することで、本来の開発に割ける時間が減少し、結果として開発速度が低下してしまうことが懸念されました。
これらの課題を解決するため、Storybook用のファイルの生成を自動化する方法を検討しました。
自動化したこと
本記事で自動化できることは下記の4点になります。
- 対象のコンポーネントが作成されたら、そのコンポーネントと同階層に自動でストーリーファイルを作成する。
- 作成されたストーリーファイルに共通的な雛形を記述する。
- import文や型情報の記述を対象のコンポーネントからトレースして、自動生成されたストーリーファイルに記述する。
- gitのhooks(pre-commit)にストーリーファイルの自動生成スクリプトを混ぜ込むことで、対象のコンポーネントがcommitされたタイミングでスクリプトを実行する。
動作検証
対象のコンポーネントがコミット時された後にストーリーファイルが生成されることが確認できる
使用技術・バージョン
使用技術 | バージョン | 備考 |
---|---|---|
node.js | v18.15.0 | JavaScript実行環境 |
typescript | 5.0.4 | 型付けを持ったJavaScriptのスーパーセット |
storybook | ^7.1.0 | UIコンポーネントの開発・ドキュメンテーションツール |
ts-morph | ^19.0.0 | TypeScriptのASTを操作するライブラリ |
prettier | ^3.0.0 | コードのフォーマットを行うライブラリ |
esbuild | ^0.18.12 | 高速なJavaScriptとCSSのバンドラーとminifyを行うライブラリ |
esbuild-register | ^3.4.2 | Node.jsでのesbuildのトランスパイルサポート |
husky | ^8.0.3 | Gitのフックを設定するツール |
lint-staged | ^13.2.3 | Gitでステージングされたファイルに対して、Linterやフォーマッタを実行するツール |
ディレクトリ構成
本記事では、下記のようなディレクトリ構成で自動化スクリプトを実装していきます。
├── .storybook
│ ├── main.ts
│ └── preview.tsx
│
├── public
│ └── XXX.png
│
├── scripts
│ ├── createStory.ts
│ ├── package.json
│ └── tsconfig.json
│
├── src
│ └── components
│ └── Base
│ └── TestCreateStoryFile
│ ├── TestCreateStoryFile.story.tsx ← このストーリーファイルを自動生成する
│ └── index.tsx
│
├── package.json
├── tsconfig.json
実装
初期設定
scripts/package.json
{
"type": "modules",
"devDependencies": {
"@types/node": "^20.4.2",
"esbuild": "^0.18.12",
"esbuild-register": "^3.4.2",
},
}
scripts/tsconfig.json
{
"compilerOptions": {
"target": "es2020", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
/* Advanced Options */
"skipLibCheck": true, /* Skip type checking of declaration files. */
"forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */
"resolveJsonModule": true
}
}
package.json
{
"name": "xxx",
"version": "0.1.0",
"private": true,
"workspaces": [
"scripts"
],
"scripts": {
"dev": "next dev",
"build": "env-cmd -f .env.build next build",
"start": "next start",
"lint": "eslint './src/**/*.{js,ts,tsx}'",
"lint:fix": "eslint --fix './src/**/*.{js,ts,tsx}'",
"format": "prettier --check './src/**/*.{js,ts,tsx}'",
"format:write": "prettier --write './src/**/*.{js,ts,tsx}'",
"husky": "npx husky install",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"auto-create-storybook": "node -r esbuild-register scripts/createStory.ts"
},
"dependencies": {
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mantine/core": "^6.0.13",
"@mantine/dates": "^6.0.13",
"@mantine/form": "^6.0.13",
"@mantine/hooks": "^6.0.13",
"@types/node": "20.1.7",
"@types/react": "18.2.6",
"@types/react-dom": "18.2.4",
"next": "13.4.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"typescript": "5.0.4"
},
"devDependencies": {
"@storybook/addon-essentials": "^7.1.0",
"@storybook/addon-interactions": "^7.1.0",
"@storybook/addon-links": "^7.1.0",
"@storybook/blocks": "^7.1.0",
"@storybook/nextjs": "^7.1.0",
"@storybook/react": "^7.1.0",
"@storybook/testing-library": "^0.2.0",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.4.3",
"@types/eslint": "^8",
"@types/jest": "^29.5.2",
"@types/js-yaml": "^4",
"@types/react-slick": "^0",
"@types/slick-carousel": "^1",
"@types/testing-library__jest-dom": "^5.14.6",
"@typescript-eslint/eslint-plugin": "^5.59.6",
"chromatic": "^6.20.0",
"eslint": "^8.44.0",
"eslint-config-next": "^13.4.9",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-storybook": "^0.6.13",
"husky": "^8.0.3",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
"lint-staged": "^13.2.3",
"next-intercept-stdout": "^1.0.1",
"next-router-mock": "^0.9.7",
"prettier": "^3.0.0",
"storybook": "^7.1.0",
"ts-jest": "^29.1.0",
"ts-morph": "^19.0.0"
},
"lint-staged": {
"src/**/*.{js,ts,tsx}": [
"yarn run lint:fix",
"yarn run format:write",
"yarn run auto-create-storybook"
]
}
}
tsconfig.json
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@/*": [
"./src/*"
]
},
"baseUrl": "./"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/types/**/*.tsx"
],
"exclude": [
"node_modules"
]
}
.storybookの配下にmain.ts
とpreview.tsx
を作成します。
import type { StorybookConfig } from '@storybook/nextjs'
const config: StorybookConfig = {
stories: ['../src/components/**/*.story.tsx'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
],
framework: {
name: '@storybook/nextjs',
options: {},
},
docs: {
autodocs: 'tag',
},
staticDirs: ['../public'],
}
export default config
storybook/main.ts
はStorybookの設定ファイルとなっており、各プロパティの詳細は下記のようになっています。
プロパティ | 説明 |
---|---|
stories | ストーリーのファイルパスを指定します。../src/components/**/*.story.tsx を設定することで、src/components ディレクトリ配下の全ての .story.tsx ファイルをストーリーとして読み込むように設定しています。 |
addons | 追加機能や拡張機能を指定します。 下記が本記事で利用されているアドオンになります。 ・ @storybook/addon-links : ストーリー間のリンクを作成する ・ @storybook/addon-essentials : 主要なアドオンセット ・ @storybook/addon-interactions : インタラクションテスト用のアドオン |
framework | 使用するフレームワークやライブラリとそのオプションを指定します。@storybook/nextjs は、Next.js向けのStorybookを使用することを示しています。 |
docs | Storybookのドキュメント関連の設定です。autodocs: 'tag' は、タグをベースにドキュメントを自動生成する設定を意味します。 |
staticDirs | ストーリーで利用する静的ファイルが格納されているディレクトリのパスを指定します。../public は、プロジェクトの public ディレクトリを指定しています。 |
import React from 'react'
import type { Preview } from '@storybook/react'
import { MantineProvider } from '@mantine/core'
import { theme } from '../src/plugins/mantine'
const preview: Preview = {
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
},
decorators: [
(Story) => (
<MantineProvider theme={theme}>
<Story />
</MantineProvider>
),
],
}
export default preview
preview.tsxファイルは、Storybookのプレビューウィンドウの挙動や見た目を設定するファイルです。
-
parameters:
プロパティ 説明 actions argTypesRegexを使用して、特定の命名パターンを持つ定義にアクションを自動的に関連付けます。
この例では、on[大文字から始まるワード]
という形式の名前を持つ全ての定義に対してアクションを関連付けています。controls ストーリーのコントロール(インタラクティブな部分)の動作を設定します。
ここでは、色や日付に関連する名前を持つ定義を特定のコントロールタイプに関連付けるためのマッチャーを設定しています。 -
decorators:
すべてのストーリーに共通のラッピング要素やデコレーションを追加するためのプロパティです。
本記事では、全てのストーリーをMantineProvider
でラップして、カスタムされたMantineテーマを適用しています。
作成した自動化スクリプト
import fs from 'fs'
import path from 'path'
import { Project } from 'ts-morph'
import { exec } from 'child_process'
const componentsDir = path.join(__dirname, '../src/components')
const project = new Project({
tsConfigFilePath: path.join(process.cwd(), 'tsconfig.json'),
})
const createdStoryFileNames: string[] = []
const createStoryFiles = (dir: string) => {
const items = fs.readdirSync(dir)
items.forEach((item: string) => {
const fullPath = path.join(dir, item)
const stats = fs.statSync(fullPath)
if (stats.isDirectory()) {
createStoryFiles(fullPath)
}
if (
stats.isFile() &&
path.extname(item) === '.tsx' &&
!item.endsWith('.test.tsx') &&
!item.endsWith('.story.tsx')
) {
let componentName = path.basename(item, '.tsx')
if (componentName === 'index') {
componentName = path.basename(dir)
}
const storyFilePath = path.join(dir, `${componentName}.story.tsx`)
if (fs.existsSync(storyFilePath)) return
const relativeDir = path
.relative(componentsDir, dir)
.split(path.sep)
.join('/')
const title = `${relativeDir}/${componentName}`
const sourceFile = project.getSourceFile(fullPath)
if (!sourceFile) return
const typeAliases = sourceFile.getTypeAliases()
const typeAliasesTypeNames: { getName: any }[] = []
const typeAliasesTypeTexts: { getText: any }[] = []
typeAliases.forEach((typeAlias: { getText: any; getName: any }) => {
typeAliasesTypeNames.push(typeAlias.getName())
typeAliasesTypeTexts.push(typeAlias.getText())
})
const typeItems = JSON.stringify(typeAliasesTypeTexts)
.replace('"type Props = {\\n', '')
.replace('\\n}"', '')
.slice(1, -1)
.replace(/\\n/g, '\n')
.split('\n')
.map((item) => item.trim())
const argsObj: Record<string, unknown> = {}
typeItems.forEach((item) => {
if (!item.includes(':')) return
const key = item.split(':')[0].trim()
const value = item.split(':')[1].trim()
if (key.includes('?')) return
switch (!!value) { // ここでストーリーファイルの args: {} に当て込む初期値を設定しています
case key.includes('width'):
case key.includes('height'):
argsObj[key] = '100px'
break
case key.includes('color'):
argsObj[key] = 'white'
break
case key.includes('href'):
case key.includes('src'):
case key.includes('url'):
argsObj[key] = 'https://example.com'
break
case key.includes('backgroundColor'):
argsObj[key] = '#3460C5'
break
case key.includes('margin'):
case key.includes('padding'):
argsObj[key] = '10px'
break
case key.includes('children'):
argsObj[key] = 'ここにchildrenの内容が表示されます。'
break
case value.includes('number'):
argsObj[key] = 1
break
case value.includes('string'):
argsObj[key] = 'ダミーデータ'
break
case value.includes('boolean'):
argsObj[key] = false
break
default:
// 未知の型または複雑な型の場合、一時的に null を割り当てる
argsObj[key] = null
}
})
const importPath = path.relative(dir, fullPath).replace('.tsx', '')
const typeName = typeAliasesTypeNames[0]
const typeContent = typeAliasesTypeTexts.join('\n')
const imports = sourceFile.getImportDeclarations()
const importTypeTexts: string[] = []
const mantineCoreImportNames: string[] = []
const mantineTargetTypes = ['Sx', 'MantineSize']
imports.forEach(
(imp: {
getModuleSpecifierValue: any
getText: any
getNamedImports: any
}) => {
const importModule = imp.getModuleSpecifierValue()
if (
(typeContent && importModule.includes('@/types')) ||
importModule === '@mantine/form/lib/types'
) {
importTypeTexts.push(imp.getText())
}
if (importModule === '@mantine/core') {
const namedImports = imp.getNamedImports()
const importNames = namedImports.map(
(namedImport: { getName: any }) => namedImport.getName(),
)
const filteredImportNames = importNames.filter((name: string) =>
mantineTargetTypes.includes(name),
)
mantineCoreImportNames.push(...filteredImportNames)
}
},
)
const importTypePath = importTypeTexts.join('\n')
const importMantineCore =
mantineCoreImportNames.length > 0
? `import { ${mantineCoreImportNames.join(
', ',
)} } from '@mantine/core';`
: ''
const argsBlock = typeName
? `args:
${JSON.stringify(argsObj, null, 2)}
,`
: ''
const hasChildren = typeItems.some((item) => /^children:/.test(item))
const renderContent = hasChildren
? `<${componentName} {...args}>{args.children}</${componentName}>`
: `<${componentName} {...args} />`
const content = `
import { Meta, StoryObj } from '@storybook/react';
import ${componentName} from './${importPath}';
${importTypePath}
${importMantineCore}
${typeContent}
export default {
title: '${title}',
component: ${componentName},
tags: ['autodocs'],
${argsBlock}
// Add your own control here
} as Meta;
type Story = StoryObj<typeof ${componentName}>;
export const Default: Story = {
render: ${typeName ? `(args: ${typeName})` : '()'} => {
/* eslint-disable react-hooks/rules-of-hooks */
return (${renderContent});
},
};
`
fs.writeFileSync(storyFilePath, content)
createdStoryFileNames.push(storyFilePath)
}
})
}
console.log('Creating story files...')
createStoryFiles(componentsDir)
console.log('Done!')
console.log('Running Prettier...')
createdStoryFileNames.forEach((fileName) => {
exec(
`prettier --write ${fileName}`,
(error: any, stdout: any, stderr: any) => {
if (error) {
console.log(`error: ${error.message}`)
return
}
if (stderr) {
console.log(`stderr: ${stderr}`)
return
}
console.log(`stdout: ${stdout}`)
},
)
})
console.log('Prettier completed!')
createStory.tsの概要
-
初期設定:
- ソースコードのコンポーネントディレクトリのパスを定義します。
- TypeScriptプロジェクトの設定をロードします。
- 作成されたストーリーファイルの名前を保持する配列を初期化します。
-
createStoryFiles関数:
- この関数は、指定されたディレクトリ内のすべての.tsxファイルを走査し、それぞれのコンポーネントファイルに対応するストーリーファイルを生成します。
- すでにストーリーファイルが存在する場合、新しいファイルは生成されません。
- 各コンポーネントのPropsの型を読み取り、Storybookのargsオブジェクトを生成します。
-
Storybookのファイル内容の生成:
- 上記の関数内で、対応するストーリーファイルの内容が生成されます。
- 生成されたコードは、必要なインポート、コンポーネントの型定義、およびStorybookのデフォルトのエクスポートなど、一般的なStorybookのストーリーファイルの構造に従って、トレースを行います。
-
Prettierの実行:
最後に、生成されたすべてのストーリーファイルにPrettierを実行して、コードをフォーマットします。
(上記コードに対する補足説明)
絶対パスの生成
const componentsDir = path.join(__dirname, '../src/components')
上記部分はファイルシステムのパス操作を行っています。
カレントファイル(このコードが存在するファイル)のディレクトリと指定された相対パスを結合して、新しい絶対パスを生成しています。
.tsxファイルのみを対象にする
const createStoryFiles = (dir: string) => {
const items = fs.readdirSync(dir)
items.forEach((item: string) => {
const fullPath = path.join(dir, item)
const stats = fs.statSync(fullPath)
if (stats.isDirectory()) {
createStoryFiles(fullPath)
}
if (
stats.isFile() &&
path.extname(item) === '.tsx' &&
!item.endsWith('.test.tsx') &&
!item.endsWith('.story.tsx')
) {
・・・
}
上記では、与えられたディレクトリとアイテム名から絶対パスを作成し、そのパスがディレクトリかどうかを確認しています。もしディレクトリだった場合、同じ関数を再帰的に呼び出して、そのディレクトリ内のアイテム(.tsx
ファイル)を処理するように実装しています。
また自動生成スクリプトの実行の際に、テストファイル(.test.tsx
)や既存のストーリーファイル(.story.tsx
)を解釈させず、.tsx
ファイルのみを対象にしています。
相対パスの算出からストーリーファイルのタイトルの決定
const storyFilePath = path.join(dir, `${componentName}.story.tsx`)
if (fs.existsSync(storyFilePath)) return
const relativeDir = path
.relative(componentsDir, dir)
.split(path.sep)
.join('/')
上記コードでは、ストーリーファイルまでのパスを算出しています。
もし既存のパスとしてストーリーファイルが存在していた場合、関数の実行から外れるように実装しています。
ストーリーファイルが存在していなかった場合、与えられたディレクトリのパス(dir)を基にして、それがcomponentsDirからどれだけの相対的な位置にあるかを示す相対パスを計算します。
その後、その相対パスのセパレータを常に/
に統一する操作を行っています。
対象となる .tsxファイルに記述されている型情報からストーリーファイルのargs部分を算出する
const sourceFile = project.getSourceFile(fullPath)
if (!sourceFile) return
const typeAliases = sourceFile.getTypeAliases()
const typeAliasesTypeNames: { getName: any }[] = []
const typeAliasesTypeTexts: { getText: any }[] = []
typeAliases.forEach((typeAlias: { getText: any; getName: any }) => {
typeAliasesTypeNames.push(typeAlias.getName())
typeAliasesTypeTexts.push(typeAlias.getText())
})
const typeItems = JSON.stringify(typeAliasesTypeTexts)
.replace('"type Props = {\\n', '')
.replace('\\n}"', '')
.slice(1, -1)
.replace(/\\n/g, '\n')
.split('\n')
.map((item) => item.trim())
const argsObj: Record<string, unknown> = {}
typeItems.forEach((item) => {
if (!item.includes(':')) return
const key = item.split(':')[0].trim()
const value = item.split(':')[1].trim()
if (key.includes('?')) return
switch (!!value) {
case key.includes('children'):
argsObj[key] = 'ここにchildrenの内容が表示されます。'
break
case value.includes('number'):
argsObj[key] = 1
break
case value.includes('string'):
argsObj[key] = 'ダミーデータ'
break
case value.includes('boolean'):
argsObj[key] = false
break
・・・
default:
// 未知の型または複雑な型の場合、一時的に null を割り当てる
argsObj[key] = null
}
})
上記コードでは、対象の.tsxファイルのtype Props
の定義に記述されたkeyやvalueを基にして、下記のような生成されるストーリーファイルのargs部分(初期値)の算出を行っています。
export default {
title: '',
component: ,
tags: ['autodocs'],
args: {}, // ← 初期値の算出を行う
} as Meta
対象の.tsxファイルのimport文のトレースと型定義の取得
const importPath = path.relative(dir, fullPath).replace('.tsx', '')
const typeName = typeAliasesTypeNames[0]
const typeContent = typeAliasesTypeTexts.join('\n')
const imports = sourceFile.getImportDeclarations()
const importTypeTexts: string[] = []
const mantineCoreImportNames: string[] = []
const mantineTargetTypes = ['Sx', 'MantineSize']
imports.forEach(
(imp: {
getModuleSpecifierValue: any
getText: any
getNamedImports: any
}) => {
const importModule = imp.getModuleSpecifierValue()
if (
(typeContent && importModule.includes('@/types')) ||
importModule === '@mantine/form/lib/types'
) {
importTypeTexts.push(imp.getText())
}
if (importModule === '@mantine/core') {
const namedImports = imp.getNamedImports()
const importNames = namedImports.map(
(namedImport: { getName: any }) => namedImport.getName(),
)
const filteredImportNames = importNames.filter((name: string) =>
mantineTargetTypes.includes(name),
)
mantineCoreImportNames.push(...filteredImportNames)
}
},
)
const importTypePath = importTypeTexts.join('\n')
const importMantineCore =
mantineCoreImportNames.length > 0
? `import { ${mantineCoreImportNames.join(
', ',
)} } from '@mantine/core';`
: ''
上記のコードでは、.tsx
ファイルの情報からimport文を取得しています。
またMantineUIからimportされている型や自分で作成した独自の型(@/types
配下の型)の情報を抽出しています。
生成するストーリーファイルに記述する雛形の作成と書き出し
const hasChildren = typeItems.some((item) => /^children:/.test(item))
const renderContent = hasChildren
? `<${componentName} {...args}>{args.children}</${componentName}>`
: `<${componentName} {...args} />`
const content = `
import { Meta, StoryObj } from '@storybook/react';
import ${componentName} from './${importPath}';
${importTypePath}
${importMantineCore}
${typeContent}
export default {
title: '${title}',
component: ${componentName},
tags: ['autodocs'],
${argsBlock}
// Add your own control here
} as Meta;
type Story = StoryObj<typeof ${componentName}>;
export const Default: Story = {
render: ${typeName ? `(args: ${typeName})` : '()'} => {
/* eslint-disable react-hooks/rules-of-hooks */
return (${renderContent});
},
};
`
fs.writeFileSync(storyFilePath, content)
createdStoryFileNames.push(storyFilePath)
上記コードでは、.tsx
ファイルの型情報からchildrenを持つコンポーネントかどうかを判断し、雛形を作成し、吐き出されるDOM形式を算出しています。
また算出された雛形をストーリーパスに従って、書き出す実装を行っています。
自動化のメリット
自動化を行い、実際に筆者が感じたメリットは下記のようになります。
-
効率性の向上:
新しいコンポーネントのためのストーリーファイルを手動で作成する手間を省くことができ、実装者の手を煩わせることなくストーリーファイルの大枠を作成できました。 -
一貫性の確保:
自動生成スクリプトにより、すべてのストーリーファイルが統一されたフォーマットで生成されるため、記述形式や内容の差異を最小限に抑えることができました。 -
エラーの減少:
人が手動でコードを書く場合、コンポーネントの構造を判断しながらストーリーファイルを記述する必要があるが、自動生成スクリプトによって、propsの各プロパティやchildrenの有無を判断できるようにしてエラーが発生しづらい環境を構築できました。 -
アダプティブなコンテンツ生成:
コンポーネントのpropsの型に基づいて適切なデフォルトのarg値を自動的に算出することで、スクリプトで生成されるストーリーはすぐに動作する状態になりました。 -
コードの整形:
スクリプトの最後にはPrettierが実行されており、生成されたすべてのストーリーファイルが自動的に整形されることで、コードの品質と読みやすさを担保しています。 -
開発速度の向上:
スクリプトを使用することで、開発者は新しいコンポーネントを迅速にStorybookに統合でき、Chromaticを使用したVRT等に活用できました。
まとめ
本記事では、ReactのコンポーネントからStorybookのストーリーファイルを効率的に自動生成する方法を紹介しました。
本記事で紹介している実装は一例に過ぎず、スクリプトの実装面やパフォーマンス部分はまだ改善の余地が多数あります。
これからもより効率的なStorybookの構築を目指して、改善を続けていきたいと考えています。
参考文献
Discussion