はじめての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のアドオン開発って、どれぐらい難しいのかなと思ったのですが、 アドオンキットのおかげで、思ったよりも簡単に開発できました。 コンポーネントやスタイルも整っているし、リリースも簡単にできましたし、素晴らしいエコシステムだなと思います。