Storybook アドオンを作りたい (Vue 3)

色々ハマりすぎて先に進めなくなったのと、本来やりたかったことは別にアドオン作らなくてもカスタムデコレータ使うだけで済む気がしてきたのでここで断念。
また機会があれば挑戦したいl。

スクラップの概要
- Vue 3 + TypeScript + Storybook 7 の構成で、Storybook のアドオンを作りたい
- Storybook 自体にはそれなりに慣れ親しんでいるし、アドオンを柔軟に導入することもできるけど、作った経験はない
- アドオン開発の知識がゼロの状態から、公式ドキュメントを参考に形にしていくスクラップ
作りたいもの
-
storybook-addon-pinia
みたいなもの - Vue インスタンスに対する pinia の注入を裏でやってくれる
- pinia の Initial State をアドオン上で定義できる
- アドオンパネルから Initial State の内容を書き換えられる
- 各ストーリーの parameters あたりから、ストーリー単位で State を書き換えられる

ドキュメントのこのページを見れば全部解決できると思ってる。でなきゃドキュメントのほうが不十分だ。

ドキュメントのチュートリアルでは自作のアドオンで以下が出来るらしい
- Storybook にアドオン専用のパネルを追加
- ストーリーからカスタムパラメータを取り出す
- パラメータをパネルに表示する
うーん、作りたいものの pinia の Initial State をアドオン上で定義できる
が満たせるかまだ怪しいけど、他は行けそうだし、まずはそのへんから理解してこうか。

アドオンキッドがあるらしい。これをフォークして作れば楽みたいだけど、チュートリアルではゼロベースで作るみたい。

新規パッケージを作る。
$ mkdir my-storybook-addon
$ yarn init
yarn init v1.22.19
question name (shingo.sasaki): my-addon
question version (1.0.0):
question description:
question entry point (index.js): dist/preset.js
question repository url:
question author:
question license (MIT):
question private:
success Saved package.json
✨ Done in 35.06s.
ちょっと package.json
を調整
{
"name": "my-addon",
"version": "1.0.0",
"main": "dist/preset.js",
"files": ["dist/**/*", "README.md", "*.js"],
"keywords": ["storybook", "addons"],
"license": "MIT"
}
プロジェクトはだいたいこういうディレクトリ構成が良いらしい。
- dist: トランスパイル後の成果物
- src: アドオンのソースコード
- .babelrc.js: Babel の設定
- preset.js: アドオンのエントリーポイント
- package.json
- README.md

React と Babel は必須だから入れろとのこと。まぁ TypeScript はいらないかな多分。
$ yarn add -D react react-dom @babel/cli

テスト用に Storybook 自体も入れる。そりゃ必要だろうけど。普通に入れて良いのかな。まぁチュートリアルに従おう。
$ npx storybook@latest init
バンドラは Vite で良いか別に。

Babel の設定。普通に React 向けのアドオンを作る流れになりそうだ。
export default {
presets: ['@babel/preset-env', '@babel/preset-react'],
};

ビルドコマンドの追加。ここでいうビルドはアドオン自体のビルドなので babel を使用する。
もっと良いビルド方法ありそうなもんだけどここも従う。
{
"scripts": {
"build": "babel ./src --out-dir ./dist"
}
}

アドオンのエントリーポイントである preset.js を実装。
function managerEntries(entry = []) {
return [...entry, require.resolve("./register")];
}
export default { managerEntries };
なにを言ってるのかはまったくわからんが、アドオンなんだから Storybook 側がこれを呼び出して良い感じにアドオンをインストールしてくれるんだろうな。

カスタムパネルを追加するコードを作成。
import React from "react";
import { addons, types } from "@storybook/manager-api";
import { AddonPanel } from "@storybook/components";
const ADDON_ID = "myaddon";
const PANEL_ID = `${ADDON_ID}/panel`;
const MyPanel = () => <div>MyAddon</div>;
addons.register(ADDON_ID, (api) => {
addons.add(PANEL_ID, {
type: types.PANEL,
title: "My Addon",
render: ({ active, key }) => (
<AddonPanel active={active} key={key}>
<MyPanel />
</AddonPanel>
),
});
});
おそらくは src/
以下にある JS ファイルはすべてインポートされて、トップレベルのコードはアドオンを組み込んた際に実行されるのかな。
コードからある程度判断はできる
-
ADDON_ID
みたいな Storybook 全体でユニークになるようなキーが必要 -
addons.register
が実際にアドオンを適用するメソッド -
addons.add
が、アドオンで何らかの Storybook 拡張を行う - ここでは
type: types.PANEL
ということでパネルの追加っぽい - パネルをどのようにレンダリングするかは普通に React(JSX) で書ける
- これ Vue 向けのアドオンだろうと、アドオン自体の実装は React でいいのかな

