株式会社ヘンリー エンジニアブログ

株式会社ヘンリーのエンジニアが技術情報を発信します

StorybookをReact以外のプロジェクトでも使いたい!

はじめまして。今月から株式会社ヘンリーのフロントエンドエンジニアをしている kobayang です。この記事では Storybook を React や Vue などの UI ライブラリを使っていないプロジェクトでも活用できるかも、という話をしていこうと思います。

Storybook

storybook.js.org

もはや説明不要な気がしますが、Storybook は UI を実装する上で便利な、UI プレビューのためのライブラリです。また、プレビューだけでなく、Jest から Story を呼び出すことで簡単にテストが書けたり、Chromatic と連携することで、VRT ができるようになったりと、開発の生産性をあげるだけでなく、プロダクトの安定性を上げるためにも重要なライブラリになっています。

そんな便利な Storybook なのですが、普段当たり前のように使っているがゆえに、Storybook がないと不安になる、ということに気づいてしまいました。その時に触っていたコードは、HTML のテンプレートエンジンで書かれていたため、プレビューできないのは仕方がないかなと思っていたのですが、そこで、あることを思い至りました。

HTML で動いているんだから Storybook で動かないはずはない、と。

Storybook for HTML

ここから本題です。先に結論を言ってしまえば、HTML を Storybook で動かすことができます。新規プロジェクトの場合は、Storybook 初期化時に、 —type html を指定することで、HTML を動かすための最小限のセットアップが行えます。

// pnpm の場合
pnpm dlx storybook@latest init --type html
// npm の場合
npx storybook@latest init --type html

Storybook v7 からは Vite でも動くようになったので、Vite or Webpack のどちらを使用するか、という選択が出ます。今回は Webpack を選択します。

初期化が完了すると、Storybook の設定と、stories フォルダにサンプル用のプロジェクトが追加されます。

Storybook の設定

.babelrc.json
.storybook/main.js
.storybook/preview.js
package.json

サンプルファイル

stories/Button.js
stories/Button.stories.js
stories/Header.js
stories/Header.stories.js
stories/Introduction.mdx
stories/Page.js
stories/Page.stories.js
stories/assets/code-brackets.svg
stories/assets/colors.svg
stories/assets/comments.svg
stories/assets/direction.svg
stories/assets/flow.svg
stories/assets/plugin.svg
stories/assets/repo.svg
stories/assets/stackalt.svg
stories/button.css
stories/header.css
stories/page.css

stories に出力されたサンプルファイルは邪魔なので全部消してしまいます。

さて、もう少し変更されたファイルを見てみましょう。まずは package.json から。

"devDependencies": {
  "@babel/preset-env": "^7.22.5",
  "@storybook/addon-essentials": "^7.0.20",
  "@storybook/addon-interactions": "^7.0.20",
  "@storybook/addon-links": "^7.0.20",
  "@storybook/blocks": "^7.0.20",
  "@storybook/html": "^7.0.20",
  "@storybook/html-webpack5": "^7.0.20",
  "@storybook/testing-library": "^0.0.14-next.2",
  "react": "^18.2.0",
  "react-dom": "^18.2.0",
  "storybook": "^7.0.20"
}

上記のパッケージが追加されました。 HTML を選択しているのに React が追加されているのが気になりますね。

今回、Storybook を動かすのに本当に必要なのは、以下のパッケージになります。

  • storybook
  • @storybook/html
  • @storybook/html-webpack5
  • @babel/preset-env

さて、ここで、 storybook/main.js をみてみると以下のような設定がされています。

framework: {
  name: "@storybook/html-webpack5",
  options: {},
}

Storybook v7 から導入された Framework によって隠蔽されてしまっていますが、どうやら、この設定によって、 HTML を Storybook が起動できるようになっていそうです。

HTML を Story として読み込んでみる

試しに、適当な index.html を作って Story に読み込ませてみましょう。

<h1>test</h1>
import html from "./index.html";

export default { title: "index" };
export const Preview = () => html;

無事に動きました 🎉

ではこれはどうでしょう?Story に直接 HTML を書いてみます。

export default { title: "index" };
export const Preview = () => `<h1>test</h1>`;

これも無事に動きました 🎉

適当に Story を試しただけですが、このことから、ファイルを HTML として出力さえすれば Storybook で動かすことができることが分かりました。

index.html が読み込めているのは、 @storybook/html-webpack5 が HTML を Webpack のローダーとして設定しているためです。ということは、ローダーさえセットできれば、任意のプロジェクトを Storybook で動かすことができるはずです。

実際にやってみましょう。

テンプレートエンジンを Storybook で動かす

UI ライブラリではないプロジェクトを Storybook により動かせるようにしたいと思います。今回は例としてHandlebars というテンプレートエンジンを使います。詳細は割愛しますが、以下のように、テンプレートを記述することができます。

<h1>{{title}}</h1>

Handlebars.js によってテンプレートをコンパイルできます。コンパイルするためにパッケージをプロジェクトに追加します。

pnpm add handlebars

以下のようにコードを書くことで、 Handlebars で書かれたファイルを HTML に変換することができました。

import Handlebars from "handlebars";

const source = `<h1>{{title}}</h1>`;
const template = Handlebars.compile(source);
const html = template({ title: "Test" });
// => <h1>Test</h1>

Story に直接書いてみる

コンパイルすることで HTML を生成することができるところまで分かったので、このコードを Story に記述してみます。

import Handlebars from "handlebars";

const source = `<h1>{{title}}</h1>`;
const template = Handlebars.compile(source);
const html = template({ title: "Test" });

