BASEプロダクトチームブログ

ネットショップ作成サービス「BASE ( https://thebase.in )」、ショッピングアプリ「BASE ( https://thebase.in/sp )」のプロダクトチームによるブログです。

Vue2 + Storybook v5 のコンポーネントを v6 向けに書き換える

BASE株式会社 Owners Experience Frontend チームのパンダ(@Panda_Program)です。

BASE では BASE の UI を構築するための社内コンポーネントライブラリ「BBQ」を使ってフロントエンドの開発をしています。

BBQ は Vue2 + Storybook v5 で作成されています。現在、フロントエンドの有志たちで Storybook のバージョンを最新の v6.2 にする対応をしています。

この記事では、Vue2 + Storybook v5 のコンポーネントを v6 向けに書き換える方法を紹介します。

なお、本記事ではStorybook v6 自体の機能の説明や、main.jspreview.jsの書き方といった Storybook の環境構築の方法には触れません。

Storybook コンポーネントを v5 から v6 に書き換える

ここではbutton-groupを v5 から v6 に書き換えた例を紹介します。

まずは v5、v6 の書き方をそれぞれご覧ください。その後、変更点をそれぞれ解説をしていきます。

v5 の button-group.stories.js

// bbq/stories/elements/button-group.stories.js

import { action } from '@storybook/addon-actions'
import { number, select, text, withKnobs } from '@storybook/addon-knobs'
import { storiesOf } from '@storybook/vue'
import { withInfo } from 'storybook-addon-vue-info'

import { ButtonGroup } from '../../elements/buttonGroup/button-group.vue'
import README from '../../elements/buttonGroup/README.md'
import { devices } from '../../values/Devices'

// Storybook コンポーネント名
const buttonStories = storiesOf('Elements/ButtonGroup', module)

buttonStories
  // addon-knob
  .addDecorator(withKnobs)
  // addon-info
  .addDecorator(withInfo)
  .add(
    'ButtonGroup',
    () => {
      return {
        components: { ButtonGroup },
        // Vue Template
        template: `
          <div :class="'theme-'+device">
            <p>
              <h2>デフォルト</h2>
              <div>アイコン+ラベル</div>
              <bbq-button-group :tag="tag" :items="iconsAndLabels" @change="({index}) => {this.selected = index; change(index)}" :selected="selected" :width="width"/>
              <div>アイコン</div>
              <bbq-button-group :tag="tag" :items="icons" @change="({index}) => {this.selected = index; change(index)}" :selected="selected" :width="width"/>
              <div>ラベル</div>
              <bbq-button-group :tag="tag" :items="labels" @change="({index}) => {this.selected = index; change(index)}" :selected="selected" :width="width"/>
            </p>
            <p>
              <h2>カスタムUI</h2>
              <bbq-button-group :tag="tag" :items="['a','b', 'c', 'd']" @change="({index}) => change(index)" :selected="selected" :width="width">
                <template v-slot="{items, change}">
                  <button v-for="(item, index) in items" @click="change(index)">
                    {{item}}: {{index}}
                  </button>
                </template>
              </bbq-button-group>
            </p>
          </div>
        `,
        // Data
        data() {
          return {
            device: select(`device`, devices, 'pc'),
            selected: number('selected', 0),
            width: select('width', ['', 'full']),
          }
        },
        // Props
        props: {
          tag: { default: text('tag', 'ul') },
        },
        // Computed
        computed: {
          icons() {
            return [{ icon: 'list' }, { icon: 'grid' }]
          },
          labels() {
            return [{ label: 'ドラッグで並び替え' }, { label: '数値で並び替え' }]
          },
          iconsAndLabels() {
            return [
              { icon: 'list', label: 'リストで並び替え' },
              { icon: 'grid', label: 'グリッドで並び替え' },
              { icon: 'attentionCircle', label: '念で並び替え' },
            ]
          },
        },
        // methods
        methods: {
          change: action('change'),
        },
      }
    },
    // Parameters
    {
      notes: README,
    }
  )

v6 の button-group.stories.js

// bbq/elements/buttonGroup/button-group.stories.js

import { devices } from '../../values/Devices'

import ButtonGroup from './ButtonGroup'
import README from './README.md'

export default {
  // Storybook コンポーネント名
  title: "V6/Elements/ButtonGroup/Vue",
  // import したコンポーネントを指定
  component: ButtonGroup,
  // parameters
  parameters: {
    notes: { README },
    docs: {
      extractComponentDescription: ((_, { notes }) => notes?.README)
    }
  },
  argTypes: {
    // addon-knob の select で定義していた変数
    device: {
      options: devices,
      defaultValue: devices[0],
      control: { type: "select" }
    },
    width: {
      options: ["", "full"],
      control: { type: "select" }
    },
    // addon-action で定義していた関数
    change: { action:  'changed' }
  }
};

const Template = (args, { argTypes }) => ({
  components: { ButtonGroup },
  props: Object.keys(argTypes),
  template: `
    <div :class="'theme-'+device">
      <div>
        <h2>デフォルト</h2>
        <div>アイコン+ラベル</div>
        <bbq-button-group :tag="tag" :items="iconsAndLabels" @change="({index}) => {this.selected = index; change(index)}" :selected="selected" :width="width"/>
        <div>アイコン</div>
        <bbq-button-group :tag="tag" :items="icons" @change="({index}) => {this.selected = index; change(index)}" :selected="selected" :width="width"/>
        <div>ラベル</div>
        <bbq-button-group :tag="tag" :items="labels" @change="({index}) => {this.selected = index; change(index)}" :selected="selected" :width="width"/>
      </div>
      <div>
        <h2>カスタムUI</h2>
        <bbq-button-group :tag="tag" :items="['a','b', 'c', 'd']" @change="({index}) => change(index)" :selected="selected" :width="width">
          <template v-slot="{items, change}">
            <button v-for="(item, index) in items" @click="change(index)">
              {{item}}: {{index}}
            </button>
          </template>
        </bbq-button-group>
      </div>
    </div>
  `
});

export const Default = Template.bind({})
Default.args = {
  // Default コンポーネントに与える Props
  selected: 0,
  tag: "ul",
  icons: [{ icon: 'list' }, { icon: 'grid' }],
  labels: [{ label: 'ドラッグで並び替え' }, { label: '数値で並び替え' }],
  iconsAndLabels: [
    { icon: 'list', label: 'リストで並び替え' },
    { icon: 'grid', label: 'グリッドで並び替え' },
    { icon: 'attentionCircle', label: '念で並び替え' },
  ],
};

なお、devices の定義はconst devices = ['pc', 'sp']です。

Storybookv6の変更点

上記、新旧ファイルの変更点を抜粋してコードを比較します。

Storybook 上のコンポーネント名

Storybook で表示されるコンポーネント名の定義方法の変更点です。

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

const buttonStories = storiesOf('Elements/ButtonGroup', module)
// v6
export default {
  title: "Elements/ButtonGroup",
  // ...
}

v6 では @storybook/vueを import する必要がなくなりました。その代わりに、default export するオブジェクト内にコンポーネントのメタ情報を記述します。

表示するコンポーネントを指定

コンポーネントを指定する箇所も変更になっています。

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

import { ButtonGroup } from '../../elements/buttonGroup/button-group.vue'

const buttonStories = storiesOf('Elements/ButtonGroup', module)

buttonStories.add('ButtonGroup',
    () => {
      return {
        components: { ButtonGroup },
        // ...
     }
  }
)
// v6
import ButtonGroup from './ButtonGroup'

export default {
  // ...
  component: ButtonGroup,
}

v6 ではコンポーネントを default export するオブジェクトのプロパティに追加します。

parametersを定義する箇所の変更

v5 では addメソッドの第三引数だった parameters の定義箇所が、v6 では default export するオブジェクトに変更になりました。ここではマークダウンファイルREADME.mdを v6 で読み込める書き方を紹介します。

// v5
import { storiesOf } from '@storybook/vue'
import { withInfo } from 'storybook-addon-vue-info'

import README from '../../elements/buttonGroup/README.md'

const buttonStories = storiesOf('Elements/ButtonGroup', module)

buttonStories
  .addDecorator(withInfo) // decorator で addon-vue-info を活用している
  .add(
  'ButtonGroup',
  () => { ... },
  { notes: README }
)
// v6
import README from './README.md'

export default {
  // ...
  parameters: {
    notes: { README },
    docs: {
      extractComponentDescription: ((_, { notes }) => notes?.README)
    }
  },
}

v6 では Storybook 上の Docs タブでREADME.mdを表示できます。このため、@storybook/addon-notesstorybook-addon-vue-infoは不要になります。

今回は v6 のコンポーネント内で定義しましたが、preview.jsに以下のように記述すると Storybook の全コンポーネントに parameters が追加されるため、docsを各ファイルで記述すること避けられます(「Migrating from notes/info addons」)。

// preview.js
import { addParameters } from '@storybook/client-api';

addParameters({
    docs: {
        extractComponentDescription: ((_, { notes }) => notes?.README)
    },
});

なお、今回は既存資産を活かすためにマークダウンファイルをそのまま使いましたが、MDXを用いる方法も公式で紹介されています。

addon-knob の書き換え

v5 では addon-knob を使うと Storybook 上でコンポーネントに与える値を画面上で変更できました。

v6 ではaddon-essentialsに含まれているcontrolsを使えば同様のことができます。

以下では knob のnumberselecttext関数を書き換えています。

// v5
import { number, select, text, withKnobs } from '@storybook/addon-knobs'

import { devices } from '../../values/Devices'

buttonStories.
  add(
    // ...
      data() {
        return {
          device: select(`device`, devices, 'pc'),
          selected: number('selected', 0),
          width: select('width', ['', 'full']),
        }
      },
      props: {
        tag: { default: text('tag', 'ul') },
      },
  }
)
// v6
import { devices } from '../../values/Devices'