そしてテスト用の Storybook でアドオンを有効化する。
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-interactions",
"../src/preset.js",
],
// 以下略

本題からズレるけど Vite のインストール必要だった。 peerDependencies だったか。
$ yarn add -D vite

preset.js で読み込むファイルも、 manager.js
しか作ってないんだからそっち指定する必要あった。それはそうだよなぁ。
function managerEntries(entry = []) {
return [...entry, require.resolve("./manager.js")];
}
export default { managerEntries };

$ yarn storybook
パネルが出た!

ストーリーからパラメータを受け取って、パネル内に表示するサンプル。
const PARAM_KEY = "myAddon";
const MyPanel = () => {
const value = useParameter(PARAM_KEY, null);
const item = value ? value.data : "No story parameter defined";
return <div>{item}</div>;
};
useParameter
はアドオン用の React Hooks で、レンダーされたコンポーネントのパラメータが変わったときに MyPanel を再レンダリングできるようになるっぽい。

スキャフォルドされててた適当なストーリーでパラメータを渡してみる。
export const Primary = {
parameters: {
myAddon: {
data: "this is Primary",
},
},
args: {
primary: true,
label: "Button",
},
};
export const Secondary = {
parameters: {
myAddon: {
data: "this is Secondary",
},
},
args: {
label: "Button",
},
};

おー楽しい。

あとはパッケージとして公開する場合の注意点がいくつか。

おーっと、もっとちゃんとしたアドオン開発のチュートリアルがあったな。
こっちは Addon Kit 使ってるって違いあるじゃん。
Addon Kit 試しつつざっくりやってく。

