Read OSS

プリセットシステム:Storybookが複数のソースから設定を組み合わせる仕組み

上級

前提知識

  • 第1回:アーキテクチャ概要
  • JavaScriptのモジュール解決の理解
  • 関数合成パターン(reduce/fold)への慣れ

プリセットシステム:Storybookが複数のソースから設定を組み合わせる仕組み

第1回で紹介した3環境アーキテクチャがStorybookの骨格だとすれば、プリセットシステムはその神経系にあたります。babelトランスフォームからViteプラグイン、サイドバーに表示されるストーリーまで、Storybookのあらゆる設定は一本のcomposableなパイプラインを通じて流れていきます。プリセットとは、名前付き関数をエクスポートするモジュールにすぎません。各関数は蓄積された設定を受け取り、変換した結果を返します。これを適切な順序で積み重ねることで、Storybookの完全な動作環境が出来上がります。

アドオンを開発している方、Storybookの設定をカスタマイズしたい方、あるいはコアに貢献したい方にとって、プリセットの理解は欠かせません。コンセプトから実装まで、順を追って見ていきましょう。

プリセットとは何か

最もシンプルに言えば、プリセットは名前付き関数や値をエクスポートするJavaScriptモジュールです。各エクスポートは設定キーに対応しています。corestoriesviteFinalfeaturesbabel といったキーがその例です。あるキーの値が必要になると、Storybookはロードされたすべてのプリセットを順番にたどり、蓄積された値をそのキーに対応する各プリセットの関数へと渡していきます。

この仕組みを支える型はコアに定義されています。

// Simplified from storybook/internal/types
interface LoadedPreset {
  name: string;
  preset: Record<string, any>;  // The module's exports
  options: Record<string, any>; // Options passed to this preset
}

interface Presets {
  apply: (extension: string, config?: any, args?: any) => Promise<any>;
}

公開インターフェースは apply メソッドのみです。呼び出し側は個々のプリセットに直接触れることなく、設定キーを要求するだけで完全に合成された結果を受け取れます。

2パスのロード戦略

第1回で触れたように、buildDevStandalone() はプリセットを2回ロードします。これは冗長な処理ではなく、必然的な設計です。最初のパスではビルダー(ViteまたはWebpack)を特定します。ビルダーはオーバーライドプリセットを持つことがあり、それを他のすべてのプリセットよりも後にロードする必要があるためです。

code/core/src/core-server/build-dev.ts#L162-L180

flowchart TD
    Start[buildDevStandalone] --> Pass1["First Pass: loadAllPresets()"]
    Pass1 --> |"isCritical: true"| Determine["Determine builder from core config"]
    Determine --> Resolve["Resolve builder + renderer packages"]
    Resolve --> Pass2["Second Pass: loadAllPresets()"]
    Pass2 --> |"Full preset chain"| Apply["Apply presets for all config keys"]

    subgraph "First Pass Presets"
        FP1[Framework preset]
        FP2[Override preset]
    end

    subgraph "Second Pass Presets"
        SP1[common-preset.ts]
        SP2[Manager builder presets]
        SP3[Preview builder presets]
        SP4[Renderer preset]
        SP5[Framework preset]
        SP6[User main.ts]
        SP7[Override presets]
    end

最初のパスではエラー発生時に警告ではなく例外をスローするよう isCritical: true を設定し、スタブのトランスポート(使用されるとエラーをログに出力する)を持つ一時的なチャネルを作成します。ロードするのはフレームワークプリセットと共通オーバーライドプリセットのみです。

