Nuxt.jsでStorybookを使用してみたメモ

React・Vue・Angularなど使うとコンポーネント単位でUIプレビューしたくなるんですよねえ。
Storybookはそれを叶えてくれるツールで、規模や人員が大きくなればなるほど必要性が増してくると思いました。
StyleDoccoとか使ってた頃の手間や苦労を思い出すと、いやはや便利になったものです。

Nuxtで作ってるサイトにStorybook入れてみたら、ちょいちょいつまづくポイントがあったので、導入から問題解決についてStepByStepでメモりました。

利用したバージョン

Storybook 5.2.0-beta
Nuxt 2.9.2

Storybookインストール

公式のVue用ガイドのManual Setupの手順で。

※@next=ベータ版

npm install @storybook/vue@next --save-dev
#or
npm install @storybook/vue@5.2.0-beta.xx --save-dev

Step2: Add peer dependencies のやつは無かったら追加する。

ver5.1.xでcss-loaderのバージョン不一致問題が発生する件

Nuxt側のcss-loaderのバージョンが3系、Storybook 5.1系側のcss-loaderがバージョン2系で、Nuxtのdevサーバー立ち上げる時にビルドエラーが発生する。

Module build failed (from ./node_modules/css-loader/dist/cjs.js):      friendly-errors 13:32:53
ValidationError: CSS Loader Invalid Options

options should NOT have additional properties

これを避けるためにStorybook5.2系をインストールすれば良いが、もしうっかり5.1系をインストールしてしまった場合は、

  1. storybookとcss-loaderをremove
  2. css-loaderをinstall
  3. storybook@5.2をinstall

で直る。

npm scriptの追加

nuxtプロジェクトのルートにあるpackage.jsonにstorybook追加。

{
  "scripts": {
    "storybook": "start-storybook"
  }
}

これだとポートが起動する都度変わる。
指定する場合は-pオプションを利用する:

{
  "scripts": {
    "storybook": "start-storybook -p 9000"
  }
}

npm run storybook
または
yarn run storybook
で起動

Storybook設定ファイルの作成

.storybook/config.jsを作成。

import { configure } from '@storybook/vue';

function loadStories() {
  require('../stories/index.js');
}

configure(loadStories, module);

いちいちrequire書くの面倒だからstoriesディレクトリ内のjs全部読む:

function loadStories() {
  const req = require.context('../stories', true, /\.js$/);
  req.keys().forEach(filename => req(filename));
}

components以下にストーリーファイルを作成する場合:

function loadStories() {
  const req = require.context('../components', true, /\.stories\.js$/);
  req.keys().forEach(filename => req(filename));
}

Storybook用ストーリーファイルの作成

stories/index.jsを作成。
components以下に作成する場合は、Loading.stories.jsとかLoading/index.stories.jsとして、loadStories()の内容と辻褄が合うようにする。

import { storiesOf } from '@storybook/vue';
import Loading from '@/components/Loading.vue';

storiesOf('Loading', module).add('default', () => ({
  components: { Loading },
  template: '<loading />'
}));

componentのローカル登録を省略する場合は、Vue.componentでグローバル登録する。

import Vue from 'vue';
import { storiesOf } from '@storybook/vue';
import Loading from '@/components/Loading.vue';

Vue.component('loading', Loading);

storiesOf('Loading', module)
  .add('default', () => '<loading />')

この時点のLoading.vueは以下。

<template>
  <div class="loading">
    Loading...
  </div>
</template>

<script>
export default {
};
</script>

<style scoped>
.loading {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 3rem;
}
</style>

これで起動するとエラーで死ぬ:

ERROR in ./stories/loading.js
Module not found: Error: Can't resolve '@/components/loading.vue' in '/Users/username/foobar/stories'
 @ ./stories/loading.js 3:0-47 4:25-32
 @ ./stories sync \.js$
 @ ./.storybook/config.js
 @ multi ./node_modules/@storybook/core/dist/server/common/polyfills.js ./node_modules/@storybook/core/dist/server/preview/globals.js ./.storybook/config.js (webpack)-hot-middleware/client.js?reload=true

Storybook用webpack.config.jsの作成

@/comonentsとか言われても知らんがな、という状況なのでStorybookのWebpackに教えてあげます。

.storybook/webpack.config.js

const path = require('path');

module.exports = ({ config }) => {
  config.resolve.alias['@'] = path.resolve(__dirname, '../')
  return config
}

これで起動する。

初期設定完了時のStorybook画面