export default {
  // ...
  argTypes: {
    // select 関数で作成していた値
    device: {
      options: devices,
      defaultValue: devices[0],   // 初期値の設定
      control: { type: "select" }   // この行は省略可能
    },
    width: {
      options: ["", "full"],
      control: { type: "select" } // この行は省略可能
    },
};

const Template = (args, { argTypes }) => ({ ... });

export const Default = Template.bind({})
Default.args = {
  selected: 0,               // number 関数で作成していた値
  tag: "ul",                    // text 関数で作成していた値
};

select 関数の代わりになるcontrol: { type: "select" } で定義した値は、defaultValue で初期値を設定できます。

なお、export defaultの中で定義しているdevicewidthは以下のようにDefault.argsで定義することも可能です。

// v6
Default.args = {
  device: devices,
  width: ["", "full"],
  selected: 0,
  tag: "ul",
};

(参考: Dealing with complex values

addon-actions の action 関数の書き換え

addon-actions を使ったダミーのコールバック関数の定義方法も変更になりました。

// v5
import { action } from '@storybook/addon-actions'

buttonStories
  .add(
    // ...
    () => {
       // ...
        methods: {
          change: action('changed'),
        },
      }
    }
  )
// v6
export default {
  argTypes: {
    change: { action:  'changed' }
  }
};

(参考: addon-actions

ただし、以前のようにaction関数を用いても問題なく動作するため、書き換えは必須ではありません。

コンポーネントに渡すデータの定義の変更

v5 で記述していた data, props, computed で定義していた値を Storybook の画面上で自由に変更したい場合は、argTypesargsに集約可能です。

// v5
buttonStories
  .add(
    // ...
    () => {
       // ...
        // Data
        data() {
          return {
            device: select(`device`, devices, 'pc'),
            selected: number('selected', 0),
            width: select('width', ['', 'full']),
          }
        },
        // Props
        props: {
          tag: { default: text('tag', 'ul') },
        },
        // Computed
        computed: {
          icons() {
            return [{ icon: 'list' }, { icon: 'grid' }]
          },
          labels() {
            return [{ label: 'ドラッグで並び替え' }, { label: '数値で並び替え' }]
          },
          iconsAndLabels() {
            return [
              { icon: 'list', label: 'リストで並び替え' },
              { icon: 'grid', label: 'グリッドで並び替え' },
              { icon: 'attentionCircle', label: '念で並び替え' },
            ]
          },
        },
// ...
// v6
export default {
  // ...
  argTypes: {
    // addon-knob の select で定義していた変数
    device: {
      options: devices,
      control: { type: "select" }
    },
    width: {
      options: ["", "full"],
      control: { type: "select" }
    },
};

// ...

export const Default = Template.bind({})
Default.args = {
  selected: 0,
  tag: "ul",
  icons: [{ icon: 'list' }, { icon: 'grid' }],
  labels: [{ label: 'ドラッグで並び替え' }, { label: '数値で並び替え' }],
  iconsAndLabels: [
    { icon: 'list', label: 'リストで並び替え' },
    { icon: 'grid', label: 'グリッドで並び替え' },
    { icon: 'attentionCircle', label: '念で並び替え' },
  ],
};

ただし、data や props、computed をそのまま残すことも可能です。その場合、 props 以外は GUI 上で値を変更できません。Storybook の GUI 上で変更したい値であれば、args で記述すれば良いと思います。

BASE BBQ の.Storybook では様々なパターンがあるため、まずは v6 の書き換えを優先しています。このため、data 等で定義している値は一旦 args に集約し、コンポーネントごとの細かい調整は個別に対応する予定です。

以上のパターンで BASE の BBQ で作成された Storybook コンポーネントの大抵のケースを網羅しています。

その他、より詳しい変更点は、Storybook 6 Migration Guideをご覧ください。

addon について

v6 で利用可能な Essential addons には、今までの主要な.addon の機能がまとめられています。

  • Docs
  • Controls
  • Actions
  • Viewport
  • Backgrounds
  • Toolbars & globals

Storybook で開発するにあたり、@storybook/addon-essentialsは開発体験を向上させてくれるため、v6 からは必須といっても過言ではないでしょう。

ここでは、先程紹介した例で使用している addon のみ取り上げます。

  • addon-knob
    • v6 では deprecated
    • addon-essentials の controls を代わりに使う
  • addon-info
    • knob と同様に v6 では deprecated
    • addon-essentials の docs を代わりに使う
  • addon-action
    • v7 で deprecated になる予定
    • v6 ではまだ使えるが、可能なら control に置き換えると次のバージョンアップがスムーズになる

v6 で addon-action を使いたい場合は、以下のように記述すれば OK です。

const Template = (args, { argTypes }) => ({
    // ...
    template: `...`,
    methods: {
        change: action("changed"),
    }
});

表示を確認する

v6 の書き方に変更したコンポーネントを実際にStorybook で表示すると以下のようになります。

Storybook の ButtonGroup コンポーネント
StorybookのButtonGroup ンポーネント

control で値を変更して様々な props の表示ケースを確認できるようになりました。

また、「Docs」というタブをクリックすると README が表示されています。

StorybookのButtonGroupコンポーネントのDocs
StorybookのButtonGroupコンポーネントのDocs

これで v6 への書き換えが完了しました。

Storybook v6 で向上した開発体験

v5 と異なり、v6 では以下の点で開発体験が向上しました。

  • コンポーネントに与えるデータを Vue の外(args, argTypes)で定義できる
  • args として定義した値は addon-knob を使わなくても Storybook 上で値を書き換えられる
  • 上記の例では取り上げていませんが、args を変えることでコンポーネントのバリエーションを容易に作成できる(using-args
  • インストールする addon の数や、stories ファイルのボイラープレートが減った

おわりに

今回は Vue2 + Storybook v5 の環境で Storybook を v6 にアップデートする詳細な方法を紹介しました。

Storybook のバージョンアップにあたり本記事の内容が参考になれば幸いです。

多くの方はお気づきだと思いますが、v5 から v6 への書き換えといってもパターンが決まっています。

このため、手順さえ分かってしまえばプログラムで機械的に置換するだけで対応できます。

この考え方をもとに、TypeScript Compiler API を使ってメタプログラミングで v5 のコンポーネントを v6 に書き換えたので、次回の記事でその方法をご紹介しようと思います。