Manager UI:モジュール式状態管理とAddon統合
前提知識
- ›第1回:アーキテクチャ概要
- ›第3回:ChannelとEvents
- ›ReactコンポーネントパターンのContext、クラスコンポーネント
- ›状態管理パターンの理解
Manager UI:モジュール式状態管理とAddon統合
Managerは、皆さんが実際に操作するStorybookアプリケーションそのものです。サイドバー、ツールバー、addonパネル、設定ページなど、複雑な状態を管理するフルReactアプリケーションで、ストーリーツリー、レイアウト設定、キーボードショートカット、アクティブなglobals、登録済みaddonの状態を一括して担います。このアーキテクチャの最大の特徴はモジュール式の状態システムです。14個の独立したモジュールがそれぞれ状態とAPIのスライスを提供し、TypeScriptのintersection typeによって単一の統合インターフェースへと合成されます。
第4回ではPreviewのレンダリングパイプラインを探りました。今回はPostMessage境界の反対側を見ていきましょう。
Manager Bootstrap
起動シーケンスは runtime.tsx から始まります。このファイルは第1回で確認した「グローバル化されたランタイム」エントリとしてコンパイルされます。
code/core/src/manager/runtime.tsx#L32-L89
sequenceDiagram
participant HTML as index.html
participant RT as runtime.tsx
participant RP as ReactProvider
participant Channel as Channel
participant UI as renderStorybookUI
HTML->>RT: Script loaded
RT->>RT: Register toolbar addon
RT->>RP: new ReactProvider()
RP->>Channel: createBrowserChannel({ page: 'manager' })
RP->>Channel: addons.setChannel(channel)
RP->>Channel: emit(CHANNEL_CREATED)
RT->>UI: renderStorybookUI(rootEl, provider)
ReactProvider クラス(32行目)はコンストラクタで3つの処理を行います:
- PostMessage + WebSocketトランスポートでブラウザチャンネルを作成する
- addonsシングルトンにチャンネルを登録する
CHANNEL_CREATEDをemitして準備完了を通知する
また、CHANNEL_WS_DISCONNECT ハンドラも設定しており、開発サーバーへのWebSocket接続が切断されたとき(サーバーのクラッシュやタイムアウトでよく発生します)に通知を表示します。
実際のレンダリングは setTimeout(() => renderStorybookUI(...), 0)(86行目)で遅延されます。HTMLのグローバルscriptタグ(CHANNEL_OPTIONS、CONFIG_TYPE などを設定するもの)がReactから使われる前に確実に評価済みであることを保証するためです。
コンポーネントツリー
ManagerのReactツリーは明確な階層構造を持っています。
code/core/src/manager/index.tsx#L32-L99
flowchart TD
Root["Root"] --> Helmet["HelmetProvider"]
Helmet --> Location["LocationProvider"]
Location --> Main["Main"]
Main --> LocationConsumer["Location (consumer)"]
LocationConsumer --> MP["ManagerProvider"]
MP --> Theme["ThemeProvider"]
Theme --> LP["LayoutProvider"]
LP --> App["App"]
App --> Layout["Layout"]
Layout --> Sidebar
Layout --> Preview["Preview iframe"]
Layout --> Panel["Addon Panel"]
各ラッパーにはそれぞれ固有の役割があります:
- HelmetProvider:ドキュメントの
<head>要素(タイトル、メタタグ)を管理する - LocationProvider:URLルーティングと履歴管理
- ManagerProvider:状態管理の中核(14モジュール、統合状態)
- ThemeProvider:
storybook/themingによるEmotionベースのテーマ管理 - LayoutProvider:レイアウトの計測とリサイズ状態の管理
renderStorybookUI 関数(92行目)は、providerが基底の Provider クラスを継承しているかを検証し、React rootを作成してツリーをマウントします。この検証により、サードパーティコードが誤ったproviderオブジェクトを渡した際に起きやすいエラーを未然に防いでいます。
ManagerProviderとモジュールシステム
Managerの状態管理の中枢は ManagerProvider です。14個のモジュールを初期化してそれぞれの状態とAPIを合成する、Reactクラスコンポーネントです。
code/core/src/manager-api/root.tsx#L82-L110
StateとAPI型はTypeScriptのintersection typeで合成されています:
export type State = layout.SubState &
stories.SubState &
refs.SubState &
notifications.SubState &
version.SubState &
url.SubState &
shortcuts.SubState &
settings.SubState &
globals.SubState &
whatsnew.SubState &
RouterData &
API_OptionsData &
Other;
export type API = addons.SubAPI &
channel.SubAPI &
provider.SubAPI &
stories.SubAPI &
refs.SubAPI &
globals.SubAPI &
layout.SubAPI &
notifications.SubAPI &
shortcuts.SubAPI &
settings.SubAPI &
version.SubAPI &
url.SubAPI &
whatsnew.SubAPI &
openInEditor.SubAPI &
Other;
classDiagram
class ManagerProvider {
+api: API
+modules: ModuleFn[]
+state: State
+initModules()
+render()
}
class ModuleFn {
<<interface>>
+init(args): ModuleResult
}
class ModuleResult {
+state: SubState
+api: SubAPI
+init(): void
}
ManagerProvider --> "14" ModuleFn : initializes
ManagerProvider --> "1" State : composes
ManagerProvider --> "1" API : composes
コンストラクタ(130〜197行目)では、各モジュールが共通の依存セットとともに初期化されます。
code/core/src/manager-api/root.tsx#L130-L197
this.modules = [
provider, channel, addons, layout,
notifications, settings, shortcuts, stories,
refs, globals, url, version, whatsnew, openInEditor,
].map((m) =>
m.init({ ...routeData, ...optionsData, ...apiData, state: this.state, fullAPI: this.api })
);
各モジュールの init 関数は完全なAPIリファレンス(最初は空のオブジェクトで、モジュールの初期化に伴って埋められていきます)を受け取ります。これにより、モジュール同士がお互いのAPIを呼び出せるようになっています。この循環参照は2フェーズで解消されます。まず全モジュールが状態スライスとAPIメソッドを返し、次に initModules() がReact effectとして呼び出されることで、他のモジュールの準備完了を前提とした初期化が実行されます。
ヒント: Managerの状態にアクセスするStorybookアドオンを作成するときは、
storybook/manager-apiのuseStorybookApi()とuseStorybookState()hookを活用しましょう。これらはManagerContextを内部で消費しており、完全に合成されたAPIと状態を提供してくれます。
主要モジュールの詳細
Storiesモジュール
storiesモジュールは最も大規模で複雑なモジュールです。以下を管理します:
- ストーリーツリー(IDからエントリへのマッピングである
IndexHash) - ストーリーの選択とナビゲーション
- ストーリーのフィルタリング(タグ、ステータス、検索による絞り込み)
- PreviewとのArgs状態の同期
code/core/src/manager-api/modules/stories.ts#L1-L80
SET_INDEX(ストーリーインデックスの変更時)、STORY_PREPARED(ストーリーの完全なメタデータが利用可能になったとき)、STORY_ARGS_UPDATED(PreviewでArgsが変更されたとき)といったチャンネルイベントを監視します。また、STORY_INDEX_INVALIDATED を受け取るとサーバーから index.json を再取得します。
Layoutモジュール
layoutモジュールはManagerのビジュアル構造を制御します。サイドバーの幅、パネルの位置(下または右)、パネルの表示/非表示、フルスクリーンモード、アクティブなaddonタブなどが対象です。状態は localStorage に保存されるため、ページをリロードしてもレイアウトの設定が引き継がれます。
Shortcutsモジュール
shortcutsモジュールはキーボードバインディング(サイドバーの切り替え、パネルの切り替え、次/前のストーリーへの移動など)を管理し、Managerフレームとプレビューiframe(PREVIEW_KEYDOWN 経由で転送されます)の両方からのキーダウンイベントを処理します。
Addonの登録とSlot
AddonはRegistration APIを通じてManagerに統合されます:
// Typical addon manager entry
import { addons, types } from 'storybook/manager-api';
addons.register('my-addon', (api) => {
addons.add('my-addon/panel', {
type: types.PANEL,
title: 'My Panel',
render: ({ active }) => active ? <MyPanel /> : null,
});
});
esbuildベースのManagerビルダーは、すべてのaddon managerエントリをバンドルします。
code/core/src/builder-manager/index.ts#L36-L120
flowchart TD
Presets["presets.apply('managerEntries')"] --> Entries["Collect all manager entries"]
ConfigDir["configDir/manager.ts"] --> Entries
Entries --> Wrap["wrapManagerEntries()"]
Wrap --> ESBuild["esbuild bundle (IIFE format)"]
ESBuild --> Output["sb-addons/*.js"]
subgraph "Available Slot Types"
PANEL["types.PANEL — Addon panels"]
TOOL["types.TOOL — Toolbar tools"]
TAB["types.TAB — Canvas tabs"]
PAGE["types.experimental_PAGE — Full pages"]
end
各エントリはtry/catchでラップされており(108〜111行目)、あるaddonが失敗してもManager全体がクラッシュしないようになっています。esbuildの設定では globalExternals を使い、addonがReact・storybook/manager-api・その他のグローバルをManagerと共有し、独自にバンドルしないようにしています。
利用可能なslotタイプは、addonがUIを挿入できる場所を定義します:
- PANEL:previewの下または横のaddonパネルに表示される
- TOOL:ツールバーに表示される
- TAB:Canvasと並ぶタブとして表示される
- experimental_PAGE:フルページのルート(Settingsで使用)
Refシステム:Storybookコンポジション
refsモジュールはStorybookコンポジションを実現します。複数のStorybookインスタンスのストーリーを1つのUIにまとめて表示する機能です。
code/core/src/manager-api/modules/refs.ts#L1-L60
flowchart TB
Main["Main Storybook"] --> Sidebar
Sidebar --> LocalStories["Local Stories (IndexHash)"]
Sidebar --> Ref1["Ref: Design System Storybook"]
Sidebar --> Ref2["Ref: Shared Components Storybook"]
Ref1 -->|"fetch /index.json"| Remote1["Remote index.json"]
Ref2 -->|"fetch /index.json"| Remote2["Remote index.json"]
Ref1 -->|"iframe src"| Preview1["Remote Preview iframe"]
Ref2 -->|"iframe src"| Preview2["Remote Preview iframe"]
main.ts にrefsを設定すると、Managerは各リモートStorybookの index.json を取得し、ストーリーインデックスを変換して、ローカルのストーリーと並べてサイドバーに統合します。ユーザーがリモートのストーリーを選択すると、Preview iframeはローカルサーバーではなくリモートのURLから読み込まれます。
refsの SubAPI インターフェースには findRef、setRef、updateRef、getRefs、checkRef といったメソッドが含まれます。状態では各refの読み込み状況、ストーリー、バージョン情報を追跡しています。
ヒント: Storybookコンポジションはデザインシステムで特に威力を発揮します。デザインシステムのStorybookを静的サイトとして公開し、利用側アプリケーションのStorybookの設定でそれを参照するだけです。コンポーネントコードを複製することなく、すべてのストーリーを1つのサイドバーで確認できます。
次回予告
これで3つのランタイム環境をすべて詳しく解説しました。サーバー(第1〜2回)、Preview(第4回)、今回のManagerです。最終回では、これらすべてを支えるビルドインフラに焦点を当てます。Nxが管理するモノレポパイプライン、ストーリーインデックスジェネレーター、変更検出、サンドボックステスト、そして構造化されたエラーシステムを取り上げる予定です。