Sign Up
Hero

VueJS で Storybook を使う

モジュラーと再利用可能なコンポーネントを作るために Vue と Storybook でコンポーネント ライブラリを構築する方法を学びましょう。

Storybook はインタラクティブに開発し、ユーザー インターフェイス コンポーネントをアプリケーションを実行しないでテストできます。Storybook は独自の Webpack 構成でコンポーネント ライブラリとしての役目を果たすので、プロジェクト依存関係や要件を気にせずに個別に開発できます。

本投稿では、チームメートの Steve Hobbs 氏が作成した人気のカンバンボード プログレッシブ Web アプリケーション(PWA)(GitHub で入手可能)を使って、Storybook を既存の Vue.js プロジェクトに統合する方法を学んでいきます。このプロセスは新しい Vue プロジェクトにも使えます。

カンバンボードによるプロジェクトを実行する

以下のコマンドを実行して、カンバンボードによるプロジェクトを起動してローカルで実行します。

git clone git@github.com:elkdanger/kanban-board-pwa.git
cd kanban-board-pwa/
npm install
npm run dev

アプリケーションが起動しているか確認するには、ブラウザーで http://localhost:8080 を開きます。

Storybook を使うためにアプリを実行する必要はありません。ご希望であれば、それを停止してブラウザー タブを閉じます。

最後に、kanban-board-pwa プロジェクトをご希望の IDE またはコード エディタで開きます。

Storybook を Vue で設定する

現行の作業ディレクトリとして kanban-board-pwa を次のコマンドで実行し、npm を使って Storybook をインストールします。

npm i --save-dev @storybook/vue

Storybook は vuebabel-core がインストールされている必要があります。kanban-board-pwa は Vue CLI を使って作成されたので、これら2つの依存関係はすでにインストールされています。

最後に、Storybook を簡単に始めて実行できる npm スクリプトを作ります。package.json ファイルの scripts セクションの下に、次を追加します。

{
  // ...
  "scripts": {
    // ...
    "storybook": "start-storybook -p 9001 -c .storybook"
  }
  // ...
}

-p コマンド引数は Storybook がローカルで実行するポート、この場合は 9001 を指定します。-c コマンド引数は Storybook に構成設定の .storybook ディレクトリを探すように伝えます。これは次で実行します。

Storybook を Vue で構成する

Storybook は多くの異なる方法で構成されます。ベストプラクティスとして、その構造は .storybook と呼ばれるディレクトリに保存します。ルート フォルダーの下にそのディレクトリを作ります。

.
├── .babelrc
├── .dockerignore
├── .editorconfig
├── .eslintignore
├── .eslintrc.js
├── .git
├── .gitignore
├── .idea
├── .postcssrc.js
├── .storybook // Storybook config directory
├── Dockerfile
├── LICENSE
├── README.md
├── build
├── config
├── index.html
├── node_modules
├── package-lock.json
├── package.json
├── screenshots
├── src
└── static

.storybook 内にすべての構成設定を保留する config.js ファイルを作ります。ここで、Storybook 対応の Vue アプリケーションを作る重要な要素を定義します。

Vue を定義する

src/main.js ファイルと同様に、vueをインポートする必要があります。config.js を次のように更新します。

// .storybook/config.js

import Vue from 'vue';

Vue コンポーネントを定義する

丁度 Vue プロジェクトで行ったように、グローバル カスタムコンポーネントの Vue.component でインポートしてグローバルに登録する必要があります。config.js インポートを更新し、TaskLaneItem コンポーネントを登録します。

// .storybook/config.js

import Vue from 'vue';

// カスタムコンポーネントをインポートします。
import TaskLaneItem from '../src/components/TaskLaneItem';

// カスタムコンポーネントを登録します。
Vue.component('item', TaskLaneItem);

これは、Storybook は Vue アプリケーションとは個別に実行するので、実行しなければなりません。ローカルに登録されたコンポーネントは自動的に持ち込まれるのでご注意ください。これらは Vue コンポーネント オブジェクトの components プロパティを使って登録されたコンポーネントです。例えば:

new Vue({
  el: '#app',
  components: {
    'component-a': ComponentA,
    'component-b': ComponentB
  }
});

ComponentA および ComponentB は #app の下にローカルに登録されます。

