RAKSUL TechBlog

ラクスルグループのエンジニアが技術トピックを発信するブログです

CSSスタイルガイドをStorybook for HTMLに移行した話

こんにちは。印刷のラクスルでフロントエンドを担当している菅野です。

2週連続の投稿となります。(前回の記事: Jestを実行した時に、同時にESlintを走らせてみる)

 

現在、印刷サービスのフロントエンドエンジニアとデザイナーでデザインシステムの構築・整備を行っています。

その一環として、運用中のCSSスタイルガイドをStorybookに移行しました。

今回は、移行を決めた動機や導入時に得られた知見、今後の運用についてご紹介したいと思います。

[caption id="attachment_4313" align="alignnone" width="1024"]storybookの実際の画面 運用中のStorybookの画面[/caption]

これまでのスタイルガイドにおける課題感

該当プロジェクト(印刷のラクスル)ではPostCSSを使用しており、社内でデザインのコンポーネント一覧を共有するために、postcss-styleguideというプラグインを使用していました。

[caption id="attachment_4311" align="alignnone" width="1024"]既存のスタイルガイド 過去運用していたスタイルガイド[/caption]

便利なプラグインではあるのですが、運用していく中で以下のような辛さを抱えていました。

  • 生成に時間がかかる(1分ほど)、hot reload等はできない
  • コンポーネントの検索ができない
  • 文字コンテンツやステータスの変更ができない
  • コードスニペットが見づらい
  • セクション分類や説明を書く部分にあまり柔軟性がない

なぜ Storybook を選んだか

世の中には色々なスタイルガイド向けのOSSが存在します。

そのなかで、なぜStorybookを選んだかというと、上記の辛さをバッチリ解決できると判断したためです。

  • 生成に時間がかかる
    • Storybookならwebpack-dev-serverでhot reloadができて幸せ!
  • 検索ができない
    • Storybookならコンポーネント検索ができる!
  • 文字コンテンツやステータスの変更ができない
    • addon-knobsを使えば、ボタンの文字変更などが簡単にできる!
  • コードスニペットが見づらい
    • source-loaderを使えば、シンタックスハイライトを効かせたりコードを隠したりできる!
  • セクション分類やドキュメントを書く部分にあまり柔軟性がない
    • addon-docsを使えばmarkdownでドキュメントを書いたりできる!セクションや改装での分類も!

加えて、以下の点も採用の後押しとなりました。

  • 静的サイトとして書き出すことができる(build-storybook)
    • 社内サーバーにデプロイして、他チームと共有することが出来る(後述)
  • フロントエンドにおいてデファクトスタンダードになりつつある
    • 情報が多く存在する安心感
  • 開発が積極的に行われている

なぜ Storybook for HTML なのか

導入対象のプロダクトではVue.jsを使用しているのですが、今回Vue向けのStorybookではなくStorybook for HTMLを導入しました。

理由は以下の通りです。

  • 導入対象のプロダクトはSPAではなく、Railsのviewと結合している
    • 各ページのテンプレート毎にVueインスタンスをマウントする形になっている
  • UI等のコンポーネントもButton.vueのように実装されているわけではない
    • 通常のcssファイル群があり、それらを各テンプレートやVueコンポーネントで使う形を取っている

Storybook for HTMLでは文字通り、HTMLで記述したStoryを読み込みます。

export const primary = () => {
  const buttonText = text('Text', 'カートに追加する')

  return `
    <div>
      <button class="ui-btn ui-btn-primary size-large">${ buttonText }</button>
      <button class="ui-btn ui-btn-primary">${ buttonText }</button>
      <button class="ui-btn ui-btn-primary size-small">${ buttonText }</button>
      <button class="ui-btn ui-btn-primary is-disable">${ buttonText }</button>
    </div>
    <div style="margin-top: 16px;">
      <button class="ui-btn ui-btn-primary style-ghost size-large">${ buttonText }</button>
      <button class="ui-btn ui-btn-primary style-ghost">${ buttonText }</button>
      <button class="ui-btn ui-btn-primary style-ghost size-small">${ buttonText }</button>
      <button class="ui-btn ui-btn-primary style-ghost is-disable">${ buttonText }</button>
    </div>
  `
}

上記のように記述すると、このように表示されます。

[caption id="attachment_4320" align="alignnone" width="1024"]button uiの表示 各ボタンが表示され、knobsも反映されています。[/caption]

