😺

Vue + Storybook環境でControlsと双方向データバインディングする

2023/09/28に公開

はじめに

ある子コンポーネントが値を受け取る。何らかの操作をした結果、イベントを通じて新しい値を親コンポーネントに返す。親は返ってきた値を元データに反映する。そして子は新しい値を受け取る。…

それが双方向データバインディング(という私の理解)です。子コンポーネント単位では 状態 (state) を持たない 構造にしておくことで、複雑度が下がって単体テストをしやすくなります。

Vue は v-model という仕組みでデータのバインディングおよび更新のハンドリングを実装できます。
https://ja.vuejs.org/guide/components/v-model.html

子コンポーネントに渡す値は通常、state としてページコンポーネントやグローバルな場所で保持します。

Storybook でも同様なのですが、双方向データバインディングの挙動をキャンバス上で確認するためにはストーリーテンプレートに state を持たせる必要が出てきます。しかし、これを各ストーリーファイルでやっていくと可読性が落ち、保守コストは増加していく恐れがあります。

でも待って!Storybook にはキャンバスに渡す値を一元管理している Controls タブがあるじゃん!これを state として使えないの?

ということで今回は @storybook/preview-api を使って Controls Addon と同期する方法をご紹介します。

執筆時点のバージョン

  • Storybook 7.4.5
  • TypeScript 5.2.2
  • Vue 3.3.4

前提

設定方法は shingo.sasaki さんの本(無料)をご覧ください。とても参考になります。
https://zenn.dev/sa2knight/books/storybook-7-with-vue-3

ライブラリのインストール

今回は例として yarn コマンドを使いますが、ここはご自身の環境に合わせてください。

yarn add @storybook/preview-api

これで準備完了です。Addon ではないのでストーリーファイルでインポートすればすぐに使えます。

余談ですが少し前は @storybook/client-api という名称でした。いつの間にか Deprecated になって代わりに @storybook/preview-api を使ってね、というふうになっています。使い方は変わりません。

コンポーネントの準備

prop で値を受け取り、その更新後の値を emit するコンポーネントなら何でも OK です。

src/components/CounterButton.vue
<script setup lang="ts">
const props = defineProps<{
  /** ボタンに表示する数値 */
  count: number;
}>();

const emit = defineEmits<{
  /** クリック時に発火し、更新後の `props.count` を返す */
  (evt: 'update:count', event: typeof props.count): void;
}>();
</script>

<template>
  <button @click="emit('update:count', count + 1)">
    現在のカウント : {{ count }}
  </button>
</template>

上記コンポーネントを実際に使う場合、以下のようなコンポーネントとなることでしょう。
※今回このファイル ↓ は使いません。

src/pages/index.vue
<script setup lang="ts">
import { ref } from 'vue';

import CounterButton from '../components/CounterButton.vue';

const count = ref(0);
</script>

<template>
  <CounterButton
    v-model:count="count"
  />
</template>

ストーリーの準備

@storybook/preview-api からインポートした useArgs という関数を render ブロックの中で使用します。

お手元の環境にコピー & ペーストして Storybook を起動してみてください。

src/components/CounterButton.stories.ts
import { useArgs } from '@storybook/preview-api';
import { type Meta, type StoryObj } from '@storybook/vue3';

import CounterButton from './CounterButton.vue';

type Story = StoryObj<typeof CounterButton>;

const meta: Meta<typeof CounterButton> = {
  component: CounterButton,

  parameters: {
    controls: {
      expanded: true,
    },
  },

  tags: [
    'autodocs',
  ],

  args: {
    count: 0,
  },

  render: (args) => {
    /**
     * `useArgs` 実行結果の返り値となる配列から2番目の関数を取り出します。
     * 1番目は Vue では使いません。多分 React 向けです。
     */
    const [, updateArgs] = useArgs<typeof args>();

    return {
      components: {
        CounterButton,
      },

      setup: () => {
        /**
         * イベントに応答して `args` を更新するためのハンドラーを作成します。
         */
        const handlers: (typeof CounterButton)['emits'] = {
          'update:count': ($event) => updateArgs({ count: $event }),
        };

        return {
          args,
          handlers,
        };
      },

      /**
       * ハンドラーを含むオブジェクトを `v-on` でバインドします。
       */
      template: `
        <CounterButton
          v-bind="args"
          v-on="handlers"
        />
      `,
    };
  },
};

export default meta;

export const Default: Story = {};

ブラウザで触ってみる

Default ストーリーを表示し、CounterButton コンポーネントを何度かクリックしてみてください。
Controls の値が更新されていくのがわかります。

解説

useArgs のソースコードはこちら。ストーリーの args を変更するロジックが書かれているのがわかります。
https://github.com/storybookjs/storybook/blob/a3cdabb025524822807318bc137f69be006596c2/code/lib/preview-api/src/modules/addons/hooks.ts#L576

先ほど例に挙げたストーリーファイルでは、render ブロックの冒頭でストーリーの args を更新するための関数 updateArgs を取り出しています。

const [, updateArgs] = useArgs<typeof args>();

この関数をストーリー内で呼び出すことで args、つまり Controls の値をキャンバス側から変更することができるのです。

例に挙げたソースでは以下のようなイベントハンドリングを行っています。イベントの結果を count という変数に反映する、というシンプルな命令です。

<CounterButton
  @update:count="
    (newValue) => {
      // ※実際には `args` を直接変更することはできません。
      args.count = newValue;
    }
  "
></CounterButton>

裏技: ロギングと両立する

Actions Addon によるロギングと両立することができます。イベントのバインディング手段が v-bindv-on の二通りある Vue 3 の仕様を利用します。

const meta: Meta<typeof CounterButton> = {
  /* ... */

  argTypes: {
    'onUpdate:count: {
      action: 'update:count',
    },
  },
};

コード例ではストーリーファイルの argTypes で Actions Addon を有効化していますが、以下の記事で紹介している共通設定を仕込んでおくと便利です。
https://zenn.dev/shota_kamezawa/articles/36cd647264656c

Discussion