最近フロントエンドのチーム開発環境の整備の一環として、Storybookの導入を行いました。

Storybook 5.3の公開により、設定方法が変更されましたので、追記・修正いたしました。
参考: https://medium.com/storybookjs/declarative-storybook-configuration-49912f77b78

導入背景

背景としては、現在弊社チームとしてはデザイナー、フロントエンドエンジニアの他に、インターン的にマークアップを手伝ってくれている人がいます。

デザイナー、エンジニア間では過去に何度もAtomic Designのコンポーネントの粒度であったり、Vue(Nuxt)を使う上でのHTML, CSS(SCSS)コーディング規約などを話し合っていて、「この議論N回目、3週間ぶり、、、」みたいなのがあります。

それにより色々と双方の認識が取れてきている部分もあるのですが、さてマークアップをインターン含めてやるかとなった時に、

  • VueファイルのComponent粒度をどう伝えるか
  • 実際のデータがない状態でも出来るだけHTMLの中身をベタがきせず、Propsで受け渡しながらComponentに切り出した開発ができるか
  • デザイナーへのマークアップしたもののレビュー
    などという問題にぶち当たります。

VueファイルのComponent粒度をどう伝えるか

こちらの問題は、デザインファイルの時点でパーツ、ページごとにコンポーネント名をつけてしまえば、解決という部分もあるのですが、そこまで用意できないプロジェクトもあったりという(言い訳)部分があり、ある程度マークアップする人に委ねられがちというところもあります。

そこでStorybookを作ることで、他のプロジェクトを参考にしながらマークアップすることで、温度感を合わせていくということができました。

この辺の開発フローは改善の余地ありということで、チームでも議論を重ねているポイントです。

実際のデータがない状態でも出来るだけHTMLの中身をベタがきせず、Propsで受け渡しながらComponentに切り出した開発ができるか

マークアップする際にNuxtを立ち上げず、StorybookのみでVueファイルを確認しながら開発が可能というのは、APIへのアクセス部分などを気にせずマークアップが可能なので、JSがあまり分からない人でもマークアップが可能というメリットがありました。
あと、動的に変わる部分もPropsで受け渡せるように書いてもらうことで、後々書き換える手間が減ったりというメリットも感じています。
Propsの型はある程度適当でも後から変えるくらいの工数は見逃せるかなという印象です。

デザイナーへのマークアップしたもののレビュー

Pull Requestを送った際に、CIでstorybookをbuildしているので、プルリクを確認する際のデザインが想定通りかという部分をわざわざローカルでビルドして確認する手間が省けます。
HTML, CSSのコーディング内容はコードを見ただけではわかりづらいという部分もあるので、パッと画面で見れるのは良いなと思っています。
個人的には、ここは一番大きい気がしているのですが、デザイナーにはまだ100%納得をしてもらえていません笑

Nuxtへの導入

1. packageの追加

$ yarn add -D @storybook/vue \
  @storybook/addons \
  @storybook/addon-viewport \
  @storybook/addon-notes \
  @storybook/addon-links \
  @storybook/addon-knobs \
  @storybook/addon-actions \
  storybook-addon-vue-info

2. .storybookディレクトリの作成とファイルの設置

次にstorybookの設定ファイルを設置します。
.storybookを作成し、treeで表示しているように、下記のようにファイルを設置します。


@storybook/vue 5.3.0以降の場合

$ tree .storybook

.storybook
├── main.js
├── preview.js
└── webpack.config.js

main.js

main.jsにはimportするpackageの記載します。

module.exports = {
  stories: ['../src/components/*.stories.js'],
  addons: [
    '@storybook/addon-actions',
    '@storybook/addon-links',
    '@storybook/addon-viewport',
    '@storybook/addon-notes',
    '@storybook/addon-knobs',
    'storybook-addon-vue-info/lib/register',
  ],
};

preview.js

preview.jsにはstorybookのファイル読み込み設定を記載します。
KnobsとInfoの設定もここで行なっています。

