Vue + Storybook環境でControlsと双方向データバインディングする
はじめに
ある子コンポーネントが値を受け取る。何らかの操作をした結果、イベントを通じて新しい値を親コンポーネントに返す。親は返ってきた値を元データに反映する。そして子は新しい値を受け取る。…
それが双方向データバインディング(という私の理解)です。子コンポーネント単位では 状態 (state) を持たない 構造にしておくことで、複雑度が下がって単体テストをしやすくなります。
Vue は v-model
という仕組みでデータのバインディングおよび更新のハンドリングを実装できます。
子コンポーネントに渡す値は通常、state としてページコンポーネントやグローバルな場所で保持します。
Storybook でも同様なのですが、双方向データバインディングの挙動をキャンバス上で確認するためにはストーリーテンプレートに state を持たせる必要が出てきます。しかし、これを各ストーリーファイルでやっていくと可読性が落ち、保守コストは増加していく恐れがあります。
でも待って!Storybook にはキャンバスに渡す値を一元管理している Controls タブがあるじゃん!これを state として使えないの?
ということで今回は @storybook/preview-api
を使って Controls Addon と同期する方法をご紹介します。
執筆時点のバージョン
- Storybook 7.4.5
- TypeScript 5.2.2
- Vue 3.3.4
前提
- Controls Addon が有効になっている
設定方法は shingo.sasaki さんの本(無料)をご覧ください。とても参考になります。
ライブラリのインストール
今回は例として yarn コマンドを使いますが、ここはご自身の環境に合わせてください。
yarn add @storybook/preview-api
これで準備完了です。Addon ではないのでストーリーファイルでインポートすればすぐに使えます。
余談ですが少し前は @storybook/client-api という名称でした。いつの間にか Deprecated になって代わりに @storybook/preview-api を使ってね、というふうになっています。使い方は変わりません。
コンポーネントの準備
prop で値を受け取り、その更新後の値を emit するコンポーネントなら何でも OK です。
<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>
上記コンポーネントを実際に使う場合、以下のようなコンポーネントとなることでしょう。
※今回このファイル ↓ は使いません。
<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 を起動してみてください。
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
を変更するロジックが書かれているのがわかります。
先ほど例に挙げたストーリーファイルでは、render
ブロックの冒頭でストーリーの args
を更新するための関数 updateArgs
を取り出しています。
const [, updateArgs] = useArgs<typeof args>();
この関数をストーリー内で呼び出すことで args
、つまり Controls の値をキャンバス側から変更することができるのです。
例に挙げたソースでは以下のようなイベントハンドリングを行っています。イベントの結果を count
という変数に反映する、というシンプルな命令です。
<CounterButton
@update:count="
(newValue) => {
// ※実際には `args` を直接変更することはできません。
args.count = newValue;
}
"
></CounterButton>
裏技: ロギングと両立する
Actions Addon によるロギングと両立することができます。イベントのバインディング手段が v-bind
と v-on
の二通りある Vue 3 の仕様を利用します。
const meta: Meta<typeof CounterButton> = {
/* ... */
argTypes: {
'onUpdate:count: {
action: 'update:count',
},
},
};
コード例ではストーリーファイルの argTypes で Actions Addon を有効化していますが、以下の記事で紹介している共通設定を仕込んでおくと便利です。
Discussion