最初のパスでビルダーが確定したら、2回目のパスで完全なプリセットチェーンを次の順でロードします。

  1. common-preset.ts — 基本デフォルト
  2. Managerビルダーのコアプリセット
  3. Previewビルダーのコアプリセット
  4. レンダラープリセット(例:@storybook/react/preset
  5. フレームワークプリセット(例:@storybook/react-vite/preset
  6. ユーザーの main.ts(Storybook設定ファイル)
  7. オーバーライドプリセット(ビルダーと共通オーバーライドから)

code/core/src/core-server/build-dev.ts#L246-L260

この順序が重要です。チェーンの後ろにあるプリセットほど前のものを上書きできます。オーバーライドプリセットは最後に実行されるため、どの設定値に対しても最終的な決定権を持ちます。

applyPresetsのreduceチェーン

プリセットシステムの核心は applyPresets() — ロードされたプリセットに対するreduce操作です。

code/core/src/common/presets.ts#L265-L316

flowchart LR
    Init["Initial Value"] --> P1["Preset 1 (common)"]
    P1 --> P2["Preset 2 (builder)"]
    P2 --> P3["Preset 3 (renderer)"]
    P3 --> P4["Preset 4 (framework)"]
    P4 --> P5["Preset 5 (user main.ts)"]
    P5 --> P6["Preset 6 (override)"]
    P6 --> Result["Final Config"]

各プリセットが設定キーに対してどう貢献するか、ロジックは3パターンに分かれます。

  1. 関数(accumulatedValue, combinedOptions) を引数にプリセットのエクスポートが呼ばれ、返り値が新しい蓄積値になります。最も一般的で強力な形式です。
  2. 配列 — 蓄積された配列に連結されます。
  3. オブジェクト — 蓄積されたオブジェクトにシャローマージされます。

具体例を見てみましょう。Storybookが viteFinal(Vite設定を変更するためのフック)を必要とするとき、presets.apply('viteFinal', baseViteConfig) を呼び出します。viteFinal 関数をエクスポートしている各プリセットが、順番に設定を変換していきます。

// In react-vite's preset.ts
export const viteFinal = async (config, { presets }) => {
  const plugins = [...(config?.plugins ?? [])];
  // Add docgen plugins...
  return { ...config, plugins };
};

各関数に渡される combinedOptions 引数は、storybookOptions、グローバルargs、そのプリセット固有のオプションを合わせたものです。さらに独自の apply メソッドを持つネストされた presets オブジェクトも含まれており、プリセットが他の設定値を再帰的に参照できるようになっています。

ヒント: viteFinalwebpackFinal フックを書くときは、受け取った設定を必ずスプレッドしてください({ ...config, ... })。蓄積された設定を捨てて新しいオブジェクトを返してしまうのは、プリセット関連のバグで最もよくある原因です。

アドオンの解決

プリセットをロードする前に、アドオンをパッケージ名から実際のファイルパスへ解決する必要があります。この対応付けを担うのが resolveAddonName 関数です。

code/core/src/common/presets.ts#L68-L116

flowchart TD
    Input["Addon name string"] --> IsPreset{Ends with /preset?}
    IsPreset -->|Yes| ReturnPreset["Return as preset type"]
    IsPreset -->|No| Probe["Probe for /preset, /manager, /preview"]
    Probe --> HasFiles{Found any?}
    HasFiles -->|Yes| Virtual["Return as 'virtual' type with:
    - presets[] (from /preset)
    - managerEntries[] (from /manager)  
    - previewAnnotations[] (from /preview)"]
    HasFiles -->|No| TryDirect{Direct resolve?}
    TryDirect -->|Yes| ReturnPreset
    TryDirect -->|No| Undefined["Return undefined (skipped)"]

これがアドオン1つにつき3ファイルの規約です。アドオンは最大3つのエントリーポイントを持てます。

  • /preset — StorybookのビルドコンフィグをNode.js側で変更するコード
  • /manager — Manager UIにパネル、ツール、タブを登録するブラウザ側のコード
  • /preview — Previewのiframeにデコレーター、パラメーター、グローバル値を追加するブラウザ側のコード

解決処理はこの3つをまとめて「virtual」アドオン型として返します。main.tsaddons: ['@storybook/addon-docs'] と書くだけで、適切なプリセットとエントリーファイルの組み合わせに自動的に展開されるのはこの仕組みによるものです。

共通プリセット:デフォルト設定の基盤

共通プリセットはベースレイヤーです。フレームワーク、アドオン、ユーザー設定が何も適用される前の、すべてのStorybookインスタンスが最初に持つデフォルト値を定義しています。

code/core/src/core-server/presets/common-preset.ts#L48-L236

幅広い設定キーのデフォルト値を提供しています。

キー デフォルト 用途
features actions、controls、interactions有効 組み込みアドオンのフィーチャーフラグ
typescript react-docgen、型チェックなし TypeScript/docgenの動作
babel ストーリーファイル向けブラウザターゲット上書き Babelトランスフォームのデフォルト
experimental_indexers CSFインデクサー(/(stories|story)\.(m?js|ts)x?$/ ストーリーファイルの探索
core チャネルオプション、テレメトリ設定 コアランタイムの設定
storyIndexGenerator 非同期シングルトンジェネレーター ストーリーインデックスの生成

205〜221行目のfeaturesプリセットは特に興味深い箇所です。actions、controls、backgrounds、viewportなど、組み込みフィーチャーのマスタースイッチになっています。これらはかつて個別のインストールが必要なアドオンでしたが、現在は組み込まれており、フィーチャーフラグで切り替えられます。

271〜288行目の experimental_serverChannel 関数では、サーバーサイドのチャネルハンドラーがすべて初期化されます。ファイル検索、ストーリー作成、ゴーストストーリー、エディタで開く機能、テレメトリチャネルなどが対象です。

薄いプリセットラッパーとしてのフレームワーク

Storybookの設計でとりわけ洗練されているのが、フレームワークパッケージの実装方法です。@storybook/react-vite を例に見てみましょう。

code/frameworks/react-vite/src/preset.ts#L1-L50

フレームワークプリセット全体がやっていることは2つだけです。

  1. ビルダーとレンダラーの宣言core エクスポート(5〜8行目)を通じて
  2. Vite設定のカスタマイズviteFinal を通じてReact固有のプラグイン(docgenなど)を追加
flowchart LR
    FW["@storybook/react-vite"] --> Core["core: { builder: builder-vite, renderer: react }"]
    FW --> VF["viteFinal: adds docgen plugins"]

    subgraph "Resolved Chain"
        BV["@storybook/builder-vite/preset"]
        RR["@storybook/react/preset"]
    end
    Core --> BV
    Core --> RR

つまり新しいフレームワークを追加するには、どのビルダーとレンダラーを組み合わせるかを宣言し、フレームワーク固有のビルドカスタマイズを加えるだけです。プリセットチェーンが残りをすべて処理してくれます。

ヒント: フレームワーク固有のビルド問題をデバッグするときは、まずフレームワークの preset.ts を確認しましょう。viteFinal(または webpackFinal)フックは、フレームワーク固有のVite/webpackプラグインが追加される最も一般的な場所です。何かが欠けていたり設定が誤っていたりする場合、たいていここに原因があります。

loadPresetの再帰処理

プリセット自身がサブプリセットやサブアドオンを宣言することもできます。この再帰を処理するのが loadPreset 関数です。

code/core/src/common/presets.ts#L156-L243

プリセットモジュールがロードされると、その addons 配列と presets 配列が再帰的に解決されます。この順序は意図的なものです。サブプリセットとサブアドオンは親プリセット本体よりも先にロードされます。つまり親は子が設定した値を上書きできます。main.ts はプリセットとしてロードされ、そのチェーンの中で最後に処理されるため、アドオンやフレームワークのいかなる設定にも最終的な発言権を持ちます。

この関数は disabledAddons リスト(build.test.disabledAddons から取得)もサポートしており、テストビルドで重いアドオンをスキップして実行速度を上げることができます。

チャネルへのブリッジ

プリセットシステムはNode.js側の関心事です。サーバーの起動時に実行され、ビルドとランタイムの両方を駆動する設定を組み立てます。しかしビルドが完了してブラウザがロードされると、別の合成メカニズムが主役になります。チャネルシステムです。次回の記事では、ChannelクラスとPostMessageトランスポート、WebSocket接続が3つの環境のリアルタイム通信をどのように実現しているかを探ります。Storybook全体の体験を支えるプロトコルの核心に迫っていきましょう。