Previewのレンダリングパイプライン:CSFファイルからレンダリングされたStoryまで
前提知識
- ›第1回:アーキテクチャ概観
- ›第3回:チャンネルとイベント
- ›Component Story Format(CSF)の理解
- ›レンダラーの例を理解するための基本的なReactの知識
Previewのレンダリングパイプライン:CSFファイルからレンダリングされたStoryまで
Preview iframeこそ、コンポーネントが実際にレンダリングされる「魔法の舞台」です。前回までの記事では、設定がどのように合成されるか(第2回)、そしてイベントが環境間をどのように流れるか(第3回)を追いました。今回はiframe内部のパイプラインを追いかけます。ランタイム全体を起動する生成済みエントリースクリプトから始まり、Storyの読み込みと設定の合成を経て、preparationからplay関数・完了までの各フェーズを管理する詳細なレンダーライフサイクルまでを解説します。
このシリーズの中で最も技術的に深い内容です。読み終えるころには、「ユーザーがStoryをクリックする」から「ピクセルが画面に描かれる」までの間に何が起きているのかを正確に理解できるようになります。
生成コードによるPreviewのブートストラップ
Preview iframeは静的なJavaScriptファイルを読み込むわけではありません。代わりにビルダー(ViteまたはWebpack)がすべてを繋ぐ仮想エントリーモジュールを生成します。
code/builders/builder-vite/src/codegen-modern-iframe-script.ts#L53-L71
生成されたコードは、次の順序で実行されます。
flowchart TD
A["1. import { setup } from 'storybook/internal/preview/runtime'"] --> B["2. import addon setup (virtual)"]
B --> C["3. setup() — apply globals, error handlers"]
C --> D["4. import PreviewWeb from 'storybook/preview-api'"]
D --> E["5. import { importFn } from virtual stories file"]
E --> F["6. import { getProjectAnnotations } from virtual annotations"]
F --> G["7. new PreviewWeb(importFn, getProjectAnnotations)"]
G --> H["8. HMR handler setup"]
setup() の呼び出しは第1回で確認したPreviewランタイムを実行します。グローバルパッケージの適用(storybook/preview-api のエクスポートを window に公開)、テレメトリーエラーハンドラーのセットアップ、そしてiframeの非アクティブ状態とManagerとの同期が行われます。
code/core/src/preview/runtime.ts#L23-L56
重要な仮想モジュールは2つです。
importFn—storiesのglobパターンから生成される関数で、パスを指定してStoryファイルを遅延インポートしますgetProjectAnnotations— アドオンとpreview.tsからすべてのpreviewアノテーション(デコレーター、パラメーターなど)を組み立てます
Previewのクラス階層
Previewは2クラスの階層構造で動作します。
code/core/src/preview-api/modules/preview-web/Preview.tsx#L60-L88
classDiagram
class Preview~TRenderer~ {
#storyStoreValue: StoryStore
+renderToCanvas: RenderToCanvas
+storyRenders: StoryRender[]
#importFn: ModuleImportFn
+getProjectAnnotations()
+initialize()
#setupListeners()
+onStoriesChanged()
}
class PreviewWithSelection~TRenderer~ {
+currentSelection: Selection
+currentRender: PossibleRender
+selectionStore: SelectionStore
+view: View
+setupListeners()
-onSetCurrentStory()
-renderSelection()
}
Preview <|-- PreviewWithSelection
Preview(基底クラス)が担当するのは以下の通りです。
- Storyの読み込みとStoryStoreの初期化
- args更新やglobals変更、強制再レンダリングのチャンネルイベントリスナー
- Story indexの取得と更新サイクル
- プロジェクトアノテーションの管理
PreviewWithSelection(ブラウザで使用されるサブクラス)はこれに加えて以下を担います。
SET_CURRENT_STORYイベントによるStoryの選択- URLの同期
- レンダーライフサイクルの管理(
StoryRenderインスタンスの生成と制御) - キーボードイベントのハンドリング
この分離が存在するのは、Preview 単体でテストシナリオ(portable stories APIなど)でも使えるようにするためです。そのようなシナリオにはブラウザの選択モデルが不要だからです。
StoryStore:Storyの読み込みと準備
StoryStore はすべてのStoryデータを管理する中央キャッシュです。
code/core/src/preview-api/modules/store/StoryStore.ts#L51-L97
flowchart TD
Import["importFn('./Button.stories.tsx')"] --> Process["processCSFFile(exports, importPath)"]
Process --> CSF["CSFFile { meta, stories[] }"]
CSF --> Prepare["prepareStory(story, meta, projectAnnotations)"]
Prepare --> PS["PreparedStory {
parameters, args, argTypes,
decorators, loaders, playFunction,
applyBeforeEach, applyAfterEach
}"]
PS --> Cache["Memoized cache (10,000 stories)"]
Storyがリクエストされると、ストアは次の手順で処理します。
importFn経由でCSFモジュールをインポートする(ビルド時に生成された仮想モジュール)processCSFFileでモジュールを処理し、meta(デフォルトエクスポート)と各named exportのStoryを抽出するprepareStoryで各Storyを準備し、プロジェクトレベル・コンポーネントレベル・Storyレベルの設定をマージする
processCSFFile と prepareStory はどちらもメモ化されています。前者は1,000エントリー、後者は10,000エントリーのキャッシュを持ちます。これはargsの変更やglobalsの更新のたびにStoryが再準備されるためで、メモ化なしではコストが非常に大きくなります。
composeConfigs:デコレーター・パラメーター・argsのマージ
複数レイヤーの設定をマージする合成アルゴリズムは composeConfigs に実装されています。
code/core/src/preview-api/modules/store/csf/composeConfigs.ts#L43-L76
この関数はモジュールエクスポートの配列(アドオン、preview.ts、コアアノテーション)を受け取り、単一の NormalizedProjectAnnotations オブジェクトを生成します。各フィールドには固有のマージ戦略が使われます。
| フィールド | 戦略 | 備考 |
|---|---|---|
parameters |
ディープマージ(combineParameters) |
後の値が優先 |
decorators |
配列の連結 | デフォルトではファイル順を逆にする(最外のラッパーが先) |
args |
オブジェクトのマージ | 後のキーが優先 |
argTypes |
オブジェクトのマージ | 後のキーが優先 |
loaders |
配列の連結 | 順番通りに実行 |
beforeEach / afterEach |
配列の連結 | ライフサイクルフック |
render |
最後の値が勝つ(シングルトン) | render関数は1つだけ |
renderToCanvas |
最後の値が勝つ(シングルトン) | フレームワーク提供 |
tags |
配列の連結 | 加算的 |
デコレーターの順序は特に注目が必要です。デフォルト(legacyDecoratorFileOrder: false)では、デコレーターは逆順にされます。これにより、ファイル内の先頭のデコレーターが最も外側をラップします。decorators: [withTheme, withRouter] と書いたとき、「withThemeがwithRouterを包み、withRouterがStoryを包む」という読み方と一致する直感的な動作です。
ヒント: デコレーターが意図しない順序で動いている場合は、設定内に
features.legacyDecoratorFileOrderが設定されていないか確認しましょう。このデフォルト値はStorybook 7で変更されており、レガシーフラグを有効にしたままのプロジェクトも少なくありません。
StoryRenderのライフサイクル
これがレンダリングパイプラインの核心です。Storyをレンダリングする必要があるとき、PreviewWithSelection は StoryRender インスタンスを生成し、複数フェーズのライフサイクルを管理します。
code/core/src/preview-api/modules/preview-web/render/StoryRender.ts#L36-L48
stateDiagram-v2
[*] --> preparing
preparing --> loading: Story loaded from store
loading --> beforeEach: Loaders resolved
beforeEach --> rendering: beforeEach callbacks run
rendering --> playing: Component mounted (if play function)
rendering --> completing: Component mounted (no play function)
playing --> played: Play function completed
played --> completing: No errors
playing --> errored: Play function threw
completing --> completed: Animations settled
completed --> afterEach: STORY_RENDERED emitted
afterEach --> finished: Cleanup hooks run
preparing --> aborted: AbortSignal
loading --> aborted: AbortSignal
playing --> aborted: AbortSignal
フェーズが遷移するたびに STORY_RENDER_PHASE_CHANGED イベントが発行され、Manager(やInteractionsのようなアドオンパネル)が進捗を追跡できます。各フェーズは次の通りです。
code/core/src/preview-api/modules/preview-web/render/StoryRender.ts#L105-L116
ヘルパーメソッド runPhase(105行目)はフェーズを更新し、フェーズ変更イベントを発行し、フェーズ関数を実行し、abortを確認します。すべてのフェーズで AbortSignal をチェックします。現在のStoryがまだレンダリング中に別のStoryが選択された場合、古いレンダーはきれいに中断されます。
preparing(130〜139行目):StoryStoreからStoryを読み込みます。準備中にabortされた場合、Storyのクリーンアップが即座に呼ばれます。
loading(287〜289行目):Storyのloaderを実行します。Storyが必要とするデータを取得する非同期関数で、読み込まれたデータは context.loaded に渡されます。
beforeEach(295〜296行目):beforeEach コールバックを実行し、後の後処理のためにクリーンアップ関数を収集します。
rendering(302〜304行目):context.mount() を呼び出します。これがフレームワークレンダラーの renderToCanvas に委譲され、ReactのCreateRoot().render() やVueのcreateApp().mount() が実行されるフェーズです。
playing(328〜341行目):Storyに play 関数があり、オートプレイが有効な場合にここで実行されます。play中の未処理エラーはキャプチャされ、PLAY_FUNCTION_THREW_EXCEPTION として報告されます。
completing → completed(381〜391行目):CSSアニメーションの終了を待ち(テスト環境ではアニメーションを一時停止し)、STORY_RENDERED を発行します。
afterEach(394〜397行目):クリーンアップのために afterEach コールバックを実行します。
finished(上記の図には未掲載):STORY_FINISHED を成功または失敗のステータスとともに発行します。
レンダラーの統合とHMR
フレームワークレンダラーは、StoryRender ライフサイクルの「rendering」フェーズで呼び出される renderToCanvas 関数を実装します。これは composeConfigs のシングルトンフィールドであり、最後に読み込まれたレンダラーが有効になります。Reactの場合はReact rootを作成してStoryをレンダリングし、Vueの場合はVueアプリインスタンスを生成し、Svelteの場合はSvelteコンポーネントをマウントします。
生成されたエントリースクリプトはHot Module Replacementの設定も行います。
code/builders/builder-vite/src/codegen-modern-iframe-script.ts#L32-L42
sequenceDiagram
participant Vite as Vite HMR
participant Entry as Entry Script
participant Preview as PreviewWeb
participant Channel as Channel
Vite->>Entry: hot.accept(VIRTUAL_STORIES_FILE)
Entry->>Preview: onStoriesChanged({ importFn: newModule.importFn })
Preview->>Preview: Re-index stories, re-render current
Vite->>Entry: hot.on('vite:afterUpdate')
Entry->>Channel: emit(STORY_HOT_UPDATED)
Storyファイルが変更されると、ViteがHMRハンドラーをトリガーします。エントリースクリプトは新しい importFn を PreviewWeb.onStoriesChanged() に渡し、影響を受けたStoryを再インデックスして必要に応じて現在の選択を再レンダリングします。STORY_HOT_UPDATED イベントはManagerにStoryが変更された可能性を通知します。
Web ComponentsはHMRと互換性がないため特別扱いです。生成コードが import.meta.hot.decline() を呼び出し、代わりにページ全体をリロードします。
ヒント: Storyのホットリロードが正しく動作しない場合は、対象ファイルが仮想モジュール内のstories globパターンに合致しているか確認しましょう。globの範囲外のファイルはHMRハンドラーによって監視されません。
次回予告
Preview iframeを通る完全なパスをたどりました。ブートストラップからレンダリングまでの全体像が見えたはずです。ブラウザ体験のもう半分はManager UIです。サイドバー、ツールバー、アドオンパネルを提供するReactアプリケーションです。次回は、14のコンポーザブルモジュールによるモジュラーなstate管理アーキテクチャ、アドオン登録システム、そしてStorybookのコンポジションを可能にするrefシステムを探っていきます。