Nuxt/Vue ルールへの対応

StyleブロックのLang対応(Sassとかの利用)

Sassを使いたくてstyle lang="scss" scopedにして起動する。と、エラーで死ぬ。

ERROR in ./components/loading.vue?vue&type=style&index=0&id=f8abd85e&lang=scss&scoped=true& (./node_modules/vue-loader/lib??vue-loader-options!./components/loading.vue?vue&type=style&index=0&id=f8abd85e&lang=scss&scoped=true&) 24:0
Module parse failed: Unexpected token (24:0)
File was processed with these loaders:
 * ./node_modules/vue-loader/lib/index.js
You may need an additional loader to handle the result of these loaders.
|
|
> .loading {
|   position: relative;
|   display: flex;
 @ ./components/loading.vue?vue&type=style&index=0&id=f8abd85e&lang=scss&scoped=true& 1:0-152 1:168-171 1:173-322 1:173-322
 @ ./components/loading.vue
 @ ./stories/loading.js
 @ ./stories sync \.js$
 @ ./.storybook/config.js
 @ multi ./node_modules/@storybook/core/dist/server/common/polyfills.js ./node_modules/@storybook/core/dist/server/preview/globals.js ./.storybook/config.js (webpack)-hot-middleware/client.js?reload=true

You may need an additional loader to handle the result of these loaders.

ということなので、
.storybook/webpack.config.jsに追加

config.module.rules.push({
  test: /\.scss$/,
    use: [
      {
        loader: 'style-loader'
      },
      {
        loader: 'css-loader'
      },
      {
        loader: 'sass-loader',
      },
    ],
});

CSS Modulesの利用

Nuxtだと素でONになっているので、styleタグにmoduleってつければ利用できるが…

<template functional>
  <div :class="[$style.red]">CSS Modules!</div>
</template>
<script>
export default {
}
</script>
<style lang="scss" module>
$color: red;
.red {
  color: $color;
}
</style>

css-loaderの設定はデフォルトでオフなので、オプションで有効化が必要。

  config.module.rules.push({
    test: /\.scss$/,
      use: [
        {
          loader: 'style-loader'
        },
        {
          loader: 'css-loader',
          options: {
            modules: {
              mode: 'local',
              localIdentName: '[local]_[hash:base64:5]',
            },
          }
        },
        {
          loader: 'sass-loader',
        },
      ],
  });

CSS内アセットの読み込み

ローディングなのでぐるぐる回ってるアニメーションのSVG画像使いたくなりました。

// Loading.vue
.loading {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  background: url('~assets/images/loading.svg') no-repeat center center;
  min-height: (64px + 32px);
}

はい死んだー

ERROR in ./components/loading.vue?vue&type=style&index=0&id=f8abd85e&lang=scss&scoped=true& (./node_modules/css-loader/dist/cjs.js!./node_modules/vue-loader/lib/loaders/stylePostLoader.js!./node_modules/sass-loader/dist/cjs.js!./node_modules/vue-loader/lib??vue-loader-options!./components/loading.vue?vue&type=style&index=0&id=f8abd85e&lang=scss&scoped=true&)
Module not found: Error: Can't resolve 'assets/images/loading.svg' in '/Users/a12333/git/monaco-ssr/components'
 @ ./components/loading.vue?vue&type=style&index=0&id=f8abd85e&lang=scss&scoped=true& (./node_modules/css-loader/dist/cjs.js!./node_modules/vue-loader/lib/loaders/stylePostLoader.js!./node_modules/sass-loader/dist/cjs.js!./node_modules/vue-loader/lib??vue-loader-options!./components/loading.vue?vue&type=style&index=0&id=f8abd85e&lang=scss&scoped=true&) 4:41-79
 @ ./node_modules/style-loader!./node_modules/css-loader/dist/cjs.js!./node_modules/vue-loader/lib/loaders/stylePostLoader.js!./node_modules/sass-loader/dist/cjs.js!./node_modules/vue-loader/lib??vue-loader-options!./components/loading.vue?vue&type=style&index=0&id=f8abd85e&lang=scss&scoped=true&
 @ ./components/loading.vue?vue&type=style&index=0&id=f8abd85e&lang=scss&scoped=true&
 @ ./components/loading.vue
 @ ./stories/loading.js
 @ ./stories sync \.js$
 @ ./.storybook/config.js
 @ multi ./node_modules/@storybook/core/dist/server/common/polyfills.js ./node_modules/@storybook/core/dist/server/preview/globals.js ./.storybook/config.js (webpack)-hot-middleware/client.js?reload=true

background: url(~assets/...) みたいなアセットの読み込みでコケないようにする。

.storybook/webpack.config.jsに追加

config.resolve.modules = [
  ...(config.resolve.modules || []),
  path.resolve(__dirname, "../"),
];

やったぜ。

アセットに対応したStorybook画面

グローバルスタイルの利用

Nuxtのassetsにある共通スタイルをStorybookで利用する。
仮にassets/scss/common.scssとする。

ここまでの設定がされていれば
.storybook/config.js に追加:

import "../assets/scss/common.scss";

で適用される。

Webpackのcss-loader設定を分ける

CSS ModulesをONにしている場合、この方法で読み込んだSCSSは全てモジュール化されてしまうので、webpack.config.jsで設定を分けておく。

  // without CSS modules
  config.module.rules.push({
    test: /common\.scss/i,
    use: [
      {
        loader: 'style-loader'
      },
      {
        loader: 'css-loader',
      },
      {
        loader: 'sass-loader',
      },
    ],
  })

  config.module.rules.push({
    test: /\.scss$/,
    exclude: /common\.scss$/i,
    use: [
      {
        loader: 'style-loader'
      },
      {
        loader: 'css-loader',
        options: {
          modules: {
            mode: 'local',
            localIdentName: '[local]_[hash:base64:5]',
          },
        }
      },
      {
        loader: 'sass-loader',
      },
    ],
  });

Nuxt.jsのプラグインをモックする

injectしているプラグインも勿論エラーになるので黙らせる設定が必要。

import Vue from 'vue'

Vue.prototype.$_plugin = () => {};

// actionを実行させてもよい

Vue.prototype.$_plugin = action('plugin');

Storybookのアドオンを利用する

何に対応しているのかというのはアドオン毎に違うので、アドオン名にライブラリ名が含まれてない場合は対応状況の確認が必要。
主要なアドオンには対応状況表が用意されている。

※@storybook/vueでバージョン指定した場合、アドオンインストールでも同じバージョンを指定すること。

コンポーネント情報を表示する(storybook-addon-vue-info)

下枠がNothing foundで寂しいのでアドオンstorybook-addon-vue-infoを入れる。

モジュールをインストール:

yarn add storybook-addon-vue-info --dev

.storybook/addons.jsを作成(追加):

import 'storybook-addon-vue-info/lib/register'

.storybook/config.js に追加:

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

addDecorator(withInfo)

stories/loading.jsinfo追加:

storiesOf('Loading', module).add(
  'default',
  () => ({
    components: { Loading },
    template: '<loading />'
  }),
  {
    info: {
      summary: '読み込み中に表示するやつ'
    }
  }
);

※グローバル登録には未対応。

storybook-addon-vue-info 追加後の画面

Decorator利用時の問題

Decoratorをローカル登録すると、表示されるソースがデコレーターのものになる不具合がある。
デコレーターがグローバル登録の場合は発生しない。

addon-knobs利用時の問題

@nextでaddon-knobsの挙動が狂う(プロパティの値が2回目から反映されなくなる)問題が出た。
storybook-addon-vue-infoをオフにしたらknobsは正しく動作する。
どこに原因があるか不明。

プロパティのライブプレビュー(addon-knobs)

Loading.vueにカラバリが増えました。
プロパティはcolorで、data属性に値を入れて操作する。
デフォルトの黄色を1、増えた白色を2としてスタイルを設定。

components/Loading.vue

<template>
  <div class="loading" :data-color="color" />
</template>

<script>
export default {
  props: {
    color: {
      type: String,
      default: '1'
    }
  }
};
</script>

<style lang="scss" scoped>
.loading {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: (64px + 32px);

  &[data-color='1'] {
    background: url('~assets/images/loading.svg') no-repeat center;
  }

  &[data-color='2'] {
    background: url('~assets/images/loading-w.svg') no-repeat center;
  }
}
</style>

プロパティで設定している色の数だけストーリーをaddしてもいいけど、数が多いと面倒だし、どうせならライブプレビューしたいので、addon-knobsを入れる。

モジュールをインストール:

yarn add @storybook/addon-knobs --dev

.storybook/addons.jsに追加:

import '@storybook/addon-knobs/register';

.storybook/config.js に追加:

import { withKnobs } from '@storybook/addon-knobs';
addDecorator(withKnobs);

stories/loading.jsprops 追加:

import { select } from '@storybook/addon-knobs';

storiesOf('Loading', module).add(
  'default',
  () => ({
    components: { Loading },
    template: '<loading :color="color" />',
    props: {
      color: {
        default: select('color', { default: '1', white: '2' }, '1')
      }
    },
    propsDescription: {
      Loading: {
        color: '色を変更する'
      }
    }
  }),
  {
    info: {
      summary: '読み込み中に表示するやつ'
    }
  }
);

Infoの隣にKnobsタブが増えて、設定したプロパティの操作が可能に。

addon-knobsパネル画面

背景色の操作(addon-backgrounds)

白背景に白いアイコンじゃ見えんので、addon-backgroundsでプレビューエリアの背景色を操作できるようにする。

モジュールをインストール:

yarn add @storybook/addon-backgrounds --dev

.storybook/addons.jsに追加:

import '@storybook/addon-backgrounds/register';

.storybook/config.js に追加:

import { configure, addDecorator, addParameters } from '@storybook/vue';

addParameters({
  backgrounds: [
    { name: 'Default', value: '#fff', default: true },
    { name: 'Dark', value: '#000' },
  ],
});

プレビューパネルのアイコン並んでるところの右側にアドオンのボタンが追加される。

addon-backgroundsの背景色選択画面

選択するとプレビュー画面の背景色が選んだ色に変わる。

addon-backgroundsによる背景色選択後画面

nuxt-link対応とイベントログ表示(addon-actions)

リンクをクリックしたはずなのに何も起きない?🤔を解決する。

モジュールをインストール:

yarn add @storybook/addon-actions --dev

.storybook/addons.jsに追加:

import '@storybook/addon-actions/register';

.storybook/config.js に追加:

import { action } from '@storybook/addon-actions';

Vue.component('nuxt-link', {
  props:   ['to'],
  methods: {
    log() {
      action()(this.to)
    },
  },
  template: `<a href="#" @click.prevent="log()"><slot>NuxtLink</slot></a>`,
})

methodsにクリック時のイベントハンドラを設定、
イベントハンドラ内でアドオンを叩いて内容を表示する。

Actionタブが追加されて、リンクをクリックした時に渡されたprops(to)の内容が表示される。

Jestのスナップショット生成(StoryShots)

JestのSnapShotsテストをVueで書くとこういうソースになるけど、

it('should snapshot match', () => {
  const wrapper = mount(MyComponent, {
    localVue,
    propsData: {
      foo: [
        {
          name: 'Bar'
        }
      ]
    }
  });
  expect(wrapper.element).toMatchSnapshot();
});

よく見るとStorybookで書いてるソースとよく似てますね?
ならStorybookのstoriesファイルからスナップショット作ってテストしたらよくない??というのを実現してくれるのがStoryShotsアドオン。
ここに書いてるのはcoreの方で、Jestがすでに動いてる状態を前提とする。

StoryShotsをインストールする。

yarn add @storybook/addon-storyshots --dev

./storybook/Storyshots.test.jsを作成。

import initStoryshots from '@storybook/addon-storyshots';
initStoryshots();

で、jestでテスト動かしたらStorryhosts.testがテストに含まれてスナプッショット作成とスナップショットテストが実行されるけど、
このままだと色々エラーが出て死ぬ。

require.contextのエラー

避けるための方法はいくつかあるけど、Babelのマクロで置き換えるのが楽だった。

マクロインストールする。

yarn add babel-plugin-macros require-context.macro --dev

./storybook/.babelrcmacros追加

"pulgins": ["macros"]

.storybook/config.jsloadStories()require.context使ってるとこをマクロに置き換える。

import requireContext from 'require-context.macro';
// ....
function loadStories() {
  // require.context -> requireContext
  const req = requireContext('../components', true, /\.stories\.js$/);
// ...

storybook-addon-vue-infoのエラー

babelのトランスパイルされてないせいでエラーになっている。
そもそもJestが動かしてる時はinfo表示する必要ないので、addDecoratorで登録するのを普通にStorybook起動したときに条件分けすればいい。

.storybook/config.js

if (typeof jest === "undefined") {
  const { withInfo } = require('storybook-addon-vue-info');
  addDecorator(withInfo);
}

Props with type Object/Array must use a factory function to return the default value.

これはVueのプロパティの初期値が配列とオブジェクトなら関数にしないとアカンというお馴染みのエラー。
addon-knobs使ってる場合はdefaultで設定してるはずなので、値がObjectやArrayになるなら関数型にしておく。

default: () => object('cats', [{ name: 'タマ', color: '三毛' }])

Vuexを利用する

stateを利用しているコンポーネントがあったとして、

store/index.js

export const state = () => {
  return {
    sampleState: 'Hello Store!'
  }
}

components/StoreSample.js

<template>
  <div class="sample">
    {{ sampleState }}
  </div>
</template>

<script>
import { mapState } from 'vuex';
export default {
  computed: {
    ...mapState(['sampleState'])
  }
};
</script>

<style lang="scss" scoped>
.sample {
  color: red;
}
</style>

そのコンポーネントのストーリーを追加すると、

stories/store-sample.js

import { storiesOf } from '@storybook/vue';
import StoreSample from '@/components/store-sample.vue';

storiesOf('Vuex Store Sample', module).add(
  'default',
  () => ({
    components: { StoreSample },
    template: '<store-sample />'
  }),
  {
    info: {
      summary: 'Vuex使うコンポーネントのサンプル'
    }
  }
);

TypeError: Cannot read property ‘state’ of undefined

というエラーが出て死ぬ。

シンプルなStoreの追加

storiesOfでaddしているオブジェクトはVueインスタンス作成時に渡すオプションそのものなので、
Vuexのガイド通りにストアを作れば動作する。

stories/store-sample.js

import Vue from 'vue';
import Vuex from 'vuex';
import { state } from '@/store/index';
import { storiesOf } from '@storybook/vue';
import StoreSample from '@/components/store-sample.vue';

Vue.use(Vuex);

const store = new Vuex.Store({
  state
});

storiesOf('Vuex Store Sample', module).add(
  'default',
  () => ({
    components: { StoreSample },
    template: '<store-sample />',
    store
  }),
  {
    info: {
      summary: 'Vuex使うコンポーネントのサンプル'
    }
  }
);

この方法だとVueインスタンスがVuex持ってる状態だから、
値のセットはdata、mounted、createdを使ってできる。

storiesOf('Vuex Store Sample', module).add(
  'default',
  () => ({
    components: { StoreSample },
    template: '<store-sample />',
    store,
    mounted() {
      store.commit('DATA_SET', [1, 2, 3, 4]);
    }
  }),
  {
    info: {
      summary: 'Vuex使うコンポーネントのサンプル'
    }
  }
);

ストアをモックする

extendsで継承してストア使ってるメソッドを上書きする方法。

import StoreSampleBase from '@/components/store-sample.vue';

storiesOf('Vuex Store Sample', module).add(
  'default',
  () => ({
    //components: { StoreSample },
    components: {
      StoreSample: {
        extends: StoreSampleBase,
        computed: {
          sampleState() {
            return 'Hello Mock!';
          }
        }
      }
    },
    template: '<store-sample />'
  }),
  {
    info: {
      summary: 'Vuex使うコンポーネントのサンプル'
    }
  }
);

ストア作るほどでもないなという場合にはこっちの方がお手軽かも。

VueRouterを利用する

$routeを参照しているコンポーネントがあったとして、

<template>
  <div class="sample">
    <p v-if="isHome">Welcome!!</p>
  </div>
</template>

<script>
export default {
  isHome() {
    return this.$route.name === 'index';
  }
};
</script>

<style lang="scss" scoped>
.sample {
  color: red;
}
</style>

そのコンポーネントのストーリーを追加すると、

stories/router-sample.js

import { storiesOf } from '@storybook/vue';
import StoreSample from '@/components/router-sample.vue';

storiesOf('VueRouter Sample', module).add(
  'default',
  () => ({
    components: { RouterSample },
    template: '<router-sample />'
  }),
  {
    info: {
      summary: 'VueRouter参照してるコンポーネントのサンプル'
    }
  }
);

Cannot read property ‘name’ of undefined

とかいうエラーが出て死ぬ。

この場合もストアの時と同じくVueRouter追加するかモックするかの二択になる

import Vue from 'vue'
import VueRouter from 'vue-router';
import { storiesOf } from '@storybook/vue';
import StoreSample from '@/components/router-sample.vue';

Vue.use(VueRouter);

const router = new VueRouter({
  routes: [{ path: '/', name: 'index' }]
});

storiesOf('VueRouter Sample', module).add(
  'default',
  () => ({
    router,
    components: { RouterSample },
    template: '<router-sample />'
  }),
  {
    info: {
      summary: 'VueRouter参照してるコンポーネントのサンプル'
    }
  }
);

Vue.userはconfig.jsでやっておいてもいい。

参考リンク

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください