Nihonbashi.js #9に参加してきた
Nihonbashi.js #9 - connpass
2024年11月1日開催のNihonbashi.js #9に参加してきました。
Web Developer Conference以来、2度目のサイボウズさんのオフィスでした。前回行っておいたおかげで迷わずにつけた … …。
LT会→懇親会の流れですすみ、懇親会ではStreamについて色々と教えていただいたり、ほかの会社さんの開発組織事情を聞くことができ、おもしろかったです。
今回はLT枠で申し込みさせていただいたので、5分間Storybookについて話してきた内容の詳細をこの記事では記録しておきます。
LT:いまさらのStorybook
タイトルの「いまさらの」は「みんなもう使っているだろうけど、こっちは業務で使ってひと月だから … …」というエクスキューズです。実際話してみると、全員が全員そういうわけでもなかった模様なので、期待値調整は難しいですね。
内容としてはスライドの通り2部構成になっています。
- Storyで使うTypeScriptの型定義
- Storybook自体がどのように動くか
スライドの通りではありますが、少しだけ文章で補足します。
Storyで使うTypeScriptの型定義を読み解く
Storyの型定義
Storyのファイルを書くと、基本的には次のような形になります。
ここにはMeta
とStoryObj
の2つの型がありますが、これらはいずれもStorybookが準拠するComponent Story Format 3に必要な属性を規定する型になっています。
CSF3に必要な属性を定義するBaseAnnotationsインターフェイス
型定義を追っていくと、いずれも最終的にはBaseAnnotations
というインターフェイスをimplementしていることがわかります。
https://github.com/ComponentDriven/csf/blob/v0.1.11/src/story.ts#L339
BaseAnnotationsはStorybook側のリポジトリではなく、csfのリポジトリで定義されています。中身としてはいつもStoryを定義している際に使っている属性を含んだ型となっているようです。
Componentレベル・Storyレベルでの設定を実現するためのMeta型、StoryObj型
MetaとStoryObjは定義する属性としてはほぼ同じですが、設定の粒度が異なります。前者はComponentレベル、後者はStoryレベルの設定を可能にします。
具体的な例として、コンポーネントに渡すargsを実例に、型による設定レベルの切り分けの実際をみていきます。なおサンプルのコンポーネントとしては、Reactで以下のように定義されたButtonコンポーネントを使います。
まずStoryレベルのMetaでは、すべてのPropsを任意で指定できるようにしています。Componentレベルはあくまで共通で設定したい場合に使うものであるためです。
具体的な型としては次のような定義になっています。
ここでTCmpOrArgs
はtypeof Button
なので、ComponentAnnotations<ReactRenderer, ComponentProps<TCmpOrArgs>>
がかえります。
そしてこのComponentAnnotationsは先ほどのBaseAnnotaionsをimplementしています。ComponentAnnotationsにはargs
はありませんが、これはBaseAnnotationsに定義されています。
型引数のTArgs>
にはComponentProps<TCmpOrArgs>>
が渡りますので、合わせるとargs
の型は次のようになります。
したがって、args
はButtonのすべてのPropsをOptionalで受け入れる型になり、Componentレベルの設定を満たすことになります。
StoryObj(Storyレベル)ではComponentレベルで定義したPropsは任意になる
つづいてStoryObjです。
StoryObjは分岐が多くぱっと見読みづらいです。Reactコンポーネントで形成するMetaを渡した場合の型を抜粋するとこの部分のなります。
StoryAnnotationsは名前から察するとおり、さきほどのComponentAnnotationsのStory版です。
ここではargs
の型は、3つめの型引数に渡した型、つまりSetOptional<TArgs, keyof TArgs & keyof DefaultArgs>
になります。SetOptional
自体は何かというと、type-festで定義されている型ユーティリティで、指定したキーをOptionalにするものです。
https://github.com/sindresorhus/type-fest/blob/main/source/set-optional.d.ts
ここではkeyof TArgs & keyof DefaultArgs
がOptionalにする対象なので、Componentレベルで定義したProps(DefaultArgs
)があれば、それらはOptionalになります。
Storyの型定義のまとめ
- csfから提供されるBaseAnnotationsという型によってComponent Story Formatに必要な属性を規定する
- MetaもStoryObjもBaseAnnotationsをimplementしたCSFを定義する型
Storyはどのようにコンポーネントカタログになるのか
たとえばstorybook dev
コマンドを実行した時に、定義したStoryがどういう過程を経てStorybookのあのUIになっているのかが気になっていました。
Storybookと実際のアプリケーションとの環境間差異を把握したいためです。全体像を把握しておけば新しいBundlerやPluginが追加になっても、ある程度想像がつくようになると考え、ざっとですが、storybook dev
からUIが立ち上がるまでを追いかけてみました。
対象バージョン:Storybook 8.3.6
結論
エントリポイント
2つのBuilder
ManagerBuilderとPreviewBuilderという2つのBuilderが取得されています。
https://github.com/storybookjs/storybook/blob/v8.3.6/code/core/src/core-server/dev-server.ts#L76-L80
これらについてはStorybookのドキュメントのBuilder APIのセクションに説明があります。
画像の通りではありますが、アプリケーションのコンポーネントを描画するiframe部分のためのビルドツールをPreview Builderと読んでおり、それ以外のStorybook自体のUI部分のためのビルドツールをManager Builderと呼ぶそうです。
Manager Builder
Manager BuilderではStorybookのUIをビルドします。実際にはStorybookに内包される部分だけではなく、StorybookのUI部分に作用するAdd-Onもビルドの対象です、
実態を追っていきます。まずManagerBuilder自体の取得は次のとおり、動的なimportによって実行されます。
https://github.com/storybookjs/storybook/blob/v8.3.6/code/core/src/core-server/utils/get-builders.ts#L7-L9
(Manager BuilderとBuilder Managerだと違う意味に取れそうなものですが … …)。
ここで取得されたManager Builderもといbuilder-managerパッケージに対して、dev-server側はstart
を呼び出しています。
start
関数自体はそこそこに長い処理ですが、やっていることとしてはesbuildによるビルド実行→sirvを介してビルドした静的ファイルをサーブする、という2点です。
getData
関数ではesbuildのインスタンスやHTMLファイルのエントリポイントとなるejsファイルを取得します。
ここで読み込まれるのは、次のejsファイルです。
https://github.com/storybookjs/storybook/blob/v8.3.6/code/core/assets/server/template.ejs
ランタイムとして同梱される./sb-manager/runtime.js
では、StorybookのUIをレンダリングするrenderStorybookUI
関数を呼び出しています。
https://github.com/storybookjs/storybook/blob/v8.3.6/code/core/src/manager/runtime.ts#L45-L53
ここから先はいつものReactの世界でした。
https://github.com/storybookjs/storybook/blob/v8.3.6/code/core/src/manager/index.tsx
Storybookのレイアウト
https://github.com/storybookjs/storybook/blob/v8.3.6/code/core/src/manager/App.tsx#L26-L35
Previewコンポーネントが実際にコンポーネントを描画する領域で、いくつかコンポーネントを潜っていくと、iframeを見つけることができます。
https://github.com/storybookjs/storybook/blob/v8.3.6/code/core/src/manager/components/preview/Iframe.tsx#L7-L18
Preview Builder
Preview Builderは先述の通り、ユーザーが定義したStoryのためのBuilderです。Builderは次のインターフェイスを満たすことを必要としており、実際にdev serverの中でもstart
やbail
などが呼び出されています。
https://storybook.js.org/docs/builders/builder-api#builder-api
現在Storybookで利用できるBuilderにはwebpackとViteがあるので、今回は@storybook/builder-viteを読んでみます。
builder-vite
builder-viteもStorybookのリポジトリに格納されています。
https://github.com/storybookjs/storybook/tree/v8.3.6/code/builders/builder-vite
startの処理はこれだけで、やっていることととしては次のとおりです。
- middlewareModeでViteの
createServer
を実行する。
- Storybookのcoreディレクトリを起点としてdist/previewを静的アセットとして配信できるようにします。
- iframeMiddlewareをミドルウェアとして指定します。
- 1をミドルウェアとして指定する。
まず1のmiddlewareModeについて、これによりルーティングの制御はrouter
、つまりStorybookがpolkaを用いてたてたHTTPサーバに移ります。結果としてルーティング後の処理をViteが受け持てるようになります。
サーバーサイドレンダリング | Viteがわかりやすく、この例ではexpressを使っていますが、polkaを使っていても同じ要領で読めるかと思います。
次に2のdist/previewですが、これはViteが配信するiframe.htmlで読み込まれるスクリプトを指しています。
3のiframeMiddlewareは同じファイルに定義されており、どうもiframe.html
でリクエストが来た場合に、@storybook/buider-vite/input/iframe.htmlをごにょごにょして返すmiddlewareのようです。
というわけでViteの世界のエントリポイントはiframe.htmlになりそうなので、次はここを起点に処理をみます。
iframe.html
iframe.htmlの中身は薄く、bodyとしてはこれだけです。いくつかdivがあるのでStorybookのUIと照らし合わせると、たしかにiframeの中にこの要素がありますね。
さきほども出てきましたが、sb-preview/runtime.js
が出てきているので、これを確認します。
sb-preview/runtime.js
実際のディレクトリでいうと、code/core/src/previewを指していそうです。このモジュールでは、Storybookの各モジュールをグローバルスコープに突っ込む役割を担っています。
あまり具体的な処理はなかったので、もう1つのscriptをみます。
@storybook/builder-vite/vite-app.js
Viteの仮想モジュールなのですが、具体的にどのファイルの処理で生成されるものを指しているのかいまいちわからず … …。
残念ながら今回はここでタイムアップです。
おわりに
発表ではStorybook 8.3で発表されたexperimental-nextjs-viteにも触れたのですが、こちらもあまり深追いはできておらず、サラッと紹介する程度でした。まだまだコード読解力が足りませんね。
ただ今回のドキュメントリーディング、コードリーディングを経て、だいぶStorybookに対して自信が持てました。