TaskLaneItem はグローバル カスタムコンポーネントなので Storybook 内に独立してインスタンスを作成するためにそれをインポートして Vue で登録しなければなりません。

ストーリーを構成し読み込む

@storybook/vue から configure メソッドをインポートして Storybook を実行し、ストーリー(ストーリーについては以下で学びます)を読み込むためにそれを実装します。

// .storybook/config.js

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

import Vue from 'vue';

// カスタムコンポーネントをインポートします。
import TaskLaneItem from '../src/components/TaskLaneItem';

// カスタムコンポーネントを登録します。
Vue.component('item', TaskLaneItem);

function loadStories() {
  // 必要なだけのストーリーを要求できます。
}

configure(loadStories, module);

Storybook はツールをテストするのと同じように機能します。Config.js ファイルが configure メソッドを実行し、それによって loadStories という関数と module を引数として取ります。loadStories はそのボディに定義されたストーリーがあります。

ストーリーは指定された状態でコンポーネントを説明します。コンポーネントには activeinactiveloading などの各状態のストーリーを書き込みます。その後、Storybook ではインタラクティブなコンポーネントライブラリでその指定された状態のコンポーネントをプレビューできます。これは後ほど設定していきます。

より良いプロジェクト マネジメントのため、このストーリーはコンポーネントの隣りに保存するのが理想的です。src 内に stories.js ファイルを生成して使用するすべてのストーリーをホストします。それから stories.js ファイルを使って、次のように config.js ファイルにそのストーリーを素早くロードします。

// .storybook/config.js

// ...

function loadStories() {
  // 必要なだけのストーリーを要求できます。
  require('../src/stories');
}

configure(loadStories, module);

loadStories を実行するとき、Storybook は src/stories.js にあるすべてのストーリーをインポートして実行します。このように Storybook の構成よりもその実装にとことん集中することで config.jsファイルのメンテナンスがずっと簡単になります。現時点ではすべてのアクションが src/stories.js ファイル内で発生します。

すべてのカスタムコンポーネントと Vue プラグインは configure() を呼び出す前に登録します。

"「Storybook はインタラクティブに開発し、ユーザー インターフェイス コンポーネントをアプリケーションを実行しないでテストできます。それと VueJS を統合する方法について学びましょう。」"

Tweet This

Vue の Storybook ストーリーを書き込む

Storybook ストーリーを書いて、コンポーネントライブラリを現実のものにしましょう。src/stories.js に移動し、次のように始めます。

// src/stories.js

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

storiesOf('TaskLaneItem', module);

ここまでで storiesOf メソッドをインポートしました。このメソッドはコンポーネントのストーリーを作るのに役立ち、この場合、TaskLaneItem コンポーネントを使ってその役割を果たします。TaskLaneItemVue.component ですでにグローバルに登録されているので、それを src/stories.js にインポートする必要はありません。Using, Storybook の宣言型言語を使って TaskLaneItem のストーリーを次のように伝えます。

storiesOf('TaskLaneItem', module);

このプロセスを実際の書籍で考えると、これは書籍の製本や表紙になります。では、これらストーリーをページに埋めます。これを宣言的に add メソッドを使って行います。

// src/stories.js

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

storiesOf('TaskLaneItem', module).add('Default TaskLaneItem', () => ({
  template: '<item :item="{id: 10, text: \'This is a test\'}"></item>'
}));

add はストーリーがある書籍に章を追加するのと同じような役目をします。各章にタイトルを付けます。この場合、Default TaskLaneItem というタイトルのストーリーを作ります。add はそのストーリータイトルと、段階的なコンポーネントをレンダリングする関数を引数として取ります。この場合、コンポーネントは template オプションを指定した Vue コンポーネント定義オブジェクトです。

重要な注意事項:コンポーネントを表す、テンプレートで使用されるタグ名は Vue.component.storybook/config.js にコンポーネントを登録したときに使用した名前です。

// カスタムコンポーネントを登録します。
Vue.component('item', TaskLaneItem);

この Vue コンポーネント定義オブジェクトは idtext を控えめな props にしてモジュラーを増やすためにリファクタリングできます。

// src/stories.js

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