import { configure, addDecorator } from '@storybook/vue'
import { withKnobs } from '@storybook/addon-knobs/vue'
import { withInfo } from 'storybook-addon-vue-info'

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

// automatically import all files ending in *.stories.js
const req = require.context('../src/components', true, /.stories.js$/)
function loadStories() {
  req.keys().forEach(filename => req(filename))
}

configure(loadStories, module)
addDecorator(withKnobs)
addDecorator(withInfo)

webpack.config.js

storybook用のwebpackの設定を記載します。
正直ここは記載内容に少し自信ないところ。
tsを使っている場合は、ここでts-loaderの設定も行います。

const path = require('path')
const rootPath = path.resolve(__dirname, '../src/')

module.exports = async ({ config, mode }) => {
  mode = "development"

  config.module.rules.push({
    test: /\.(otf|eot|svg|ttf|woff|woff2)(\?.+)?$/,
    loader: 'url-loader',
  });

  config.module.rules.push({
    test: /\.css/,
    use: [
      'style-loader',
      { loader: 'css-loader', options: { url: false } },
    ],
  });

  config.module.rules.push({
    test: /\.ts/,
    use: [
      {
        loader: 'ts-loader',
        options: {
          appendTsSuffixTo: [/\.vue$/],
          transpileOnly: true
        },
      }
    ],
  });

  config.module.rules.push({
    test: /\.vue$/,
    loader: 'storybook-addon-vue-info/loader',
    enforce: 'post'
  });

  config.module.rules.push({
    test: /\.scss$/,
    use: [
      'style-loader',
      'css-loader',
      {
        loader: 'sass-loader',
      },
      {
        loader: 'sass-resources-loader',
        options: {
          resources: [
            path.resolve(__dirname, './../src/assets/scss/_variables.scss'),
            path.resolve(__dirname, './../src/assets/scss/common.scss'),
          ],
        }
      }
    ]
  });

  config.resolve.extensions = ['.js', '.vue', '.json']
  config.resolve.alias['~'] = rootPath
  config.resolve.alias['@'] = rootPath

  return config;
}

@storybook/vue 5.3.0未満の場合

$ tree .storybook

.storybook
├── addons.js
├── config.js
└── webpack.config.js

addons.js

addons.jsにはimportするpackageの記載します。

import '@storybook/addon-actions/register'
import '@storybook/addon-links/register'
import '@storybook/addon-notes/register'
import '@storybook/addon-viewport/register'
import '@storybook/addon-knobs/register'
import 'storybook-addon-vue-info/lib/register'

config.js

config.jsにはstorybookのファイル読み込み設定を記載します。
KnobsとInfoの設定もここで行なっています。

import { configure, addDecorator } from '@storybook/vue'
import { withKnobs } from '@storybook/addon-knobs/vue'
import { withInfo } from 'storybook-addon-vue-info'

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

// Import all files ending in *.stories.js
const req = require.context('../app/components', true, /.stories.js$/)
function loadStories() {
  req.keys().forEach(filename => req(filename))
}

configure(loadStories, module)
addDecorator(withKnobs)
addDecorator(withInfo)

webpack.config.js

storybook用のwebpackの設定を記載します。
正直ここは記載内容に少し自信ないところ。
tsを使っている場合は、ここでts-loaderの設定も行います。

const path = require('path')
const rootPath = path.resolve(__dirname, '../app/')

