ぬまのそこ

namazuのゆるいエンジニアブログ

VueプロジェクトでのStorybook運用Tips

久しぶり更新。

半年ほどVue.jsのプロジェクトでStorybookを利用していたのですが、最近ガイドラインの整備や開発フロー等を固めることができ、現状割とよしなにできてきている(と個人的に思っている)ので、これまでの運用のTipsをまとめようと思います。

今回はVueのプロジェクトでStorybookを運用するにフォーカスを当てて、どうしてきたか、なぜそうしたのかをまとめます。

誰かに少しでもカオスなVueプロジェクトの整備に役立つ情報が提供できれば幸い。

TL;DR

先にまとめ。

  • StoryはvueファイルのSFCで定義する(記法を増やさない)
  • 規約を固める, 定義の仕方が一意に定まるようにする, 規約はテストする
  • Story定義と対象コンポーネントの乖離を防ぐためlintやtestをする
  • VuexはStoryごとにインスタンスを分ける(Story間で依存を出さない)
  • Storybookのメリットを最大化させるためにDOMSnapshot, ビジュアル回帰テスト等を組み合わせる
  • 常に最新のStorybookをエンジニア以外も確認できる状態にする

1. StoryはvueファイルのSFCで定義する(記法を増やさない)

Storybookの導入直後はjsxでStoryを定義していました。しかしこれはうまくいきませんでした。

Story対象のコンポーネントSFC(.vue)で定義しており、プロジェクトのVueファイルは基本SFCで書かれていました。 そのためStory定義だけJSXが現れる ということになりました。 

その結果、

  • webpackの設定が本家ビルドと乖離
  • JSXのためにlint等の設定も増える, vue-eslint-pluginの恩恵をStory定義は受けられない
  • whitespaceの扱い等vue-template-compilerを使わないことで感覚と違う挙動が起きる
  • 記法が違うのに慣れずVueは書けるがStoryはすぐ定義できないメンバ

等の問題が置きました。

特にStory定義をコードで見るとコンポーネントの使い方を示すものとしたかったのにもかかわらずv-modelv-on等が実際のコードと離れてしまいよくわからないという問題が致命的でした。

この問題に対処するため, SFCでStory定義を書くようにしました。

( v-model等がJSXでは厄介だったため, 一時期ComponentOptionのtemplate内にテンプレート文字列で定義なんてことも行われてカオスになりました )

だいぶ端折っていますが, 大体こんな感じのコードで実現します。

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

// storybook/components/button/button-base.stories.vue みたいなSFCファイルを Components|Button/ButtonBase というStoryで登録する.
const vueContext = require.context('./stories', true, /\.stories\.vue$/);
vueContext.keys().forEach(path => {
  const pathStr = path.toString();
  const matches = pathStr.match(/^\.\/(.+)\/(.+).stories\.vue$/);
  const contextPaths = matches[1].split('/')
  const rootPath = contextPaths.shift();
  const kind = `${rootPath}|${contextPaths.join('/')}`;
  if (contextPaths.length === 0) {
    throw new Error('RootSeparatorの直下にStoryは配置できません');
  }
  const componentFileName = toCamelfromKebab(matches[2]);

  const stories = storiesOf(kind, module);

  const story = vueContext(path);
  const component = story.default;

 stories.add(`${componentFileName}`, () => ({
    ...component
  }));
});

require.contextstories.vue(この拡張子がstory定義ということにしています)を探してきて, そのファイルパスに応じてStory定義の階層を切ります。 そしてファイル名をStory名として追加します。

実際ではこのコードに加え, parameters(Storybookのデコレータで使うアレ)をSFC定義から流すとか , a/b/cパターンがあるコンポーネントのStory定義のための方法とか, knob対応とか諸々をし運用しています。 基本SFC側に任意のオプションを定義できるようにしてやってそれをこの登録スクリプトでよしなにしています。

2. 規約を固める, 定義の仕方が一意に定まるようにする

SFCで書くと割と書き方が一意になりますが、機械生成とかしたいのと考えることを減らしたいので、現在以下の規約を定めています。 定めた規約はテストしています。

コンポーネントに対し必ず1つの同名のStory定義ファイル(.stories.vue)が存在すること

ButtonBase.vue というファイルのStory定義は, ButtonBase.stories.vue というファイルであることを保証しています。

コンポーネントの使い方は?といったときに同名でファイル探索して探せるのと, Storybook上でも同名で定義されるのでStorybook上でみてコードベースで見てということが簡単にできます。

もし対象コンポーネントがpropsによってパターン分けがあって, それをStorybook上で定義したいときはpropsProviderというプロパティを生やしていて, そのキーでstoryの場合分けがされます.

