Vue2 + Storybook v6 を扱う

はじめに

現場の状況で久しくメンテされなくなった storybook v4 時代にかかれていた storybook 関連ファイルが 依存パッケージアップデートで全く動かなくなった。

storybook 自体は公式・コミュニティ合わせて色々なフレームワークに対応しているものの、 やはり React ベースで書かれてそうな API というか、 v4 辺りのころから Vue.js で使うには若干無理やり使う感はあった。特に、よく使うaddon-knobsaddon-actions

v6 になってもそれはあまり変わらず、むしろ Vue3 で使う場合の使い勝手のよさが際立ってる。

結論

Vue3 で storybook v6 使うのはいいぞ!

という話なのである。いやそれじゃタイトル回収されない

github のリポジトリの issue みたりいくらか記事見ても全然見つからないので、みんなうまくいってるのか… こちらの不手際で設定や使うパッケージをミスってるだけだったら申し訳ないんだけど、Vue2 で storybook v6 扱うの結構しんどかった

"@storybook/vue" + "storybook"なんだけどなあ

Vue3 + storybook v6

たまたま同じ状態から Vue3 移行を先にやった別プロダクトがあったので、storybook v6 に合わせた stories をいくつか書いてみたが、これがかなり良くなっている。

API が react の FC 寄り?になった Vue3(CompositionAPI)だと、 そのまま args 横流しするだけでよかった:

せっかくなんでちょっとそれっぽいコード例
export default {
  title: 'AppButton'
  component: AppButton,
  argTypes: {
    width: {
      description: "(px) set 0 if 'auto' is needed",
    },
    fontSize: {
      description: '(px)',
    },
    color: {
      control: { type: 'color' },
    },
    onClick: {
      action: 'click',
    },
  },
}

const Template = args => ({
  components: { AppButton },
  setup()
    const { fontSize, width, color, ...props } = args
    const style = computed(() => ({
      color,
      width: width ? `${width}px` : 'auto',
      fontSize: `${fontSize}px`,
    }))
    return {
      props,
      style,
    }
  },
  template: `
    <AppButton v-bind="props" :style="style" />
  `,
})

export const Default = Template.bind({})
Default.args = {
  // storybook用のargs
  color: '#000',
  width: 0,
  fontSize: 12,
  // props
  disabled: false,
  text: '決定',
}

v4 の頃と違って、storybook 側の actions/knobs まわりの面倒見が良くなったこと、 その定義がメタデータとして分離したことによって、記述量が明らかに減ってて見通しがとてもよくなった

特に、イベント周りの扱いに関しては Vue3 の attr の仕様変更関連 RFC によって、従来 v-on で bind していたイベントリスナーでさえ、 v-bind を通じて bind することができるようになった。

v-bind_example
// Vue2 はこうしてるコードは
<AppButton @click="handleClick" />

// Vue3 だとこれもできる
<AppButton v-bind="{ onClick: handleClick }" />

// もちろん従来どおりのやり方もOK
<AppButton @click="handleClick" />

storybook 側ではメタデータのargTypes で定義したものは storybook の Template Function の引数(args) を通じて値として渡されるため、そのまま onClick は actions によって正しく捕捉され動作する。うれしい。下記は極端にシンプルな例:

SimpleButtonInCompositionAPI.stories.js
export default {
  title: 'SimpleButton'
  component: SimpleButton,
  argTypes: {
    onClick: {
      action: 'click',
    },
  },
}

const Template = args => ({
  components: { SimpleButton },
  setup() {
    return {
      args,
    }
  },
  // args.onClick がそのまま v-bind="{ onClick: fn }" のように渡され
  // 従来の @click="onClick" 同等として機能する
  template: `<SimpleButton v-bind="args" />`,
})

export const Default = Template.bind({})
Default.args = {}

Vue2 + storybook v6

ここまでタイトルにない Vue3 環境の話で言いたかったのは、 Vue2 (Options API)だと storybook の Template Function の引数(args)をそのままテンプレートに渡すとうまく動かないということである。