storiesOf('TaskLaneItem', module).add('Default TaskLaneItem', () => ({
  data() {
    return {
      item: { id: 10, text: 'This is a test' }
    };
  },
  template: '<item :item="item"></item>'
}));

ストーリーを書く基盤ができました。では Storybook を実行してその機能を確認しましょう。

"「Storybook はコンポーネント ライブラリにコンパイルされたストーリーを通して個別のコンポーネントのプレゼンテーションと状態を説明します。VueJS の Storybook ストーリーを書く方法を学びましょう。」"

Tweet This

Storybook を実行する

コンピューターで次のコマンドを実行します。

npm run storybook

すべての実行が成功したら、コンソールに次のメッセージが表示されます。

info Storybook started on => http://localhost:9001/

ブラウザーで URL http://localhost:9001/ を開きます。ロードしたら、ご自分の Storybook をお楽しみください。

この状態では TaskLaneItem コンポーネントはあまり良くありません。それはライブ アプリケーションで見たときと比較してテキストが読みにくいからです。

TaskLaneItem コンポーネントの定義を保留する src/components/TaskLaneItem.vue ファイルを開きます。背景色が定義されている以外はスタイル指定がされていないことが分かります。このコンポーネントの完全なスタイル指定は Bootstrap から来ます。よって、次のステップは Storybook に Bootstrap を使用させることです。

カスタムヘッドタグを Storybook に加える

index.html を開くと、kanban-board-pwa アプリは <head> 要素内で異なるタグを使っていることが分かります。Storybook で正確にコンポーネントをプレビューするための2つの関連タグは Bootstrap と FontAwesome をプロジェクトに紹介する <link> タグです。

Storybook はそのアプリとは個別に実行するので、index.html 内で定義されたこれらタグを見たり使用したりすることはできません。ソリューションとして、.storybook 構成ディレクトリの下に preview-head.htmlファイルを作成して必要な <link> タグを次のように追加します。

<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootswatch/4.0.0-beta.2/superhero/bootstrap.min.css">
<link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet" integrity="sha384-wvfXpqpZZVQGK6TAh5PVlGOfQNHSoD2xbE+QkPxCAFlNEevoEH3Sl0sibVcOQVnN" crossorigin="anonymous">

それを停止して Storybook を再起動してから npm run storybook を再び実行します。これで Storybook が更新した構成を使用できるようになります。再びブラウザーで http://localhost:9001/ を開くと、同じスタイル設定でずっと良い TaskLaneItem をフルバージョンのアプリでプレビューすることができます。

コンポーネント変更を Storybook でプレビューする

これまで既存の定義と構成でコンポーネントを段階的に実行してきました。Storybook の最も強力な機能はアプリケーションを実行せずに、変更をライブで見ることができることです。TaskLaneItem オレンジ色内のテキストの色にしたいとするとき、そのパディングを増やし、オレンジ色の境界線を追加します。いかがですか?TaskLaneItem.vue にある <style> タグ内で変更します。

// src/components/TaskLaneItem.vue
<template>
  // ... Template definition
</template>
<script>
  // ... Script definition
</script>
<style>
.card.task-lane-item {
  background: #627180;
  border: solid orange 5px;
}
.card-title {
  color: orange;
}
.card-block {
  padding: 20px;
}
</style>

そのファイルを保存すると、Storybook を更新したコンポーネントのプレビューをすぐに見ることができます!

個別のコンポーネントの外観や印象を実験することで時間の節約にもなります。UI パズル全体を組み立てる代わりに、ひとつのデータだけをプレビューできます。ただし Storybook はコンポジションの中でコンポーネントを実行できます。

次に進む前に、変更した TaskLaneItem のスタイルをリバースするだけでかなり良くなります。

Storybook で Vuex を使用する

ここでは簡単に提示するコンポーネントをプレビューする方法を学びましょう。では、さらに複雑な構造で、データと一緒にハイドレートするために Vuex ストアを使って TaskLane コンポーネントのストーリーを作りましょう。まず .storybook/config.js を更新してTaskLaneをインポートして登録します。

// .storybook/config.js

// ...

// カスタムコンポーネントをインポートします。
import TaskLaneItem from '../src/components/TaskLaneItem';
import TaskLane from '../src/components/TaskLane';

// ...