Addon Kit の README もざっくり読んでおく
特徴
- Live-Editing 機能
- React/JSX サポート
- Bable トランスパイル
- TypeScript サポート
- プラグインメタデータ
- リリース管理機能
- ボイラーテンプレートとサンプルコード
- ESM サポート
なんだかしらんがすごそうだ。
開発用スクリプト
-
yarn start
: 監視モードで Babel を動かして Storybook を起動する -
yarn build
: アドオンコードをパッケージビルドする
サンプルコード
-
src
には最初から、React/JSX で書かれたサンプルアドオンが実装されている- ツールバーの拡張
- パネルの拡張
- タブの拡張
- ほかにも、グローバルステート管理のためのサンプルコードや、パラメータをストーリーから受け取るサンプルコードが含まれている
メタデータ
メタデータは npm で公開したアドオンを、公式のアドオンカタログに掲載する際のメタデータのこと
これを良い感じに作成してくれる。
リリース管理
auto
を用いたリリース自動化の仕組みが用意されている
GitHub 及び npm へのプッシュを自動化するので、NPM_TOKEN
GH_TOKEN が必要(
.env` で定義)
yarn release
で実行できるが、これも自動化する GitHub Actions も用意されている。
リリースに含まれるアクション
- アドオンのビルド
- version の更新
- GitHub / npm にリリースをプッシュ
- ChangeLog を作成して GitHub にプッシュ

リリース管理は慣れるまで怖いから手動でやりそう。

明日以降やること
- AddonKit でスキャフォルドされた現状をざっと確認する
- Storybook 側を React でなく Vue 3 で書き直す
- Pinia を導入する
- スキャフォルドされたアドオンのコードをまるっと削除する
そこから本格的にアドオン開発に着手する

諸々あって5日ぐらい空いてしまったけどちゃんと続けてく。

AddonKit で生成されたコードの構成、役割をざーっと確認する。
package.json
諸々の手順を踏んで自動生成された package.json の内容メモ
- パッケージの公開を前提としたパッケージメタデータが定義されてる
- name
- version
- description
- keywords
- repository
- author
- licencse
- publicConfig
- commonjs/esm どちらでも使えるように
exports
フィールドが定義されている- exports
- main
- module
- types
- files
- Storybook のアドオンカタログへの公開を前提とした、アドオンメタデータが定義されている
- storybook
- displayName
- supportedFrameworks
- icon
- storybook
dependencies はナシ。代わりに peerDependencies に、アドオンを動かすための各種パッケージが入ってる。利用側で複数のアドオンを使用する場合に余計にパッケージがインストールされないようになってる。
devDependencies では最初に指定した通り、Vite を使って React 用の Storybook を動かすためのパッケージ群が入ってきてる。
おそらく開発に要するユーティリティと思われるパッケージが多数。これは必要に応じて調べたりする。
npm スクリプトが色々定義されてる。定義から内容を予想すると以下の感じ。
-
clean
: ビルド成果物を削除 -
prebuild
: おそらくビルド前に呼び出されるコマンドで、clean
を実行するだけ -
build
:tsup
を使ったビルド- tsup は TypeScript のバンドラーらしい
-
build:watch
: 監視付きビルド -
test
: 定義なし。アドオンのテストって大変そう。 -
start
: アドオンの監視ビルドをしながら Storybook を起動する -
prerelease
: リリース前の各種チェックをしてくれるスクリプトの実行? -
release
:auto
を用いた自動リリースの実行 -
eject-ts
: TSで書かれたコードをJSに変換する -
storybook
: いわずもがな -
build-storybook
: いわずもがな
TS で書くことは間違い無さそうだし、eject-ts
は不要そう。
$ rm scripts/eject-ts
npm scripts と、あと README 内の記載も削除
scripts/welcome.js
元々は postinstall で実行された、最初にスキャフォルドするためのスクリプト。スキャフォルド完了後は自動で postinstall からも取り除かれる仕組みがあったみたい。
これも削除で良さそう。ここでしか使ってないパッケージである prompts も削除OK
$ rm scripts/welcome.js
$ yarn remove prompts
.github/workflows/release.yml
GitHub にコードプッシュした際に自動でリリース作業が行われるワークフローがすでにある。
プッシュごとに yarn release
が実行されそう。 GITHUB_TOKEN
と NPM_TOKEN
が設定されてなきゃ何も起こらないけど、プッシュするたびに無駄にワークフローが走って失敗されるの嫌なので当面は実行されないようにしよう。
全部コメントアウトしてもファイルがある時点でエラーになるっぽい。まぁ一旦削除しちゃうか。必要になったらまたコミットログ見るなり、AddonKid のレポ見れば良いし。
$ rm -rf .github
.prettierrc / .prettierignore
Pretiter の設定はデフォルトじゃなくて自分好みにさせてもらおうかな。個別に設定ファイル作るより package.json に含んじゃいたい。
"prettier": {
"semi": false,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 120,
"arrowParens": "avoid"
}
$ rm .prettierrc .prettierignore
一応全ファイルに再実行
$ yarn prettier . --write
README.md
当然 AddonKit 側の README が複製されてるのでガッツリ削除。AddonKit について知りたい時はそっちをみるよ。
tsup.config.ts
tsup 自体はまだ良く知らないけど、アドオンをビルドするために使用してるツール。
src/index.ts
src/preview.ts
src/manager.ts
をエントリーに、cjs/esm 両形式でビルドして、 dist
に出力してくれそう。
yarn build
で dist に生成される。
index
にはあんまり意味なくて、 manager
preview
それぞれが Storybook に読み込まれるみたい。
.storybook
ローカル開発用の Storybook の設定。
基本は React + Vite の構成でシンプルなやつだけど、dist
にあるビルド済みの自身のアドオンを使用するように拡張されてる。
yarn start
することで、アドオンのビルドと、それを使った Storybook の起動ができる。
src
ちょっとややこしいが、ここにはアドオン自体の実装と、それを開発環境で動かす Storyファイル及びコンポーネントが一緒になって入ってる。というかターゲットを Vue にしてるんだから、ここで使用されるストーリーも Vue になるべきでは。

スキャフォルドされたアドオンの内容を確認する
アドオンは大きく manager
と preview
に分かれてる。
manager
やってること
- ツールバーにメニューを追加
- パネルを追加
- タブを追加
preview
やってること
- デコレーターの追加
- グローバルパラメーターの追加
アドオンで出来ることの一通りのミニマムパターンが定義されてるみたい。
これを参考にするとはいえ、基本的には使わないコードが殆どなので削除していこう

それはそれとして、アドオンのコードを書き換えても Storybook 側がホットリロードされないんだけどそういうもの…?
普通に Issue にあがってたのでおまかんじゃないっぽい

描画するストーリーを React から Vue に変更する
今回作りたいのは pinia 用のアドオンなので、 pinia を動かすための Vue が必須になる。現状 React で実装されているストーリーファイルを、Vue に変える必要が出てくる。
必要そうなパッケージ入れる
$ yarn add -D @storybook/vue3-vite vue vue-tsc @vitejs/plugin-vue
設定ファイルを書き換える
import type { StorybookConfig } from '@storybook/vue3-vite'
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'./local-preset.js'
],
framework: {
name: '@storybook/vue3-vite',
options: {}
},
docs: {
autodocs: 'tag'
}
}
export default config
vite 設定も変える
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()]
})
適当にコンポーネント作る。
で作ってるやつを流用しようかな。とりあえずボタンコンポーネント
<script setup lang="ts">
const props = defineProps<{
label: string
variant: 'primary' | 'secondary'
size: 'small' | 'medium' | 'large'
}>()
const emits = defineEmits<{
(e: 'click'): void
}>()
</script>
<template>
<button @click="emits('click')" :class="[props.variant, props.size]">
{{ props.label }}
</button>
</template>
<style scoped>
button {
font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
font-weight: 700;
border: 0;
border-radius: 3em;
cursor: pointer;
display: inline-block;
line-height: 1;
color: white;
padding: 1rem;
}
button.primary {
background-color: #1ea7fd;
}
button.secondary {
background-color: #ff7e00;
}
button.large {
font-size: 1.25rem;
padding: 1.25rem 1.5rem;
}
button.medium {
font-size: 1rem;
padding: 1rem 1.25rem;
}
button.small {
font-size: 0.875rem;
padding: 0.75rem 1rem;
}
</style>
そのストーリー
import MyButton from './MyButton.vue'
import type { Meta, StoryObj } from '@storybook/vue3'
type Story = StoryObj<typeof MyButton>
const meta: Meta<typeof MyButton> = {
title: 'MyButton',
component: MyButton,
render: args => ({
components: { MyButton },
setup() {
return { args }
},
template: "<MyButton v-bind='args' />"
})
}
export const Default: Story = {
args: {
label: 'Button',
variant: 'primary',
size: 'medium'
}
}
export default meta

スキャフォルドされたアドオンの実装を最小限にする
それぞれ、ただテキストをベタ書きするだけの最小構成に。
import { addons, types } from '@storybook/manager-api'
import { ADDON_ID, TOOL_ID, PANEL_ID, TAB_ID } from './constants'
addons.register(ADDON_ID, () => {
addons.add(TOOL_ID, {
type: types.TOOL,
title: 'My addon',
match: ({ viewMode }) => !!(viewMode && viewMode.match(/^(story|docs)$/)),
render: () => 'My addon tool'
})
addons.add(PANEL_ID, {
type: types.PANEL,
title: 'My addon',
match: ({ viewMode }) => viewMode === 'story',
render: () => 'My addon panel'
})
addons.add(TAB_ID, {
type: types.TAB,
title: 'My addon',
route: ({ storyId }) => `/myaddon/${storyId}`,
match: ({ viewMode }) => viewMode === 'myaddon',
render: () => 'My addon tab'
})
})
デコレーターとかグローバルパラメーターのファイルも削除。
これで devDependencies から React を消せる。 (react / react-dom 自体は Storybook のビルドに必要)
$ yarn remove @storybook/react @storybook/react-vite @types/react @vitejs/plugin-react
$ yarn start
で、Vue コンポーネントのストーリーが描画されつつ、最小限のアドオンが動くことを確認。
ようやくスタート地点って感じ。

pinia を導入する
pinia 用のアドオンなので、まずは普通に pinia を導入する。
ここでは pinia を使って、ユーザーの認証周りと、TODO管理を扱うステートを作成するってイメージで実装する。
$ yarn add -D pinia
pinia は利用側でも必要なので peerDependencies にも入れておくと良いのかな。
"pinia": "^2.0.0"
開発用のコードは src
直下じゃなくて src/dev
にしようかな。
src/dev/components
src/dev/stores
src/dev/stories
みたいな。
ストアは雑にこんな感じ。
import { defineStore } from 'pinia'
type CurrentUser = {
id: string
name: string
}
export const useCurrentUserStore = defineStore('currentUser', {
state: () => ({
currentUser: null as CurrentUser | null
}),
getters: {
displayName() {
return this.currentUser?.name ?? 'Guest'
}
},
actions: {
async signIn(name: string, password: string) {
const response = await fetch('/api/sign-in', {
method: 'POST',
body: JSON.stringify({ name, password })
})
if (response.ok) {
const user = await response.json()
this.currentUser = {
id: user.id,
name: user.name
}
}
},
async signOut() {
await fetch('/api/sign-out', {
method: 'POST'
})
this.currentUser = null
}
}
})
import { defineStore } from 'pinia'
type Todo = {
id: string
text: string
completed: boolean
}
export const useTodoStore = defineStore('todo', {
state: () => ({
todoList: [] as Todo[]
}),
getters: {
completedTodoList(state) {
return state.todoList.filter(todo => todo.completed)
},
uncompletedTodoList(state) {
return state.todoList.filter(todo => !todo.completed)
}
},
actions: {
async fetchTodoList() {
const response = await fetch('/api/todo-list')
if (response.ok) {
this.todoList = await response.json()
}
},
async syncTodoList() {
await fetch('/api/todo-list', {
method: 'POST',
body: JSON.stringify(this.todoList)
})
}
}
})

pinia に依存した Vue コンポーネントを作成する
こんな構造かな
- MyPage.vue
- TodoListHeader.vue
- TodoList.vue
- Todo.vue
TodoListHeader と TodoList が pinia にアクセスする。Todo は props を受け取るだけ。
ここから2時間ぐらい、色々書いては試してを続けて、ようやく満足の行く状態になった。

pinia のセットアップをアドオン経由で行うようにする
ここからが本番。
現状、 .storybook/preview.ts
にて、 pinia ストア及び初期値の設定を行っているが、これをアドオン経由で行うようにしたい。
setup(app => {
app.use(
createTestingPinia({
createSpy: fn => args => {
fn?.(args)
},
stubActions: false,
initialState: {
currentUser: {
currentUser: {
id: '1',
name: 'sasaki'
}
},
todo: {
todoList: [
{ id: '1', text: 'Task 1', completed: false },
{ id: '2', text: 'Task 2', completed: false },
{ id: '3', text: 'Task 3', completed: false },
{ id: '4', text: 'Task 4', completed: true },
{ id: '5', text: 'Task 5', completed: true },
{ id: '6', text: 'Task 6', completed: true }
]
}
}
})
)
})
おそらくはアドオン側の preview.tsx
がマージされるように作られているはずなので、そっちに移動するだけで良さそう。
withPiniaDecorator
の形で切り出した。
export const withPiniaDecorator = makeDecorator({
name: 'withPinia',
parameterName: 'pinia',
wrapper: (getStory, context, { parameters }) => {
setup(app => {
console.log(app)
app.use(
createTestingPinia({
createSpy: fn => args => {
fn?.(args)
},
stubActions: false,
initialState: {
currentUser: {
currentUser: {
id: '1',
name: 'sasaki'
}
},
todo: {
todoList: [
{ id: '1', text: 'Task 1', completed: false },
{ id: '2', text: 'Task 2', completed: false },
{ id: '3', text: 'Task 3', completed: false },
{ id: '4', text: 'Task 4', completed: true },
{ id: '5', text: 'Task 5', completed: true },
{ id: '6', text: 'Task 6', completed: true }
]
}
}
})
)
})
return getStory(context)
}
})

アドオンパネルに初期ステートを表示する
デコレーターでは testingPinia のオプションを受け取るようにし、その際に state を保持しておく。
export const withPiniaDecorator = (testingOptions: Partial<TestingOptions> = {}) => {
const state = testingOptions.initialState || {}
return makeDecorator({
name: 'withPinia',
parameterName: 'pinia',
wrapper: (getStory, context, { parameters }) => {
setup(app => {
app.use(
createTestingPinia({
createSpy: fn => args => {
fn?.(args)
},
stubActions: false,
initialState: {},
...testingOptions
})
)
})
return getStory(context)
}
})()
}
パネルを作ろう。今回パネル以外 (ツールバー、タブ) も削除
import { addons, types } from '@storybook/manager-api'
import { ADDON_ID, PANEL_ID } from './constants'
addons.register(ADDON_ID, () => {
addons.add(PANEL_ID, {
type: types.PANEL,
title: 'pinia/state',
match: ({ viewMode }) => viewMode === 'story',
render: () => 'pinia state panel'
})
})
定数もちゃんと定義する
export const ADDON_ID = 'storybook/pinia'
export const PANEL_ID = `${ADDON_ID}/state`
export const PARAM_KEY = `piniaStateParameter`

次回、どうにかしてデコレーターに渡したステートをパネル上に表示する

useGlobals 使って Globals に State をまるごと突っ込んで Storybook 全体で共有すれば行ける気がする。
デコレータ側で注入
const [_, updateGlobals] = useGlobals()
const initialState = testingOptions.initialState || {}
useEffect(() => {
updateGlobals({ [PARAM_KEY]: initialState })
}, [])
パネル側で取得して描画
addons.register(ADDON_ID, api => {
addons.add(PANEL_ID, {
type: types.PANEL,
title: 'Pinia/state',
match: ({ viewMode }) => viewMode === 'story',
render: context => {
const [globals] = useGlobals()
return JSON.stringify(globals[PARAM_KEY])
}
})
})
イケたにはイケたけど、こんなやり方で良いのかわからない。
あとURLにシリアライズされたステートがまるごと乗っかるから、サイズによっては大変そう。
useAddonState
のほうが向いてそう。