// propsProvider  { [key: string]: () => { testOnly: boolean, [key:string]: any } }
  // Aパターン, Bパターン みたいなstoryをsfc内で複数定義可能にする.
  if (component.propsProvider !== undefined) {
    Object.entries(component.propsProvider).forEach(([patternName, data]) => {
      const storyModifier = patternName !== 'default' ? `[${patternName}]` : '';
      const providedProps = Object.fromEntries(Object.entries(data)
        .map(([name, value]) => [
          name,
          {
            default: value
          }
        ]));

      stories.add(`${componentFileName}${storyModifier}`, () => ({
        ...componentOptions,
        props: {
          ...providedProps,
          ...componentOptions.props
        }
      }));
    });

こんな感じです.

コードベースでコンポーネントの存在する階層とStory定義上の階層は一致すること

例えばcomponents/button/ButtonBase.vue みたいなコンポーネントがあります。 これをstorybook/stories/components/button/ButtonBase.stories.vue みたいなディレクトリに置くということです。 (components/modal/ModalBase.vue => storybook/stories/components/modal/ModalBase.vue) (実際はもっとコンポーネントの階層分けは厳密ですが, 対応関係を満たします。

1でsfcの置き場からStorybookでの階層を決めているとしたので, 全て対応するようにルールにしています。

これは, stories.vueファイルを対象としたテストで検証しています.

3. Story定義と対象コンポーネントの乖離を防ぐためlintやtestをする

コンポーネントにpropsが増やされたり消されたりして, propsが変化したときstory定義を古いままそのままにされると, すぐに乖離が起きてStory定義がコンポーネントの使い方や挙動を示すものとして使い物にならなくなります。

これを防ぐため, 必ず一致することを検証しています。 方法としてはstoryshotsが走るときvnodeを走査しもしズレてたらテストを落とします たぶんLintでやったほうが良いですが、Lint力がないので動的解析しています(fnComponentは諦めました)

こんな感じのをmixinでstoryshtosが走るときに混ぜています. 混ぜるとvnodeを見てアレならテストが落ちます.

export function testMounted() {
  const { story } = this.$options.$storyContext; // storyContextをvueインスタンスに混ぜて流しています
  const storyComponentName = story;
  const findStoryComponentNode = node => {
    // fnComponent
    if (node.fnOptions && node.fnOptions.name === storyComponentName) {
      return node;
    }
    if (node.componentOptions && node.componentOptions.tag === storyComponentName) {
      return node;
    }

    for (let i = 0; node.children && i < node.children.length; i++) {
      const test = findStoryComponentNode(node.children[i]);
      if (test !== null) {
        return test;
      }
    }
    return null;
  };
  const componentNode = findStoryComponentNode(this._vnode);

  // テスト対象コンポーネントがvNodeTreeに存在している
  expect(componentNode).not.toBeNull();

  // コンポーネント側で定義されたpropsをstory定義が満たしている
  if (!componentNode.componentOptions) {
    // fnのとき(やり方がわからない... )
  } else {
    const defineProps = componentNode.componentInstance.$options._propKeys;
    const propsData = Object.keys(componentNode.componentOptions.propsData || {});
    defineProps &&
      defineProps.length > 0 &&
      expect(propsData).toEqual(expect.arrayContaining(defineProps));
  }
}

上記以外にも、片方の名前が勝手に変わったとかディレクトリが移動したーとかで乖離することもありますが、双方が一致していないとテストが落ちるようにしています。 

レビューで指摘するのは辛い感じになる上普通に漏れるので、ルールにしたものは全てテストがある状態を心がけてます。

4. VuexはStoryごとにインスタンスを分ける

moduleのstateを関数形式で書いてなかったりしたとか, 全StoryでVuexのインスタンスが同じとかだと, たまに変な挙動をする上, storyshot等のテストが意図せず順序依存していたーなんてことになります。(2,3度引っかかりました)

Vuexのインスタンスはまぁ当然として, 読み込むとVue.prototypeとかを汚染するコンポーネント/ ディレクティブを利用したStory定義とか注意が必要です。

定義時にそれぞれ依存をなくすように心がけましょう。

5. DOMSnapshot, ビジュアル回帰テスト等をStorybookと合わせ活用する

Storybookの運用はやらないのと比較して当然コストがかかります。 なので得られるメリットを最大化しましょう。

うちではStoryshotsを利用したDOMSnapshotや, puppetterを利用してビジュアル回帰テスト等をしています。 ( カバレッジがちゃんとあると ) これらのCIが通るだけで割と安心できるので良いです。

6. 常に最新のStorybookをエンジニア以外も確認できる状態にする

しましょう。 やることはCIでビルドしてどっかにuploadするだけです。

特にデザイナーや、開発をやってない人に、ほらと見せられる状態にしておくのは価値があります。 webに上がっているStorybookを見せて, デザイナーとこのパターンがーみたいな話をすると話が早かったりしたことは何度も。

さいご

こんな感じで半年ほど運用しています。

うちは途中からStorybookを導入したのですが、今ではいわゆる共通コンポーネントレイヤーは100%Story定義がある状態をプロジェクトで実現できました。 Story定義もコードベースと乖離せずフレッシュな状態を保てています。 

Storybook入れる前はテスト等なく何もわからない変更がデプロイされていたのが、DOMSnapshotのDiffとカバレッジである程度安心できる状態。

引き続き頑張っていきたいです。