// カスタムコンポーネントを登録します。
Vue.component('item', TaskLaneItem);
Vue.component('lane', TaskLane);

// ...

次に、src/stories.js に戻って TaskLane のストーリーを作ります。

// src/stories.js

// ... TaskLaneItem stories

storiesOf('TaskLane', module).add('Default TaskLane', () => ({
  data() {
    return {
      doneItems: [
        { id: 10, text: 'This is a test' },
        { id: 12, text: 'This is another test' },
        { id: 14, text: 'This is yet another a test' },
        { id: 16, text: 'This is one more test' }
      ]
    };
  },
  template: `
      <div class="col-md">
        <lane id="done" title="Done" :items="doneItems"></lane>
       </div>
    `
}));

これは前に TaskLaneItem で実行した方法と同様で、主な違いは col-md クラスで div 内に lane を折り返し doneItems の配列を items プロパティを通して TaskLane にパスすることです。

前に、Vue.componentでコンポーネントを登録するために使った名前なので、コンプレート内のコンポーネントタグ名として lane を使っていました。

src/components/TaskLane.vue ファイルで見たように TaskLaneTaskLaneItem をローカルで登録するので、Storybook は自動的に TaskLaneItem をこのストーリーの TaskLane のスコープに持ち込みます。Default TaskLane ストーリーのコンポーネント定義オブジェクト内に components プロパティを作る必要はありません。

そのファイルを保存します。その変更はそのページをリフレッシュするまで Storybook に正しく表示されないかもしれないので、リフレッシュしてください。TaskLane メニュー項目をクリックしてそれを拡大しそれから Default TaskLane をクリックします。TaskLane コンポーネントのプレビューが次のように表示されます。

TaskLane はかんばんボードにタスクをリストするために TaskLaneItem を使用します。これら TaskLaneItem コンポーネントはこのアプリで構成されているように、TaskLane コンポーネントの間にドラッグ アンド ドロップします。Storybook TaskLane プレビューが完全にインタラクティブになったことが分かります。レーン内に表示されている項目はドラッグして動かすことができます。

ただし、レーン内にコンポーネントをドラッグすると、その位置は固定されません。開発者コンソールを開くと、次のようなエラーが見えます。

vue.esm.js:591 [Vue warn]: Property or method "$store" is not defined on the instance but referenced during render.

どうしたのでしょうか?src/components/TaskLane.vue ファイルにある TaskLane の定義に移動します。このコンポーネントはこのプロジェクトで作成された vuexストアを使用して項目を更新することが分かります。

// src/components/TaskLane.vue
// ... Template tag
<script>
// ... Script imports
export default {
 // ... Other properties
  computed: {
    itemCount() {
      if (!this.items) return '';
      if (this.items.length === 1) return '1 task';
      return `${this.items.length} tasks`;
    },
    draggables: {
      get() {
        return this.items;
      },
      set(items) {
        this.$store.commit('updateItems', {
          items,
          id: this.id
        });
      }
    }
  }
};
</script>
// ... Style tag

vuex ストアのインスタンスは $store を通じて存在します。それはレーンをタスクレーン項目が属するものに更新する updateItems 変更をコミットするために使用されます。ただし、ストーリー内の TaskLane のインスタンスはこのストアの存在に非対応です。さらに重要なのは、項目の配列をレンダリングするために手動で TaskLane にパスしています。なぜこのような問題が発生するのでしょうか。

kanban-board-pwa アプリケーションのアーキテクチャはアプリケーションの状態に対する信頼できる唯一の情報源として Vuex ストアを使用します。データをレンダリングする必要があるコンポーネントはストアから入手しなければなりません。Vuex ストアは再有効化されているので、ストアのストラクチャが変更されると、影響を受けたデータにサブスクライブされたコンポーネントは更新されます。

問題は Storybook サンドボックス内にあり、ストアはデータで初期化されていません。また、TaskLane はその items データをその親コンポーネントである KanbanBoard から取得します。KanbanBoard コンポーネント定義は src/components/KanbanBoard.vue でご覧いただけます。この親コンポーネントは TaskLane コンポーネントにパスした算定されたプロパティを作るストアをクエリします。

