プロジェクトにStorybookを導入した際、詰まったポイントと解決方法

# 概要
先日投稿した記事のように[Storybookを触ってみたらとても良かった](https://qiita.com/kotobuki5991/items/dcf411789b359d99f82a)ので、実際に弊社のプロジェクトにStorybookを導入してみました。

現在私が開発に携わっている[Video Kicks](https://www.tunecore.co.jp/video-kicks)では、Atomic Designを採用しています。

その中でもまずは、コンポーネント増減が少なくstorybook関連ファイルのメンテナンスコストが低いatomsに対して導入し、
様子を見つつStoryを随時追加していくことを目標とします。

この記事では、Storybook導入の中で詰まったポイントと解決方法を紹介します。
Storybookそのものの紹介はしないので、それが知りたい場合は、[Storybookを触ってみたらとても良かった](https://qiita.com/kotobuki5991/items/dcf411789b359d99f82a)または、[公式ドキュメント](https://storybook.js.org/)をご確認ください。


# 作業環境

- Vue.js・・・3.2.31
- storybook/vue3・・・6.5.16

etc..

# ディレクトリ構成

以下のように、srcディレクトリ配下とstoriesディレクトリ配下の構造が同じになるように設定します。
※本記事で触れないディレクトリは省略しています。

```
root/
 ├ _templates/stories/new/
 ├ .storybook/
 ├ public/img/
 ├ src/
 |  └components/atoms/
 └ stories/
    └components/atoms/
```
vueファイルとstoriesファイルを同じディレクトリに配置する以下のような方法もあるようですが、
現状のsrcディレクトリ配下の構成に極力影響を与えないために、上の構成を採用しています。
```
src/
 └ components/
       └ atoms/
           └ button/
                └ Button.stories.mdx
                └ Button.vue
```




# 詰まったポイント

## 画面にコンポーネントが表示されない

<img width="800px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475547/cfb67922-3e0b-5c6c-560c-85352c557571.png">

このように、ツリー部分にはコンポーネント名、ストーリー名が表示されていますが、Canvasタブにコンポーネントが表示されません。

### 原因
vueファイル内のstyleが読み込めていないことが原因でした。

### 解決方法
- .storybook/main.jsを以下のように修正します。

```main.js
const path = require("path");

module.exports = {
  "stories": ["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
  "addons": ["@storybook/addon-links", "@storybook/addon-essentials", "@storybook/addon-interactions"],
  "framework": "@storybook/vue3",
  core: {
    builder: "webpack5"
  },
  webpackFinal: async (config) => {
    config.resolve.alias = {
      '@': path.resolve(__dirname, '../src'),
      'vue$': 'vue/dist/vue.esm-bundler.js'
    }
    // ここから
    config.module.rules.push(
      {
        test: /\.scss$/,
        use: [
          'vue-style-loader',
          'css-loader',
          'sass-loader'
        ]
      }
    );
    // ここまで
    return config
  }
};
```

## 画像が表示されない

画像を使用したコンポーネントや、propsとして画像のパスを渡すコンポーネントで、画像が表示されませんでした。

### 原因
画像のパスが解決できていないことが原因でした。

### 解決方法
- .storybook/main.jsに以下の設定を追加します。

```js
staticDirs: ['../public'],
```
こうすることで/img/hogehoge.jsで../public/img/hogehoge.jsへアクセスできるようになります。[ ]内の値はディレクトリ構成によって変わります。


## グローバルなVueコンポーネントを登録したい

Video Kicksでは、atomsに当たるコンポーネントはインポートなしで読み込めるようにグローバル登録しています。
これをstorybook上でも同様に利用できるようにします。


### 解決方法
.storybook/preview.jsを以下のように編集します。

```preview.js
import { app } from '@storybook/vue3'; // storybookで使用されるvue3
import * as components from "@/components/atoms/components";  // グローバル登録したいコンポーネントをインポート

export const parameters = {
  actions: { argTypesRegex: "^on[A-Z].*" },
  controls: {
    matchers: {
      color: /(background|color)$/i,
      date: /Date$/,
    },
  },
}

// appにコンポーネントを登録
export default class VKUI {
  static install = (app) => {
    for (const key in components) {
      const component = (components)[key];
      app.component(key, component);
    }
  };
}

app.use(VKUI);
```
そうすると、以下のように storiesファイル内でも`<vk-row>`や`<vk-col>`をimportなしで使用することができます。

```js
// 一部抜粋
export const Template = (args) => ({
  components: { ImageUploader },
  setup() {
    return { args };
  },
  template: `
    <vk-row>
      <vk-col class="w-80">
        <image-uploader
          v-bind="args"
          @change="${args.change}"
          @delete="${args.delete}"
        >
      </vk-col>
    </vk-row>
  `,
});
```


## グローバルなcssを登録
アプリ全体で共通使用しているcssを登録します。

### 解決方法

```preview.js
import "@/assets/init.css";
import "@/assets/grid.css";
```

## イベントに対応するアクションを定義しても反応しない

例えば、changeイベント発火時に動作する[Actions](https://storybook.js.org/docs/react/essentials/actions)を定義したときに、動作しない状況があります。

### 原因

argsとして定義する際の命名が間違っていたようです。

### 解決策

"@storybook/addon-actions”を使用します。

[アクションの定義](https://zenn.dev/sa2knight/books/storybook-7-with-vue-3/viewer/addon_actions#%E3%82%A2%E3%82%AF%E3%82%B7%E3%83%A7%E3%83%B3%E3%81%AE%E5%AE%9A%E7%BE%A9)
また、命名は以下のようになっています。
> アクション名は `onXXX` という命名にすることで、自動的に `XXX` に対応するイベントのハンドラとして定義されます。

従って、changeのイベントハンドラはonChangeで定義します。

```
<Story
    name="Uploaded"
    args={{
      src: "/img/linkcore.png",
      id: "uploader-id",
      disabled: false,
      name: "uploader-name",
      onChange: action('changeImage'),
      onDelete: action('deleteImage'),
    }}>
    {Template.bind({})}
</Story>
```
そうすると、以下のようにActionsタブにイベント発火時の情報が表示されるようになります。

<img width="800px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475547/177c77ec-09e5-7196-2a92-93f16fe96fbb.png">


## Docsタブの内容を自動生成したい

記述したStoryに応じて、Docsタブの内容を生成します。

### 解決方法
`@storybook/addon-docs`を使用します。

- Meta
    - title・・・ツリーに表示されるコンポーネント名
    - argTypes・・・propsに関する設定を行う
- Canvas・・・この中に記述したStoryはDocs上に表示する枠が生成される
- ArgsTable・・・storyに特定のStoryのname属性値を渡すことで、argsに関する表が生成される

~~~md
import { Meta, Story, ArgsTable, Canvas } from "@storybook/addon-docs";
import { action } from '@storybook/addon-actions';
import VkBackLinkBtn from '@/components/atoms/VkBackLinkBtn.vue';

<Meta
  title="components/atoms/VkBackLinkBtn"
  argTypes={{
    to: {
      description: '遷移先のパス',
      table: {
        category: 'props',
        defaultValue: { summary: "" },
      },
    },
    slotValue: {
      description: 'slotsの内容',
      table: {
        category: 'slots'
      },
    },
  }}
/>

export const Template = (args) => ({
  components: { VkBackLinkBtn },
  setup() {
    return {...args};
  },
  template: `
    <VkBackLinkBtn :to="to">
      <template v-if="${"slotValue" in args}" v-slot>${args.slotValue}</template>
    </VkBackLinkBtn>
  `,
});

# VkBackLinkBtn
<br/>

## Component
<Canvas>
  <Story
    name="VkBackLinkBtn"
    args={{
      to: "/release/music_video/list",
      slotValue: "ボタンの文字"
    }}
    >
    {Template.bind({})}
  </Story>
</Canvas>

## Usage
```vue
<vk-back-link-btn to="/release/music_video/list">
    一覧へ戻る
</vk-back-link-btn>
```

## Props

<ArgsTable story="VkBackLinkBtn" />

~~~

このようなDocsが生成されます。

<img width="800px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475547/647a2a72-288c-6bdd-5ca1-cf23adf359b3.png">

## cssのFontAwesomeアイコンが認識されない

```css
.error-message:before {
  font-family: "FontAwesome";
  padding: 0 4px 0 0;
  content: "\f06a"; // これ
  color: $c_text3;
}
```
！マークが表示されてほしいですが、
<img width="400px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475547/f0685e94-706c-4977-c7bb-4c965c1b3d15.png">

このように文字化けしています。
<img width="800px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475547/9405e6c0-0f40-fa23-6220-ece42c55a8da.png">


storybookをブラウザのdevtoolで確認した時CSSは以下のようになっていました。
```css
.error-message[data-v-60e39706]:before {
    font-family: "FontAwesome";
    padding: 0 4px 0 0;
    content: ""; // 文字化け
    color: #ff6e54;
}
```

### 解決策

FontAwesomeを読み込むmain.scssを追加し、preview.jsからインポートします。

```main.scss
@import "https://netdna.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.css"
```

```preview.js
import { app } from '@storybook/vue3';
import * as components from "@/components/atoms/components";
import "@/assets/init.css";
import "@/assets/grid.css";
import "./main.scss"; // これを追加
```

## .stories.mdxファイルの雛形を自動生成したい

これは本当に導入した方がいいです。すごく時短になります。

### 解決策
このツールを使用します。
[Hygen](https://www.hygen.io/)

まずは、以下のコマンドを実行します。

```
$ cd projectroot

$ npm install --save-dev hygen

$ hygen init self

	Loaded templates: /Users/fukuju.t/.nodebrew/node/v16.14.0/lib/node_modules/hygen/src/templates
       added: _templates/generator/help/index.ejs.t
       added: _templates/generator/with-prompt/hello.ejs.t
       added: _templates/generator/with-prompt/prompt.ejs.t
       added: _templates/generator/new/hello.ejs.t
       added: _templates/init/repo/new-repo.ejs.t

$ hygen generator new --name stories

	Loaded templates: _templates
	       added: _templates/stories/new/hello.ejs.t
```

そして、_templates/stories/new/配下にprompt.jsを配置します。

```js
const inputValidator = (input) => {
  if (input !== '') {
    return true
  }
}

module.exports = [
  {
    type: 'input',
    name: 'name',
    message: 'コンポーネントの名前を指定してください。ex) Header',
    validate: inputValidator,
  },
  {
    type: 'input',
    name: 'directory',
    message: 'storyを生成する対象のコンポーネントが配置されているディレクトリの、src以下のパスを指定してください。ex) components/atoms、pages/release/music_video',
    validate: inputValidator,
  },
  {
    type: "confirm",
    name: "have_slots",
    message: "Slotsを持ちますか？",
    choices: ["Yes", "No"],
    initial: "Yes",
  },
]

```

ここでは、ファイル自動生成コマンドの実行時に対話形式で入力する内容を定義します。

さらに、_templates/stories/new/配下にstories.mdx.ejs.tファイルを作成します。
これは、生成される.stories.mdxファイルの雛形となり、
prompt.jsのnameプロパティが、<%= %>で囲まれた値と対応しています。

~~~
---
to: ./stories/<%= directory %>/<%= name%>.stories.mdx
---

import { Meta, Story, ArgsTable, Canvas } from "@storybook/addon-docs";
import { action } from '@storybook/addon-actions';
import <%= name %> from '@/<%= directory %>/<%= name %>.vue';

<Meta
  title="<%= directory %>/<%= name %>"
  argTypes={{
    argName: {
      description: 'description',
      table: {
        category: 'props',
        defaultValue: { summary: "value" },
      },
    },
    <% if (have_slots) { -%>
    slotValue: {
      description: 'slotの内容。',
      table: {
        category: 'slots',
      },
    },
    <% } -%>
  }}
/>

export const Template = (args) => ({
  components: { <%= name %> },
  setup() {
    return {...args};
  },
  template: `
  <% if (!have_slots) { -%>
    <<%= name %>
      :propName="propName"
    />
  <% } -%>
  <% if (have_slots) { -%>
    <<%= name %>
      :propName="propName"
    >
      <template v-if="${"slotValue" in args}" v-slot>${args.slotValue}</template>
    </<%= name %>>
  <% } -%>
  `,
});

# <%= name %>
<br/>

## Component
<Canvas>
  <Story
    name="<%= name %>"
    args={{
      propName: "value",
      <% if (have_slots) { -%>
        slotValue: "slotValue",
      <% } -%>
    }}
    >
    {Template.bind({})}
  </Story>
</Canvas>

## Usage
```vue
somecode here
```

## Props

<ArgsTable story="<%= name %>" />

~~~

package.jsonのscriptsに以下を追加します。

```package.json
"hygen:stories": "hygen stories new"
```

hygenコマンドの第一引数（generator名）、第二引数（action名）は
stories.mdx.ejs.tやprompt.jsを配置した、_templates/stories/new/のディレクトリ名に対応しています。

コマンドを実行し、質問に答えると・・・
```
npm run hygen:stories

> client_vue@0.1.0 hygen:stories
> hygen stories new

✔ コンポーネントの名前を指定してください。ex) Header · HogeHoge
✔ storyを生成する対象のコンポーネントが配置されているディレクトリの、src以下のパスを指定してください。ex) components/atoms、pages/release/music_video · components/atoms

✔ Slotsは持ちますか？ (Y/n) · false

Loaded templates: _templates
       added: ./stories/components/atoms/HogeHoge.stories.mdx
```

生成されるのが以下のファイルです。

~~~HogeHoge.stories.mdx
import { Meta, Story, ArgsTable, Canvas } from "@storybook/addon-docs";
import { action } from '@storybook/addon-actions';
import HogeHoge from '@/components/atoms/HogeHoge.vue';

<Meta
  title="components/atoms/HogeHoge"
  argTypes={{
    argName: {
      description: 'description',
      table: {
        category: 'props',
        defaultValue: { summary: "value" },
      },
    },
        slotValue: {
      description: 'slotの内容。',
      table: {
        category: 'slots',
      },
    },
  }}
/>

export const Template = (args) => ({
  components: { HogeHoge },
  setup() {
    return {...args};
  },
  template: `
    <HogeHoge
      :propName="propName"
    >
      <template v-if="${"slotValue" in args}" v-slot>${args.slotValue}</template>
    </HogeHoge>
  `,
});

# HogeHoge
<br/>

## Component
<Canvas>
  <Story
    name="HogeHoge"
    args={{
      propName: "value",
      slotValue: "slotValue",
    }}
  >
    {Template.bind({})}
  </Story>
</Canvas>

## Usage
```vue
somecode here
```

## Props

<ArgsTable story="HogeHoge" />

~~~

これで、別のstoriesファイルからコピペする手間がなくなりました！



# 導入して良かった点

導入前は、コンポーネントをドキュメント化し、動きや状態をメンバー間で共有できることがメリットであると考えていました。
実際に導入すると、それに加えて以下の点が良かったこととして挙げられます。

- 各コンポーネントへの理解が深まる
- 責務が重複していたり、使用していないコンポーネントが見つかる


# まとめ

個人的に触ってみたStorybookを実際のプロジェクトに導入してみましたが、チュートリアル通りいかないことも多々あり苦労しました。
しかし、１からの導入をさせてくれるというのはありがたいですね！とてもいい経験になりました。