外部からの reactive な値を用いることができるデザインの Vue3(CompositionAPI) とは違い、Vue2(OptionsAPI) では Template Function の引数(args) をそのまま使うのではなく、template の Vue インスタンスに prop として bind される値を用いる。

SimpleButtonInOptionsAPI.stories.js
export default {
  title: 'SimpleButton'
  component: SimpleButton,
  argTypes: {
    onClick: {
      action: 'click',
    },
  },
}

const Template = args => ({
  components: { SimpleButton },
  template: `<SimpleButton @click="$props.onClick" />`,
})

export const Default = Template.bind({})
Default.args = {}

このとき、 this.$props の中身は下記のようになっている。(storybook: 6.3.12)

まずそもそも v-bind="{ onClick: fn }"@click="fn" として動作しないため、単純に this.$props を bind すれば良いわけではない。ただし下記のVue2 の公式 Doc の例のように v-onでオブジェクト記法を用いることはできる。

// https://jp.vuejs.org/v2/api/index.html#v-on のコード例より引用
//
<!-- オブジェクト構文 (2.4.0+) -->
<button v-on="{ mousedown: doThis, mouseup: doThat }"></button>

なので、storybook 側もそれに合わせて、v-on="$props"を用いたコード例が載っている:

// https://storybook.js.org/docs/vue/writing-stories/args#story-args より引用
// Button.stories.js

import Button from './Button.vue';

export default {
  component: Button,
  title: 'Components/Button',
};

//👇 We create a “template” of how args map to rendering
const Template = (args, { argTypes }) => ({
  components: { Button },
  props: Object.keys(argTypes),
  // Storybook provides all the args in a $props variable.
  // Each arg is also available as their own name.
  template: '<Button v-bind="$props" v-on="$props" />',
});

//👇 Each story then reuses that template
export const Primary = Template.bind({});
Primary.args = {
  primary: true,
  label: 'Button',
};

Storybook provides all the args in a $props variable.

とあるように、全部 this.$props で渡されるので、v-bind v-on どっちも全部渡してしまえというつもりなのだけど…

onClick

this.$props の中を見るとわかるとおり、イベントハンドラに関数を期待するのにも関わらず v-on="{ click: undefined }" なので、bind した瞬間(つまり story が表示されたとき)に死ぬ。

vue.esm.js:628 [Vue warn]: Invalid handler for event "click": got undefined

found in

---> <SimpleButton> at src/components/atoms/SimpleButton.vue
       <Anonymous>
         <Anonymous>
           <Root>

なので、

  1. this.$props からイベントハンドラを分離する
  2. 分離したイベントハンドラのキー名をイベント名変換して v-on に bind する object をつくる(仮に computed.handlers とする)
  3. v-on="handlers" のように bind する

という工程が必要になる。

使うためにやること

基本的には上記で書いたことをやるだけなので詳細な説明を省くが、this.$props からイベントハンドラの分離には大体こんなコードが必要になる:

computed.handlers
type EventHandler = (event: Event) => void;
type EventHandlerName<T extends string = string> = `on${Uppercase<T>}`;
type EventName<
  T extends EventHandlerName = EventHandlerName
> = T extends EventHandlerName<infer U> ? Lowercase<U> : never;

const isEventHandlerName = (key: string): key is EventHandlerName =>
  key.startsWith('on');
const toEventName = <T extends EventHandlerName>(name: T): EventName<T> =>
  name.replace(/^on/, '').toLowerCase() as any;

//...
  computed: {
    handlers() {
      return Object.keys(this.$props)
        .filter((key): key is EventHandlerName => key.startsWith('on'))
        .reduce(
          (acc, key) => ({
            ...acc,
            [toEventName(key)]: this.$props[
              key as keyof typeof this.$props
            ] as EventHandler,
          }),
          {} as Record<EventName, EventHandler>
        );
    },
//...
  },