module.exports = async ({ config, mode }) => {
  mode = "development"

  config.module.rules.push({
    test: /\.(otf|eot|svg|ttf|woff|woff2)(\?.+)?$/,
    loader: 'url-loader',
  });

  config.module.rules.push({
    test: /\.css/,
    use: [
      'style-loader',
      { loader: 'css-loader', options: { url: false } },
    ],
  });

  config.module.rules.push({
    test: /\.ts/,
    use: [
      {
        loader: 'ts-loader',
        options: {
          appendTsSuffixTo: [/\.vue$/],
          transpileOnly: true
        },
      }
    ],
  });

  config.module.rules.push({
    test: /\.vue$/,
    loader: 'storybook-addon-vue-info/loader',
    enforce: 'post'
  });

  config.module.rules.push({
    test: /\.scss$/,
    use: [
      'style-loader',
      'css-loader',
      {
        loader: 'sass-loader',
      },
      {
        loader: 'sass-resources-loader',
        options: {
          resources: [
            path.resolve(__dirname, './../app/assets/scss/_variables.scss'),
            path.resolve(__dirname, './../app/assets/scss/common.scss'),
          ],
        }
      }
    ]
  });

  config.resolve.extensions = ['.js', '.vue', '.json']
  config.resolve.alias['~'] = rootPath
  config.resolve.alias['@'] = rootPath

  return config;
}

3. vueファイル、story.jsファイルの作成

次はやっと、vueファイル、storybook用のファイルを作成します。
弊社では、componentのフォルダに、.vue, story.js, .spec.jsを入れてます。
同じフォルダに入れることで、放置される危険性?を少しでも減らせればという思いです。

├── atoms
│   ├── buttons
│   │   ├── CommonButton.spec.js
│   │   ├── CommonButton.story.js
│   │   └── CommonButton.vue
│   ├── forms
│   │   ├── InputForm.story.js
│   │   ├── InputForm.vue
~
~
├── index.stories.js

index.stories.js

ここでimportするstorybookのファイルを記載します。
Componentの数が多くなればatom.stories.jsとかって分けたら良いのだろうか?

/** atoms */
import '@/components/atoms/forms/InputForm.story'

今回は例として、InputFormの部分を見てみます。

InputForm.vue

<template>
  <input
    v-model="$attrs.value"
    class="input"
    :type="type"
    @input="$emit('input', $event.target.value)"
  >
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  name: 'AtomsFormsInputForm',
  props: {
    type: {
      type: String,
      default: 'text',
    },
  },
})
</script>

<style lang="scss" scoped>
  .input {
    width: 100%;
    height: 50px;
    background-color: #eceff1;
    border: solid 1px #cfd8dc;
    border-radius: 5px;
    -webkit-appearance: none;
    -moz-appearance: none;
    appearance: none;
  }
</style>

InputForm.story.js

addon-knobsの使い方が分からず、dataに書いたりして微妙につまりましたが、propsに記載してやればいいようです。

実際のファイルは下記です。infoとかを表示できるのは嬉しいですね!

import { storiesOf } from '@storybook/vue'
import { text } from '@storybook/addon-knobs/vue'
import InputForm from './InputForm.vue'

storiesOf('Atoms/forms', module)
  .add(
    'InputForm',
    () => ({
      components: { InputForm },
      template: `<InputForm
        :type="type"
      />`,
      props: {
        type: {
          type: String,
          default: text('type', 'password'),
        },
      },
      description: {
        InputForm: {
          props: {
            type: 'typeは text, password, date などは利用可能. 詳細はNotesに記載.',
          },
          events: {
            input: '入力内容を親コンポーネントに受け渡す',
          },
        },
      },
    }),
    {
      info: true,
      notes: `
        # Input Form

        ## Props
        * type
          * string
            * typeは text, password, date などは利用可能
            * file, image は別コンポーネントで管理する
      `,
    })

4. package.jsonへの記載とビルド

package.jsonのscriptsに下記のように追記し、$yarn storybookで実行するとstorybookが起動します。あとは、localhost:6006(ポートは自分で設定した番号)の画面をみながら開発を進めればOKです!

  "scripts": {
    "storybook": "start-storybook -p 6006",
    "build-storybook": "build-storybook",
  },

実際にビルドされた画面はこんな感じで動かせます。

まだまだガッツリ活用できている段階ではないですが、模索しながらフロントエンド開発の効率を高めていければと思います!

参考