はじめてのStorybookアドオン開発体験記
こんにちは。 先日、ちょっとしたきっかけでStorybookのアドオンをはじめて開発しました。 本記事では、そのStorybookのアドオン開発の体験を共有したいと思います。
開発したアドオン
storybook-addon-range-controls というアドオンを開発しました。 npmで公開しています。
このアドオンは、端的にいうと Storybook上で、コンポーネントの文字列・数値・配列の引数を、スライダーでデータ増減できるアドオン です。 作ろうと思ったきっかけは、StorybookのArgTypesでrangeの値を使って、データ増減に伴うデザイン崩れをチェック していたのですが、 配列のスライダーをするのに少しだけコードを書く手間があり、もっと簡単にできないかというのがきっかけです。
デモは、以下のリンクで公開していますので、気になる方はぜひ触ってみてください。
このアドオンを作るために、何をしたのかを振り返ります。
実装したコードベースは、以下で公開していますので、よければご参考ください。
アドオンキット
まず、Storybookのアドオンってどうやって作るのか、作った経験がない私にはわからなかったので、 以下のStorybookのドキュメントを読みました。
読んでいると、以下のアドオンキットがあることに辿り着きました。
これは、Storybookのアドオン開発に必要なものが最低限揃っていて、最初のとっかかりにはちょうどよかったです。
アドオンの種類
アドオンキットを読んでいると、以下の3つの種類のアドオン開発があることを知りました。
- パネル
- ツール
- タブ
3つの種類についての説明は、以下の公式ページがわかりやすいです。
今回、私がなんとなく想像していたのはパネルだったので、今回はパネルを使ってみることにしました。
パネル開発
アドオンキットでは、開発するアドオンを組み込んだStorybookを起動できる(npm run start)ようになっていて、起動させたStorybook上でアドオンの実装を進めていきます。
アドオン開発には、さまざまなAPIが用意されており、公式のドキュメントが参考になります。
例えば、表示しているStoryのparamtersやargsを取得するhooksなどがあります。 以下は、私が利用したhooksのコード例です。
import React, { memo } from "react";
import type { RangeControlsParameters } from "src/types";
import { useParameter, useArgs } from "storybook/manager-api";
import { useTheme } from "storybook/theming";
import { KEY } from "../../constants";
type Props = {
  active: boolean;
};
export const Panel = memo((props: Props) => {
  const config = useParameter<RangeControlsParameters>(KEY, {});
  const theme = useTheme();
  const [args, updateArgs] = useArgs();
  return (<>...</>);
});また、パネルのUI開発には、以下にあるように、コンポーネントやスタイルなどが用意されています。
- コンポーネント一覧
- スタイル一覧
上記のコンポーネントやスタイルにあるタイポグラフィやカラー、スペーシングなどを使うだけなので、 ゼロから作り上げるということはありません。また、ダークテーマの対応もできます。
以下は、私が実装したパネルのコード例です。 emotionを使ったことがある人なら、理解しやすいコードかと思います。
// PropControl.styles.ts
import { Badge } from "storybook/internal/components";
import { styled, typography } from "storybook/theming";
export const StyledDetails = styled.details`
  border: 1px solid
    ${({ theme }) =>
      theme.base === "dark" ? theme.color.dark : theme.color.border};
  margin-bottom: ${({ theme }) => theme.layoutMargin}px;
  background: ${({ theme }) => theme.color.lightest};
`;
export const StyledSummary = styled.summary`
  padding: ${({ theme }) => theme.layoutMargin}px;
  cursor: pointer;
  font-weight: ${typography.weight.bold};
  font-size: ${typography.size.s2}px;
  display: flex;
  align-items: center;
  justify-content: flex-start;
  color: ${({ theme }) => theme.color.defaultText};
  background: ${({ theme }) => theme.background.content};
  &:hover {
    background: ${({ theme }) =>
      theme.base === "dark" ? theme.color.darker : theme.color.lighter};
  }
`;
export const SummaryBadge = styled(Badge)`
  margin-left: auto;
`;リリース
npmへの公開は、アドオンキットにあるGitHub Actionsの release.yml をほとんどそのまま使いました。 必要なAPIトークンは、READMEに書いてあるので、その通りにやれば大丈夫です。
デモ
アドオンの使い方を示すためにデモが欲しかったので、Chromaticのサービスを使用しました。
以下のURLにデモを公開しています。
困ったこと
ホットリロードが効かない
以下のaddon-kitのissueにもあるように、ホットリロードがうまく動かないことがありました。
そのため、開発中はコード修正してリロードを繰り返していました。ちょっと面倒でしたね。
パラメータに関数を渡せない
StorybookのParametersに関数を設定したとしても、useParameterでは関数が取得できません。 JSONシリアライズの関係かと思います。
少し強引なやり方かもしれませんが、プレビューのデコレータからだと、パラメータに関数がまだ残っているため、そのデータをパネルへ渡すようにしました。
以下のように、プレビューのデコレータで、関数入りパラメータを独自シリアライズをし、パネルではそのデータを取得するようにしました。
// withGlobals.ts
import type {
  Renderer,
  StoryContext,
  PartialStoryFn as StoryFunction,
} from "storybook/internal/types";
import { useEffect, useChannel } from "storybook/preview-api";
import { EVENTS, KEY } from "./constants";
import { serializeFunctions } from "./utils/serialize";
export const withGlobals = (
  StoryFn: StoryFunction<Renderer>,
  context: StoryContext<Renderer>,
) => {
  const emit = useChannel({});
  useEffect(() => {
    const params = context.parameters?.[KEY];
    if (params) {
      const serialized = JSON.stringify(serializeFunctions(params));
      emit(EVENTS.PARAMETERS_SYNC, serialized);
    }
  }, [context.id, context.parameters]);
  return StoryFn();
};// preview.ts
import type { ProjectAnnotations, Renderer } from "storybook/internal/types";
import { KEY } from "./constants";
import { withGlobals } from "./withGlobals";
const preview: ProjectAnnotations<Renderer> = {
  decorators: [withGlobals],
  initialGlobals: {
    [KEY]: false,
  },
};
export default preview;// Panel.tsx
import React, { memo } from "react";
import type { RangeControlsParameters } from "src/types";
import { useChannel } from "storybook/manager-api";
import { EVENTS } from "../../constants";
import { reviveFunctions } from "../../utils/serialize";
type Props = {
  active: boolean;
};
export const Panel = memo((props: Props) => {
  useChannel({
    [EVENTS.PARAMETERS_SYNC]: (serialized: string) => {
      try {
        const parsed = JSON.parse(serialized);
        const revived = reviveFunctions<RangeControlsParameters>(parsed);
        // Do something with the revived parameters
      } catch (e) {
        console.error("Failed to deserialize parameters", e);
      }
    },
  });
  return (<>...</>);
});最後に
Storybookのアドオン開発って、どれぐらい難しいのかなと思ったのですが、 アドオンキットのおかげで、思ったよりも簡単に開発できました。 コンポーネントやスタイルも整っているし、リリースも簡単にできましたし、素晴らしいエコシステムだなと思います。