これだけでも全然いいんだけど、そのままだと v-bind="$props" で props(or attrs) としてイベントハンドラが渡る気持ち悪さがあるので、丁寧に処理する

computed.propsとtemplate
    props() {
      // NOTE: workaround
      const eventKeys = Object.keys(this.$props)
        .filter(isEventHandlerName)
        .flatMap((key) => [key, toEventName(key)]);

      const propKeys = Object.keys(this.$props).filter(
        (key) => !eventKeys.includes(key)
      );
      return propKeys.reduce(
        (acc, key) => ({
          ...acc,
          [key]: this.$props[key as keyof typeof this.$props],
        }),
        {}
      );
    },
  },
  template: `
    <Component
      v-bind="props"
      v-on="handlers"
    />
  `,

うーん…

createTemplate として共通化する

stories ファイルを量産していくと、上記ボイラープレートコードだらけになるので特殊なケースを除いて共通化するモチベーションが高くなる。 また、storybook 側で上記対応が不要になった場合にも修正箇所が減るので関数化して stories ファイルを簡潔にしよう。

ついでに、css/:class で style を当てるケースを想定して、上記実装を拡張した例:

const toKebab = (str: string) =>
  str.slice(0, 1).toLowerCase() +
  str
    .slice(1)
    .replace(/([A-Z])/g, '-$1')
    .toLowerCase();

type CSSProp = {
  name: string;
  unit?: string;
};

export const createTemplate = (
  Component: Component,
  cssProps: CSSProp[] = [],
): Story => (_, { argTypes }) => ({
  components: { Component },
  props: Object.keys(argTypes),
  computed: {
    style() {
      /*
       * [{ name: 'fontSize', unit: 'px' }, { name: 'color' }] を
       * `font-size:<storyのfontSizeの値>px;color:<storyのcolorの値>;` として処理する
       */
      return cssProps.reduce((acc, prop) => {
        const value = this[prop.name as any];
        if (value === undefined) {
          return acc;
        }
        return `${acc}${toKebab(prop.name)}:${value + (prop.unit ?? '')};`;
      }, ``);
    },
    handlers() {
      return Object.keys(this.$props)
        .filter((key): key is EventHandlerName => key.startsWith('on'))
        .reduce(
          (acc, key) => ({
            ...acc,
            [toEventName(key)]: this.$props[
              key as keyof typeof this.$props
            ] as EventHandler,
          }),
          {} as Record<EventName, EventHandler>,
        );
    },
    props() {
      // イベント名 `^onEvent` を取り出して、`event, onEvent` を列挙する
      const eventKeys = Object.keys(this.$props)
        .filter(isEventHandlerName)
        .flatMap((key) => [key, toEventName(key)]);

      const propKeys = Object.keys(this.$props)
        // eventKeys を元にイベントハンドラを取り除く
        .filter((key) => !eventKeys.includes(key))
        .filter(
          (key) =>
            // v-model 対応しているコンポーネントは storybook が vModel というprop名の値が混ざるので消す…
            !cssProps.some((prop) => prop.name === key) && key !== 'vModel',
        );
      return propKeys.reduce(
        (acc, key) => ({
          ...acc,
          [key]: this.$props[key as keyof typeof this.$props],
        }),
        {},
      );
    },
  },
  template: `
    <Component
      v-bind="props"
      v-on="handlers"
      :style="style"
    />
  `,
});

これで、使う側(stories ファイル)はとても簡潔になった:

AppButton.stories.js
export default {
  title: createTitle(dirname, filename),
  component: AppButton,
  argTypes: {
    fontSize: {
      description: 'font-size in px',
    },
    color: {
      control: { type: 'color' },
    },
    onClick: {
      action: 'click',
    },
  },
};

const Template = createTemplateWithStyle(ButtonClose, [
  { name: 'fontSize', unit: 'px' },
  { name: 'color' },
]);

export const Default = Template.bind({});
Default.args = {
  fontSize: 13,
  color: '#000',
};

workaround が不要になったら、単に createTemplatecomputed の処理がスッキリするだけ。最高!