// src/components/KanbanBoard.vue
<template>
  <div class="board">
    <div class="row">
        <div class="col-md">
          <task-lane id="todo" title="Todo" :items="todoItems"></task-lane>
        </div>
        <div class="col-md">
          <task-lane id="inProgress" title="In progress" :items="inProgressItems"></task-lane>
        </div>
        <div class="col-md">
          <task-lane id="done" title="Done" :items="doneItems"></task-lane>
        </div>
    </div>
  </div>
</template>
<script>
import { mapState } from 'vuex';
import TaskLane from './TaskLane'
export default {
  name: 'KanbanBoard',
  components: {
    'task-lane': TaskLane
  },
  computed: mapState({
    todoItems: s => s.items.todo,
    inProgressItems: s => s.items.inProgress,
    doneItems: s => s.items.done
  })
};
</script>

個別に Tasklane インスタンスを作成することが Storybook を使用する目的です。ストアからの項目プロパティを Tasklane にパスできるために KanbanBoard 全体のインスタンスを作ることはソリューションでも、ストアがまだ空だからという理由でもありません。この問題を解決する最初のステップは Storybook プロジェクトが作成されたときに、項目をストアに追加することです。

.storybook/config.js に移動し、次のようにファイルを更新します。

// ...その他のインポート
import store from '../src/store';

// ..カスタムコンポーネントをインポートします。

store.commit('addItem', { text: 'This is a test' });
store.commit('addItem', { text: 'This is another test' });
store.commit('addItem', { text: 'This is one more test' });
store.commit('addItem', { text: 'This is one more test' });

// ...カスタムコンポーネントを登録します。

// ...

ここで Storybook プロジェクトが構築されたら、ストアはこれら4つの項目で読み込まれます。ただし、Storybook プロジェクトのファイルに変更すると、その項目はブラウザーのローカル ストレージに保存されているので、重複の項目が表示されます。これは Storybook がホストされているブラウザー ウィンドウを再度読み込みして解決します。

各項目の id はそのストアによって自動的に作成されます。次に、ストア項目をプロパティとして TaskLaneコンポーネントにパスするために src/stories.js 内の Default TaskLane ストーリーを更新します。

// src/stories.js

import { mapState } from 'vuex';
import { storiesOf } from '@storybook/vue';
import store from '../src/store';

// ...TaskLaneItem ストーリー

storiesOf('TaskLane', module).add('Default TaskLane', () => ({
  computed: mapState({
    items: s => [...s.items.todo, ...s.items.inProgress, ...s.items.done]
  }),
  store,
  template: `
      <div class="col-md">
        <lane id="todo" title="Todo" :items="items"></lane>
       </div>
    `
}));

ストアから計算されたゲッター関数を生成するために src/components/KanbanBoard.vue 内にあるロジックと同様に、mapState を使います。

項目を追加するとき、kanban-board-pwa アプリの機能を理解していることが重要です。KanbanBoard テンプレートに戻ると、各レーンに id 値が与えられていることが分かります。

// src/components/KanbanBoard.vue

<template>
  <div class="board">
    <div class="row">
        <div class="col-md">
          <task-lane id="todo" title="Todo" :items="todoItems"></task-lane>
        </div>
        <div class="col-md">
          <task-lane id="inProgress" title="In progress" :items="inProgressItems"></task-lane>
        </div>
        <div class="col-md">
          <task-lane id="done" title="Done" :items="doneItems"></task-lane>
        </div>
    </div>
  </div>
</template>

その idはストアの配列記号と(意図的に)一致するので 、データのオーバーヘッドがあまりなく、リストを劇的に更新する簡易対応になり、このデモのスコープにはぴったりのソリューションです。

作業を保存して Storybook をリフレッシュします。次をご覧ください。

項目をレーン内に並べ替え、これらが新しい位置を覚えているかご覧ください。

vuex ディレクトリを使う必要はありませんが、ストア内に一からストアを作成したい場合、vuex をインポートしそれを次のようにインストールします。

// .storybook/config.js

import Vuex from 'vuex'; // Vue plugins

// Vue プラグインをインストールします。
Vue.use(Vuex);

それから新しいストアのインスタンスを次のようにストーリー内に作ります。

import Vuex from 'vuex';

storiesOf('Component', module)
  .add('Default Component', () => ({
    store: new Vuex.Store({...}),
    template: `...`
  }));