Storybookを導入する

インストールしたモジュール

実際にインストールしたモジュールは以下になります。

ここはプロジェクトによって必要なものが変わってくると思います。

  • @storybook/addon-docs: Story毎のドキュメントを記述できる。addon-infoの代替となるアドオン。
  • @storybook/addon-knobs: knobを埋め込めるアドオン。
  • @storybook/html: Storybook本体。
  • @storybook/source-loader: Storybook上でコードスニペットを表示するためのloader。

Storybookに関する設定

各種設定は、基本的に公式ドキュメント通りの設定で問題ありませんでした。 [caption id="attachment_4388" align="alignnone" width="300"] Storybookの設定ファイル群[/caption]

.storybook/addons.js

import '@storybook/addon-knobs/register'

.storybook/presets.js

module.exports = [
  '@storybook/addon-docs/html/preset'
]
また、前述したとおり弊社ではPostCSSを使用しているため、Storybook側のwebpackでPostCSSをコンパイルする設定を追加しています。 Storybookでは、ベースのwebpack.configを上書きすることが出来ます。 Custom Webpack Config

.storybook/webpack.config.js

const path = require('path')

const repoRootPath = path.join(__dirname, '../../')
const rootPath = path.join(__dirname, '../')

module.exports = async ({ config }) => {
  config.module.rules = [
    {
      // NOTE: Storybook上でコードスニペットを表示するためのloader設定
      test: /\.stories\.[tj]sx?$/,
      loader: require.resolve('@storybook/source-loader'),
      exclude: /node_modules/,
      enforce: 'pre',
    },
    {
      test: /\.css$/,
      use: [
        // NOTE: プロジェクト側のloaderと同じ設定をする
        {
          loader: 'style-loader'
        },
        {
          loader: 'css-loader',
        },
        {
          loader: 'postcss-loader',
          options: {
            plugins: [
              require('postcss-import')({}),
              require('postcss-color-mod-function')({}),
              require('postcss-cssnext')({}),
            ]
          }
        }
      ],
      exclude: /node_modules/,
    },
    {
      test: /\.(png|svg|jpg|gif)$/,
      use: {
        loader: 'file-loader',
        options: {
          // NOTE: 画像はRailsのアセットから読み込む
          context: path.resolve(repoRootPath, 'app/assets/images/web')
        }
      }
    }
  ]

  config.resolve = {
    modules: [
      path.resolve(repoRootPath, 'app/assets/images/web'),
      'node_modules'
    ]
  }

  return config
}
そして、実際のプロジェクト側で使われているCSファイルをindex.cssにまとめています。

.storybook/index.css

/* Storybook用のimportをまとめる */
@import '../src/stylesheets/hoge.entry.css';
@import '../src/stylesheets/fuga.entry.css';
@import '../src/stylesheets/piyo.entry.css';
@import '../src/stylesheets/hogera.entry.css';
/* 以下importが並んでいる */

Storyの記述

Storyはsrc配下にstoriesディレクトリを作成し、そこにまとめました。 [caption id="attachment_4390" align="alignnone" width="300"] src配下のディレクトリ構造[/caption] StoryファイルはstoriesOf形式ではなく、Storybook 5.2で新しく導入されたComponent Story Format(CSF)で記述しています。 Component Story Format(CSF)
import { withKnobs, text } from '@storybook/addon-knobs'

export default {
  title: 'Design System|UI/Button',
  decorators: [
    withKnobs
  ],
  parameters: {
    notes: `
      ボタンを表示するスタイルです。
    `
  },
}

export const all = () => {
  return `
    <div class="ui-btn ui-btn-primary">primary</div>
    <div class="ui-btn ui-btn-default">default</div>
    <div class="ui-btn ui-btn-accent">accent</div>
  `
}

export const primary = () => {
  const buttonText = text('Text', 'カートに追加する')

  return `
    <div>
      <button class="ui-btn ui-btn-primary size-large">${ buttonText }</button>
      <button class="ui-btn ui-btn-primary">${ buttonText }</button>
      <button class="ui-btn ui-btn-primary size-small">${ buttonText }</button>
      <button class="ui-btn ui-btn-primary is-disable">${ buttonText }</button>
    </div>
    <div style="margin-top: 16px;">
      <button class="ui-btn ui-btn-primary style-ghost size-large">${ buttonText }</button>
      <button class="ui-btn ui-btn-primary style-ghost">${ buttonText }</button>
      <button class="ui-btn ui-btn-primary style-ghost size-small">${ buttonText }</button>
      <button class="ui-btn ui-btn-primary style-ghost is-disable">${ buttonText }</button>
    </div>
  `
}

