Read OSS

テーマシステム:エイリアス、Swizzling、コンポーネントのカスタマイズ

上級

前提知識

  • プラグインシステムの理解、およびプラグインがテーマパスを提供する仕組み(第1〜2回)
  • Reactコンポーネントのコンポジションパターン
  • Webpackのresolve.aliasの概念

テーマシステム:エイリアス、Swizzling、コンポーネントのカスタマイズ

コンテンツプラグインが component: '@theme/DocItem' を指定してルートを作成すると、webpackのコンパイル時に興味深いことが起きます。その文字列が、複数のディレクトリを優先順位付きでチェックする階層的なエイリアスシステムを通じて解決されるのです。この仕組みこそが、Docusaurusを唯一無二の拡張性を持つフレームワークたらしめています。テーマパッケージをフォークすることなく任意のテーマコンポーネントをカスタマイズでき、しかもカスタマイズ時には「元の」コンポーネントをラップ用に参照できます。

本記事では、エイリアス解決の仕組み、theme-classicに含まれるコンポーネント、theme-commonが提供するヘッドレスなユーティリティ、そしてswizzle CLIがコンポーネントカスタマイズをいかに安全で使いやすくしているかを解説します。

階層的なエイリアス解決システム

テーマのエイリアスシステムは、@theme/*@theme-original/*@theme-init/* という3つのwebpackエイリアス名前空間を基盤としています。解決ロジックは aliases/index.ts で構築されています。

119〜134行目の loadThemeAliases() 関数は、以下の3つのソースから優先度順(低→高)にエイリアスマップを構築します。

  1. ThemeFallbackDir — 最小限の組み込みフォールバックコンポーネント(25行目)
  2. プラグインのテーマパス — インストール済みテーマ(theme-classicなど)のコンポーネント。各プラグインの getThemePath() 経由で取得
  3. ユーザーの src/theme/ ディレクトリ — サイト固有のテーマオーバーライド
flowchart BT
    FALLBACK["ThemeFallbackDir<br/>(lowest priority)"] --> ALIAS["@theme/* resolution"]
    PLUGIN["Plugin themes<br/>(theme-classic, etc.)"] --> ALIAS
    USER["src/theme/<br/>(highest priority)"] --> ALIAS
    
    ALIAS --> |"@theme/Root"| RESOLVED["Actual component file"]

88〜117行目の createThemeAliases() 関数は、テーマパスを順番にイテレートします。あるテーマコンポーネントが以前に登録済みのものを上書きする場合、システムはそのコンポーネントの最初の提供元を指す @theme-init/* エイリアスを作成します。同時に、95行目でテーマパス上の全コンポーネントに @theme-original/* エイリアスが作成されるため、ユーザーのオーバーライドから元のコンポーネントを参照できるようになっています。

重要な点があります。aliases/index.ts#L35-L44sortAliases() 関数が、より具体的なパスを先に解決するよう順序を保証しています。@theme/NavbarItem/LocaleDropdown@theme/NavbarItem より前に解決されなければなりません。そうでないと、後者が前者を上書きしてしまうからです。このソートは、まずアルファベット順に並べ、その後に親パスが子パスより必ず後に来るよう調整する形で実現されています。

これにより、ラップパターンが成立します。src/theme/DocItem/index.tsx を作成すると、そのファイルに @theme/DocItem エイリアスが割り当てられます。ファイル内では import OriginalDocItem from '@theme-original/DocItem' とすることで、theme-classicのバージョンを取得して独自のロジックでラップできます。

ヒント: 3つのエイリアス(@theme@theme-original@theme-init)はそれぞれ異なるユースケースに対応しています。swizzleしたコンポーネントで「本来の」実装を参照するには @theme-original を使いましょう。@theme-init は、他のテーマによる上書きが起きる前の最初の実装が必要な、マルチテーマの高度なシナリオ向けに存在します。

@docusaurus/* クライアントAPI

テーマエイリアスに加えて、Docusaurusは @docusaurus/* エイリアスを通じてパブリックなクライアントAPIを提供しています。aliases/index.ts#L141-L159loadDocusaurusAliases() 関数は client/exports/ ディレクトリをスキャンし、各ファイルに対してエイリアスを作成します。

エイリアス ソース 用途
@docusaurus/Link client/exports/Link.tsx プリロード対応のルーター連携リンク
@docusaurus/useDocusaurusContext client/exports/useDocusaurusContext.tsx サイト設定とi18nデータへのアクセス
@docusaurus/BrowserOnly client/exports/BrowserOnly.tsx クライアントサイドのみでレンダリングするラッパー
@docusaurus/Head client/exports/Head.tsx <head> タグへの挿入
@docusaurus/ErrorBoundary client/exports/ErrorBoundary.tsx テーマフォールバック付きのエラーバウンダリ
@docusaurus/useBaseUrl client/exports/useBaseUrl.tsx ベースURL解決フック
@docusaurus/useBrokenLinks client/exports/useBrokenLinks.tsx 壊れたリンクのレポーティング

中でも Link コンポーネントは特筆に値します。React RouterのNavLinkおよびLinkコンポーネントをラップし、ベースURLの自動付与、末尾スラッシュの正規化、壊れたリンクのチェック、ホバー/フォーカス時のルートプリロードといった横断的な関心事をひとつのコンポーネントで処理します。これらを個々のテーマコンポーネントに分散させずに済むのは、このコンポーネントのおかげです。

graph TD
    subgraph "@docusaurus/* aliases"
        LINK["@docusaurus/Link"]
        CTX["@docusaurus/useDocusaurusContext"]
        BO["@docusaurus/BrowserOnly"]
        HEAD["@docusaurus/Head"]
    end
    subgraph "client/exports/"
        LINK_SRC["Link.tsx"]
        CTX_SRC["useDocusaurusContext.tsx"]
        BO_SRC["BrowserOnly.tsx"]
        HEAD_SRC["Head.tsx"]
    end
    LINK --> LINK_SRC
    CTX --> CTX_SRC
    BO --> BO_SRC
    HEAD --> HEAD_SRC

Theme Classic:コンポーネントライブラリ

theme-classic パッケージは、Docusaurusサイトのビジュアル層を構成する100以上のReactコンポーネントを提供しています。theme-classic/src/theme/Layout/index.tsx のルートレイアウトコンポーネントを見ると、その構造がよくわかります。

export default function Layout(props: Props): ReactNode {
  return (
    <LayoutProvider>
      <PageMetadata title={title} description={description} />
      <SkipToContent />
      <AnnouncementBar />
      <Navbar />
      <div className={styles.mainWrapper}>
        <ErrorBoundary fallback={(params) => <ErrorPageContent {...params} />}>
          {children}
        </ErrorBoundary>
      </div>
      {!noFooter && <Footer />}
    </LayoutProvider>
  );
}

ここに登場するすべてのサブコンポーネント — @theme/SkipToContent@theme/AnnouncementBar@theme/Navbar@theme/Footer — は、エイリアスシステムを通じて解決されます。つまり、これらをそれぞれ独立してswizzleできるということです。

コンポーネントの構成は階層的なパターンに従っています。

graph TD
    LAYOUT["Layout"] --> NAVBAR["Navbar"]
    LAYOUT --> FOOTER["Footer"]
    LAYOUT --> AB["AnnouncementBar"]
    
    LAYOUT --> DOCPAGE["DocPage"]
    DOCPAGE --> DOCROOT["DocRoot"]
    DOCROOT --> SIDEBAR["DocSidebar"]
    DOCROOT --> DOCITEM["DocItem"]
    DOCITEM --> CONTENT["DocItemContent"]
    DOCITEM --> FOOTER_D["DocItemFooter"]
    DOCITEM --> PAGINATOR["DocPaginator"]
    
    LAYOUT --> BLOGPAGE["BlogLayout"]
    BLOGPAGE --> BLOGPOST["BlogPostPage"]
    BLOGPAGE --> BLOGLIST["BlogListPage"]

テーマコンポーネントがデータを受け取るチャネルは2つあります。props(ルーティング層から渡されるもので、addRoute() で宣言した modules がコンポーネントのpropsになります)と、Reactコンテキスト(サイドバーの状態を管理する DocProvider などのプロバイダーコンポーネントから提供されるもの)です。

Theme Common:ヘッドレスなユーティリティとフック

theme-classicがビジュアルコンポーネントを担うのに対し、theme-common はロジック層を担います。共有フック、コンテキストプロバイダー、ユーティリティ関数など、あらゆるテーマから利用できる汎用的な機能が揃っています。この分離設計により、インタラクションロジックはすべて再利用しつつ、見た目だけを完全にカスタムしたテーマを構築することも可能です。

主なエクスポートは以下の通りです。

  • フック: useCollapsibleuseTabsuseCodeWordWrapuseHistoryPopHandler
  • コンテキストプロバイダー: DocProviderBlogProviderTabsProvider
  • ユーティリティ: ThemeClassNames(CSSクラス定数)、useLockBodyScrolluseWindowSize
  • コンポーネント: Details(折りたたみ)、TOCItems(目次レンダラー)

このアーキテクチャの設計思想は明快です。theme-common はロジック、theme-classic はプレゼンテーション。たとえば DocItem をswizzleする場合でも、サイドバーの状態やTOCデータといったコンテキスト情報は @docusaurus/theme-common からフックをインポートするだけで取得できます。ロジックを一から再実装する必要はありません。

Swizzle CLI

swizzle/index.tsswizzle コマンドは、コンポーネントカスタマイズのためのガイド付きワークフローを提供します。サポートされるアクションは2つです。

Ejectactions.ts#L49-L80)は、テーマコンポーネントの完全なソースコードを src/theme/ ディレクトリにコピーします。コピーされたコンポーネントは @theme/* エイリアスを取得し、元のコンポーネントを完全に置き換えます。完全なコントロールを得られる反面、アップストリームの変更への追随は自分で行う必要があります。

Wrap は、@theme-original/* を通じて元のコンポーネントをインポートするラッパーコンポーネントを作成します。元のコンポーネントの前後にコンテンツを追加したり、propsを変更したり、条件に応じて別のものをレンダリングしたりすることが可能です。

flowchart TD
    SWIZZLE["docusaurus swizzle"] --> LIST{--list?}
    LIST -->|yes| TABLE["Show available components"]
    LIST -->|no| THEME["Select theme"]
    THEME --> COMP["Select component"]
    COMP --> ACTION{Wrap or Eject?}
    ACTION -->|wrap| WRAP["Create wrapper in src/theme/<br/>importing @theme-original/*"]
    ACTION -->|eject| EJECT["Copy source to src/theme/"]
    WRAP --> LANG{TypeScript or JavaScript?}
    EJECT --> LANG
    LANG --> FILES["Write files"]

各テーマコンポーネントにはセーフティレベルが設定されています。

  • Safe — swizzleが推奨されており、コンポーネントのAPIは安定している
  • Unsafe — swizzle自体は可能だが、内部構造がバージョン間で変わる可能性がある
  • Forbidden — swizzleはブロックされている(内部実装に深く依存しているため)

セーフティレベルは各テーマパッケージが getSwizzleConfig() を通じて宣言します。--danger フラグを使うと、Unsafeなコンポーネントでも安全チェックをスキップできます。

swizzle/index.ts#L30-L63 の言語選択では、getTypeScriptThemePath() を通じてテーマがTypeScriptをサポートしているか確認します。サポートしていればJSとTSのどちらかを選択でき、そうでなければJavaScriptのみが選択肢となります。

ヒント: 可能な限り、EjectよりWrapを選びましょう。ラップされたコンポーネントはテーマのアップデートに対して堅牢です。コンポーネント全体の実装ではなく、自分のカスタマイズロジックだけをメンテナンスすれば済みます。

フォールバックコンポーネント

エイリアス解決スタックの最下層に位置するのが、theme-fallback ディレクトリです。ここに含まれる最小限のコンポーネントが、フルテーマなしでもDocusaurusが動作することを保証しています。

フォールバックの Root コンポーネントはただのパススルーです。

export default function Root({children}: Props): ReactNode {
  return <>{children}</>;
}

フォールバックの NotFound は、シンプルな「ページが見つかりません」メッセージをレンダリングします。これらは意図的にスタイルなしで実装されています。クラッシュを防ぐことが目的であり、見栄えの良いUIを提供することではないからです。

フォールバックディレクトリはエイリアス解決チェーンの最初のテーマパスとして登録されています。つまり、インストール済みのテーマ(またはユーザーオーバーライド)があれば、必ずこれらのコンポーネントが上書きされます。どのテーマも提供しない必須コンポーネントを受け止める、最後の安全網です。

このアーキテクチャが機能する理由

エイリアスベースのテーマシステムは独特のアプローチであり、改めて考える価値があります。多くのフレームワークでは、テーマをフォークするか、限られたスロットベースのカスタマイズを使うしかありません。Docusaurusはwebpackのresolve.aliasという仕組みを通じて、コンポーネント単位の完全な制御を提供しています。実行時には見えない仕組みですが、ビルド時には絶大な力を発揮します。

この設計にはいくつかの利点があります。

  • きめ細かいカスタマイズ — 他の部分に触れずに、1つのコンポーネントだけを上書きできる
  • ラップによってアップストリームの更新が保たれる — ラッパーはテーマの改善を自動的に受け取る
  • 複数テーマのコンポジションが可能 — 各テーマのコンポーネントが前のものを上書きし、@theme-init で元のチェーンが保持される
  • 実行時のオーバーヘッドなし — エイリアスはコンパイル時に解決される

トレードオフとして、解決ロジックが複雑になること、そしてコンポーネントの実際の出所を理解するまでの学習コストがあります。しかし、カスタマイズ性が最重要なドキュメントフレームワークとしては、十分に価値のあるトレードオフと言えるでしょう。

次回予告

これでレンダリングパイプライン全体をカバーしたことになります。プラグインによるルート生成(第2回)、ビルドでのバンドルコンパイル(第3回)、MDXによるコンテンツ処理(第4回)、そしてテーマシステムによるコンポーネント解決(今回)です。最終回では、開発者体験の層に踏み込みます。devサーバーのホットリロードアーキテクチャ、i18nシステム、設定のロード、フューチャーフラグシステムを解説し、シリーズ全体を締めくくります。