vuex のような必須 Vue プラグインは Vue.use を使ってインストールする必要があります。

複合コンポーネントを Storybook に組み立てる

項目をレーン内にドラッグ アンド ドロップしてそれらがどのように並べ替えられるかを学ぶことはよかったですが、項目をレーンの間にドラッグ アンド ドロップできることはさらに素晴らしいことです。では、フルバージョンのアプリに存在する「Todo」、「In Progress」、「Done」の3つのレーンを表すストーリーを作りましょう。これは KanbanBoardコンポーネントのインスタンスを作らないで完了します。 src/stories.js に移動し、Three TaskLanes ストーリーを作ります。

// src/stories.js

// ... インポートします

// ...TaskLaneItem ストーリー

storiesOf('TaskLane', module)
  .add('Default TaskLane', () => ({
    computed: mapState({
      items: s => [...s.items.todo, ...s.items.inProgress, ...s.items.done]
    }),
    store,
    template: `
      <div class="col-md">
        <lane id="todo" title="Todo" :items="items"></lane>
       </div>
    `
  }))
  .add('Three TaskLanes', () => ({
    computed: mapState({
      todoItems: s => s.items.todo,
      inProgressItems: s => s.items.inProgress,
      doneItems: s => s.items.done
    }),
    store,
    template: `
      <div class="row">
        <div class="col-md">
          <lane id="todo" title="Todo" :items="todoItems"></lane>
        </div>
        <div class="col-md">
          <lane id="inProgress" title="In progress" :items="inProgressItems"></lane>
        </div>
        <div class="col-md">
          <lane id="done" title="Done" :items="doneItems"></lane>
        </div>
    </div>
    `
  }));

ここでは、レーン項目の各カテゴリ用に算出されたゲッター関数を生成するために mapState を使う KanbanBoard に存在する同じ算出されたプロパティを使います。3つのレーンはストアの配列シンボルに一致する「Todo」、「InProgress」、「Done」で id 値として作成されます。 この作業を保存し、Storybook に戻ります。TaskLane メニューのタブをクリックし、それから Three Tasklanes をクリックします。そうすると、次のようなものが表示されます。

重複項目があったら Storybook ウィンドウをリフレッシュします。

ここで、項目をレーン間にドラッグ アンド ドロップします。各項目はその新規レーンとレーンの新規位置を記憶します。良い機能ですね?

下部から上部にコンポーネントを作る練習をしましょう。小さなk提示可能なコンポーネントから始め、そのスタイルやコンテンツを調整し、それからプレゼンテーションのコンポーネント構成に依存する大きめのコンポーネントに移動します。Storybook では UI パズルの真にモジュラー非依存型データのような各コンポーネントを処理することに集中できます。

大規模なチームにとってのもうひとつの利点は、コンポーネント ライブラリを作ることでプロジェクト内だけでなく、効果的にブランディングを行うために一貫した外観と印象を必要とする組織のプロジェクト全体でコンポーネントを再利用できることです。

まとめ

vuex のような Vue プラグインにアクセスしたり使用したりしながら Storybook のコンポーネント ライブラリを通してインタラクティブな方法で基本的なプレゼンテーションや複雑なコンポーネントをプレビューする方法を学びました。Storybook はボタンをクリックして機能を起動したり UI テストのためなどに、コンポーネントを通してトリガーされるアクションをプレビューするためにも使うことができます。これらは高度な使用事例です。イベント処理やテストを扱うために、Vue 用に Storybook を拡張することについてのブログ投稿についてご関心のある方は以下のコメント欄でお知らせください。

Auth0 では、Storybook を広範囲にわたって使ってきました。Storybook でコンポーネント ライブラリを作ることについてご覧になりたい方、私たちが見つけた利点を知りたい方は「React および Storybook でコンポーネント ライブラリを設定する」 についての投稿をご覧ください。

Auth0:絶対に ID で妥協しない

アプリケーションの構築についてご関心がありますか?そのためにはユーザー認証を設定する必要があります。一から実装するのは複雑で時間がかかるかもしれませんが、Auth0 を使うと数分でできます。詳細については、https://auth0.com をご覧いただくか、@auth0 on Twitter をフォローしてください。