Read OSS

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つです。

  • importFnstories の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がリクエストされると、ストアは次の手順で処理します。

  1. importFn 経由でCSFモジュールをインポートする(ビルド時に生成された仮想モジュール)
  2. processCSFFile でモジュールを処理し、meta(デフォルトエクスポート)と各named exportのStoryを抽出する
  3. prepareStory で各Storyを準備し、プロジェクトレベル・コンポーネントレベル・Storyレベルの設定をマージする

processCSFFileprepareStory はどちらもメモ化されています。前者は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をレンダリングする必要があるとき、PreviewWithSelectionStoryRender インスタンスを生成し、複数フェーズのライフサイクルを管理します。

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ハンドラーをトリガーします。エントリースクリプトは新しい importFnPreviewWeb.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システムを探っていきます。