React・Vue・Angularなど使うとコンポーネント単位でUIプレビューしたくなるんですよねえ。
Storybookはそれを叶えてくれるツールで、規模や人員が大きくなればなるほど必要性が増してくると思いました。
StyleDoccoとか使ってた頃の手間や苦労を思い出すと、いやはや便利になったものです。
Nuxtで作ってるサイトにStorybook入れてみたら、ちょいちょいつまづくポイントがあったので、導入から問題解決についてStepByStepでメモりました。
利用したバージョン
Storybook 5.3.14
Nuxt 2.11.0
Storybookインストール
公式のVue用ガイドのManual Setupの手順で。
※@next=ベータ版
npm install @storybook/vue --save-dev #or npm install @storybook/vue@next --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系をインストールしてしまった場合は、
- storybookとcss-loaderをremove
- css-loaderをinstall
- storybook@5.2をinstall
で直る。
npm scriptの追加
nuxtプロジェクトのルートにあるpackage.json
にstorybook追加。
{ "scripts": { "storybook": "start-storybook" } }
これだとポートが起動する都度変わる。
指定する場合は-p
オプションを利用する:
{ "scripts": { "storybook": "start-storybook -p 9000" } }
staticディレクトリにあるファイルを利用する場合はオプションでディレクトリを指定する
{ "scripts": { "storybook": "start-storybook -p 9000 -s ./static" } }
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 }
これで起動する。
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', }, ], });
PostCSSの利用
SassとPostCSSを併用する場合はsass-loaderの前にpostcss-loaderを追加する。
config.module.rules.push({ test: /\.scss$/, //exclude: /common\.scss$/i, use: [ { loader: 'style-loader' }, { loader: 'css-loader', }, { loader: 'postcss-loader', options: { config: { path: './.storybook/' } } }, { loader: 'sass-loader', }, ], });
設定はoptionsに書いてもいいし、上記のようにpathを指定して
./storybook/postcss.config.js
を用意してもよい。
module.exports = { plugins: { 'autoprefixer': {}, } }
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, "../"), ];
やったぜ。
グローバルスタイルの利用
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.js
にinfo
追加:
storiesOf('Loading', module).add( 'default', () => ({ components: { Loading }, template: '<loading />' }), { info: { summary: '読み込み中に表示するやつ' } } );
※グローバル登録には未対応。
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.js
に props
追加:
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-knobsが返す値はHTMLがエスケープされるので、そのままにして欲しい場合はオプションでescapeHTML: false
に設定する。
{ info: { summary: '読み込み中に表示するやつ' }, knobs: { timestamps: true, // ユーザー入力中はイベント発行しない escapeHTML: false // HTMLのエスケープをしない } }
背景色の操作(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' }, { name: 'Pattern', value: 'url(./images/bg.png)' } ], });
backgroundプロパティに対する設定をvalueに書けばいいので、画像やグラデーションもおk。
これでプレビューパネルのアイコン並んでるところの右側にアドオンのボタンが追加される。
選択するとプレビュー画面の背景色が選んだ色に変わる。
bodyのスタイルと喧嘩する件
グローバルに読み込んだCSSファイル内でbodyに背景指定がある場合、addon-backgroundsの背景変更が反映されなくなる。
これはaddon-backgroundsがiframeの背景色を変更しているからなので、Storybookがつけるクラスを用いてbodyへの背景指定を避けるしかない。
body:not(.sb-show-main) { background-image: url('/images/bg.png'); }
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('nuxt-link')(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/.babelrc
にmacros
追加
"pulgins": ["macros"]
.storybook/config.js
のloadStories()
で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を利用する
mapStateを利用しているコンポーネントがあったとして、
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使うコンポーネントのサンプル' } } );
addon-actionsをVuexで使う
ストアを追加する場合、mutationsやactionsでaddon-actionsを実行することができる。
import { action } from '@storybook/addon-actions'; const store = new Vuex.Store({ actions: { // $store.dispatch('getData', props); async getData(ctx, props) { await action('getData')(props); } }, mutations: { // $store.commit('setItem', data); setItem(state, data) { action('setItem')(data); } } });
ストアをモックする
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.use
はconfig.jsでやっておいてもいい。
addon-knobsとVuexやVue Routerを連動させる
addon-knobsのメソッドが返す選択された値を利用する。
const store = new Vuex.Store({ state: { isShow: true } }); storiesOf('Vuex Store Sample', module).add( 'default', () => ({ template: '<div :is-show="isShow">Sample</div>', store, props: { isShow: { default: () => { store.state.isShow = boolean('isShow', true); } } } }), { info: { summary: 'Vuex操作するサンプル' } } );
gettersは上書きできないのでstateに返したい値を設定しておく
const store = new Vuex.Store({ state: { isShow: true, isGetterReturnValue: false }, getters: { isGetter: state => state.isGetterReturnValue } });
ルーターはpushで移動してるっぽくできる
const router = new VueRouter({ routes: [ { path: '/index', name: 'index' }, { path: '/mypage', name: 'mypage' }, { path: '/category', name: 'category' }, { path: '/search', name: 'search' } ] }); storiesOf('Vuex Store Sample', module).add( 'default', () => ({ template: '<div>{{ $route.name }}</div>', router, props: { page: { default: () => { const page = select( 'page', { 'ホーム': 'index', 'マイページ': 'mypage', 'カテゴリー': 'category', '検索': 'search' }, 'index' ); router.push(page); } }, } }), { info: { summary: 'VueRouter操作するサンプル' } } );
移動前や移動後の情報をaddon-actionで表示したい場合は、
ナビゲーションガードにactionsを仕込む。
router.afterEach((to, from) => { action('router')(to, from); });
Vueプラグインを利用する
Vue.useなどでインストールできるものはconfigファイルで設定する。
import { CollapsePlugin, BButton } from 'bootstrap-vue'; Vue.use(CollapsePlugin); Vue.component('b-button', BButton); Vue.directive('b-tooltip', () => {});