export default { title: "index" };
export const Preview = () => html;

動きそうですが、これは残念ながらエラーになってしまいました。

handlebars モジュールを読み込む際に、 Module not found: Error: Can't resolve 'fs' というエラーになります。本質的なエラーではないですが、Storybook を起動するために修正する必要があります。fs module を何らか mock してあげれば回避できそうです。

ググって見つかった stackoverflow の回答に従って、以下のように Storybook の main.jsconfigwebpackFinal を記述します。

webpackFinal: async (config) => {
  config.resolve.fallback.fs = false;
  return config;
},

これで、Storybook が起動しました 🎉

ファイルから Import する(その1)

上記の直接 Story に書いていくやり方は、さすがに古典的すぎるので、せめてファイルからインポートできるようにしたいです。というよりファイルからインポートできないと、プレビューとしての意義を果たせません。察しの良い方はすでにお分かりの通り、この辺りから Webpack のローダーをカスタマイズしていきます。

先ほど修正した webpackFinal のフィールドがありますが、ここから Storybook で動かす Webpack の設定を変更できるようになっています。

とりあえずファイルから読み込めるようにするために、raw-loader を使ってみましょう。 raw-loader は名前の通り、ファイルをテキストとして読み込むローダーです。

まず、 raw-loader をパッケージに追加します。

pnpm add -D raw-loader

.storybook/main.jswebpackFinal に、 .handlebars 拡張子のファイルに対して raw-loader を適用するようにセットしてみましょう。

webpackFinal: async (config) => {
  config.module?.rules?.push({
    test: /\.handlebars$/,
    loader: "raw-loader",
  });
  config.resolve.fallback.fs = false;
  return config;
},

次に、 index.handlebars を作成し、そこにテンプレートを記述します。

<h1>{{title}}</h1>

これで準備が整いました。先ほど書いた source の部分を index.handlebars から import するように変更してみましょう。

import Handlebars from "handlebars";
import source from "./index.handlebars";

const template = Handlebars.compile(source);
const html = template({ title: "Test" });

export default { title: "index" };
export const Preview = () => html;

動きました 🎉

ファイルから Handlebars で書かれたテンプレートを Story に import できるようになりました。ここまでで必要最小限のプレビュー機能が達成できたことになります。

しかし、テンプレートが一つのファイルで収まることは稀でしょう。実際には、テンプレートは別のテンプレートに依存します。Handlebars においても、テンプレートから別のテンプレートを呼び出すことが可能です。

以下のように記述します。

<h1>{{title}}</h1>
{{>subtitle}}

subtitle.handlebars

<h2>{{subtitle}}</h2>

上記で記述した Story だと、 subtitle を読み込むことができないので、以下のようなエラーになってしまいます。

これを解決するには、 registerPartial を使って、コンパイルの前に、依存する Partial Template を登録しておく必要があります。

import Handlebars from "handlebars";
import source from "./index.handlebars";
import subtitle from "./subtitle.handlebars";

Handlebars.registerPartial("subtitle", subtitle);

const template = Handlebars.compile(source);
const html = template({ title: "Test", subtitle: "Sub" });

export default { title: "index" };
export const Preview = () => html;

これで、動くには動きますが、依存ファイルが増えるたびに Story にそれを追加しなければいけないのは何とも筋が悪いです。次はこれを何とかしましょう。

ファイルから import する(その2)

Handlebars には幸いにも Webpack のローダーが存在します。

github.com

このローダーを設定することで、先ほどの依存をいい感じに解決してくれるようになります。

設定してみましょう。

まずは、handlebars-loader をパッケージに追加します。

pnpm add -D handlebars-loader

Storybook の Webpack のローダーを変更します。

config.module?.rules?.push({
  test: /\.handlebars$/,
-  loader: "raw-loader",
+  loader: "handlebars-loader",
});

変更はこれだけです。この設定により、 Handlebars で書かれたテンプレートがローダーによって解決されるようになりました。

以下のような Story を記述すれば先ほどのテンプレートも動くようになります。

import template from "./index.handlebars";

const html = template({ title: "Test", subtitle: "Sub" });

export default { title: "index" };
export const Preview = () => html;

無事に動きました 🎉  これで、Handlebars によって記述された HTML をいい感じに Storybook で確認できるようになりますね!

まとめ

HTML を Storybook で動かす方法について簡単に紹介しました。また、Handlebars というテンプレートを例にとって、コンパイル可能であれば raw-loader を使ってテンプレートファイルをプレビューできること、さらに Webpack のローダーがあれば、より良い感じに Story を記述できることを示しました。

ぱっと思いつく適用例としては、 erbhaml の Webpack ローダーが存在していそうなので、Rails のプロジェクトにも Storybook が適用できるかもしれません。(注: 試してないので分かりません)

github.com

github.com

今回例に出した Handlebars は、Vite 用のローダーがなかったため、Vite では起動できませんでしたが、設定さえしてあげれば Vite でも同様なことができるはずです。

また、実際のプロジェクトは、より複雑だったり、依存するファイルがプログラムによって制御されていたりと、ローダーを直接使うことが難しかったりするかもしれません。

しかし、Webpack をいい感じに設定して HTML さえ出力できれば Storybook を使えることが分かってもらえたと思うので、もし、Storybook が入ってないプロジェクトがある場合には、ぜひ一度導入をチャレンジしてみて欲しいなと思います。

皆様がより良い Storybook ライフを送れますように。敬具。

参考

今回説明で使った成果物は以下のリポジトリにあります。

github.com