export const sizeFull = () => {
  return `<button class="ui-btn ui-btn-default size-full">Button</button>`
}

sizeFull.story = {
  parameters: {
    docs: {
      storyDescription: '`width: 100%;` で利用したいときは `size-full` を使う.'
    }
  }
}
詳細な記法やAPIに関しては割愛いたします。公式ドキュメントをご覧ください。

npm scriptを追加する

npm scriptは以下の2つを追加しました。
"scripts": {
  ...,
  "storybook": "start-storybook -p 10001",
  "build-storybook": "build-storybook -c .storybook --quiet"
}
npm run storybookを実行すると、http://localhost:10001でStorybookが立ち上がります。ポートを指定しているのは、他のアプリケーションとの関係です。 npm run build-storybookを実行すると、静的アプリケーションとしてビルドします。こちらについては後述します。

静的アプリケーションとしてビルドする

build-storybookコマンドを実行することで、静的アプリケーションとしてビルドすることが出来ます。

Exporting Storybook as a Static App

今回の移行に際して「社内サーバーにデプロイし、他チームと共有できる」状態にしたかったため、ビルドした静的アプリケーションを、社内からしかアクセス出来ないサーバーに配置しました。 ビルドはアプリケーションがQA環境へデプロイされる時に実行され、同QA環境へアプリケーションが配置されます。 [caption id="attachment_4392" align="aligncenter" width="1024"] 社内環境にデプロイされているStorybook[/caption]

移行にあたって気をつけたこと

一度でやらずフェーズを分ける

はじめから既存の全てのStyleguideを移行しようとすると、時間が掛かり差分も多くなることが予想されました。 そのため、フロントエンドチームとデザイナーチームで相談し、以下のようにフェーズを分けることにしました。 本記事の執筆時現在、フェーズ2まで完了しており、これからフェーズ3に順次取り掛かる予定です。

フェーズ1

対象のプロダクトにStorybookを導入する。導入するだけで、既存からの移行はしない。

フェーズ2

既存のpostcss-styleguideに書き起こされているものを、全てStorybookに移行する。

フェーズ3

新規で実装するデザイン(=既存のデザインではないもの)のStoryを追加していく。

スピード感を持って進める

こういったDX(Developer EXperience)改善系のタスクは、日々増えていくタスクの中でどうしても優先度が下がりがちです。

なあなあで終わってしまわないよう、以下の事柄を意識して進めました。

  • 導入・移行は少人数でやる
    • 最初からたくさんの人を巻き込まない
  • 作業(PR)の粒度を細かくする
    • 一つのPRで色々やりすぎると、永遠にマージされないPRと化す
  • やる・やらないをはっきりとさせる
    • はじめからやりたいこと全てを実現することは不可能

上記3点を意識して進めた結果、非常にスムーズに導入・移行を行うことができました。

[caption id="attachment_4400" align="alignnone" width="724"]Storybook導入時のPR Storybook導入時のPR[/caption]

今後の展望

現状は導入と移行が完了した段階であり、まだ検証フェーズです。

今後は

  • デザイナーチームがデザインを作成する際に実際にStorybookを使用してもらい、フィードバックをもらう
    • 新たなaddon追加や、Storyの粒度を改善
  • 既存のstyleguideからそのまま移行してきた部分の修正・ブラッシュアップ
    • UIの情報やknobの組み込みなど...

を進めていき、デザインシステムとして十分な機能を果たせるよう、引き続き改善を進めていく予定です。

まとめ

今回導入したStorybook for HTMLは、公式ドキュメント以外の情報が非常に少なく、導入例等もほとんど見かけませんでした。

導入してみた感想としては、アプリケーション構造の都合上VueやReactのコンポーネントベースのStorybookを導入しづらい環境においても、Storybookの恩恵を得ることができ、非常に満足しています。

「SPAではないため、コンポーネントベースではないけれど、Storybookを使いたい...」とお考えの方々は、導入を検討してみてはいかがでしょうか。

ラクスルでは、伝統的な業界のデジタル化を推進するフロントエンドエンジニアを募集しています。