Read OSS

Gatsby の拡張機能:Plugin API、テーマ、デプロイアダプター

中級

前提知識

  • 本シリーズの第1〜4回
  • ユーザー視点での Gatsby プラグイン利用に関する基本的な理解

Gatsby の拡張機能:Plugin API、テーマ、デプロイアダプター

すべての Gatsby サイトは、突き詰めればプラグインのコンポジションです。最もシンプルなサイトであっても、ファイルシステムルーティング、エラーページ、Babel 設定、webpack テーマシャドウイングを担う十数個の内部プラグインが動いています。プラグインシステムの理解はプラグイン作者だけに必要なものではありません。Gatsby 自体がどのように動いているかを把握するためにも、欠かせない知識です。

このシリーズ最終回では、Gatsby の拡張機能のすべてを取り上げます。プラグインの検出・読み込みから、実装できる3つの API サーフェス、テーマのコンポジションまでを解説します。さらに、source・transformer プラグインの定石パターン、SSG/DSG/SSR のページモードシステム、デプロイアダプターの抽象化についても掘り下げます。

プラグインの解決と3つの API サーフェス

第2回で触れたように、packages/gatsby/src/bootstrap/load-plugins/index.ts のプラグインローダーは初期化時に実行されます。そのパイプラインは、normalizeオプション検証内部プラグインの読み込みフラット化API の照合エクスポートの検証 という流れです。

collatePluginAPIs ステップでは、各プラグインのエクスポートを3つのドキュメント化された API サーフェスと照合します。

サーフェス ファイル 主な API 実行環境
Node gatsby-node.js sourceNodes, createPages, onCreateNode, onCreateWebpackConfig, createSchemaCustomization Node.js(ビルド時)
Browser gatsby-browser.js onClientEntry, wrapPageElement, wrapRootElement, onRouteUpdate ブラウザ(クライアント実行時)
SSR gatsby-ssr.js onRenderBody, onPreRenderHTML, wrapPageElement, wrapRootElement Node.js(HTML 生成時)

64〜76行目の検証では、既知の API 名に一致しないプラグインのエクスポート、いわゆる「不正なエクスポート」を収集します。たとえば exports.createPages とすべきところを exports.createPage と書いてしまうようなタイポを検出し、プラグイン作者がフレームワークの仕様から外れていないか確認できます。

flowchart TD
    A["gatsby-config.js plugins array"] --> B["normalizeConfig()"]
    B --> C["validateConfigPluginsOptions()"]
    C --> D["loadInternalPlugins()"]
    D --> E["flattenPlugins()"]
    E --> F["collatePluginAPIs()"]
    F --> G["handleBadExports()"]
    G --> H["handleMultipleReplaceRenderers()"]
    H --> I["SET_SITE_FLATTENED_PLUGINS"]

読み込み後、フラット化された配列の各プラグインエントリには、どの API を実装しているかを示すメタデータが含まれます。

{
  resolve: "/path/to/plugin",
  name: "gatsby-source-filesystem",
  nodeAPIs: ["sourceNodes", "onCreateNode"],
  browserAPIs: [],
  ssrAPIs: [],
  pluginOptions: { path: "./src/data" },
}

ポイント: 74行目の handleMultipleReplaceRenderers チェックにより、replaceRenderer API を実装できるプラグインは最大1つに制限されます。複数のプラグインがレンダラーの置き換えを試みた場合(たとえば異なる HTML レンダリング戦略が2つ存在する場合)、ビルド時に失敗するのではなく、起動時にわかりやすいエラーが表示されます。

内部プラグインをアーキテクチャの手本として読む

Gatsby は packages/gatsby/src/internal-plugins/ に置かれた内部プラグインを通じて、自身のプラグイン API を積極的に活用しています。これらは loadInternalPlugins によって自動的に読み込まれ、プラグインの契約がどうあるべきかを示す典型例となっています。

内部プラグイン 役割
internal-data-bridge Redux の状態から Site ノードと SitePage ノードを生成する
dev-404-page ルート一覧を含む開発用 404 ページを生成する
prod-404-500 本番用の 404・500 エラーページを生成する
load-babel-config Babel 設定を読み込み、マージする
bundle-optimisations webpack のバンドル分割戦略を設定する
webpack-theme-component-shadowing テーマのコンポーネントシャドウイングを有効化する
functions Gatsby Functions(サーバーレス)を処理する
partytown メインスレッド外でスクリプトを実行するための Partytown を統合する

なかでも webpack-theme-component-shadowing プラグインは特に参考になります。packages/gatsby/src/internal-plugins/webpack-theme-component-shadowing/gatsby-node.js はわずか25行のファイルです。onCreateWebpackConfig を実装してカスタムの webpack resolve プラグインを注入します。

exports.onCreateWebpackConfig = ({ store, actions }) => {
  const { flattenedPlugins, program } = store.getState()
  actions.setWebpackConfig({
    resolve: {
      plugins: [
        new GatsbyThemeComponentShadowingResolverPlugin({
          extensions: program.extensions,
          themes: flattenedPlugins.map(plugin => ({
            themeDir: plugin.pluginFilepath,
            themeName: plugin.name,
          })),
          projectRoot: program.directory,
        }),
      ],
    },
  })
}

これは Gatsby が自分自身のプロダクトを自分で使っている好例です。コンポーネントシャドウイング機能は、サードパーティのプラグインでも利用できる同じ onCreateWebpackConfig API にフックするプラグインとして実装されています。

テーマシステム:再帰的な設定マージとコンポーネントシャドウイング

Gatsby におけるテーマは、自身の gatsby-config を持つプラグインです。このシンプルな定義が、強力なコンポジションを可能にします。

再帰的な設定マージ

loadThemesgatsby-config を持つプラグインに出会うと、そのテーマの設定を再帰的に解決します。テーマの設定はオプションを受け取る関数として書くこともできます。

// In a theme's gatsby-config.js
module.exports = (themeOptions) => ({
  plugins: [
    { resolve: `gatsby-source-filesystem`, options: { path: themeOptions.contentPath } },
  ],
})

解決アルゴリズムはテーマツリーを深さ優先でたどり、すべての設定を収集したうえで mergeGatsbyConfig によってマージします。テーマは別のテーマに依存でき、その依存関係は推移的に解決されます。

flowchart TD
    A["User's gatsby-config"] --> B["resolveTheme('gatsby-theme-blog')"]
    B --> C["Theme's gatsby-config(options)"]
    C --> D["resolveTheme('gatsby-theme-core')"]
    D --> E["Core theme's gatsby-config"]
    E --> F["mergeGatsbyConfig(core, blog)"]
    F --> G["mergeGatsbyConfig(merged, user)"]
    G --> H["Final merged config"]

コンポーネントシャドウイング

コンポーネントシャドウイングは、テーマをフォークせずにカスタマイズできる仕組みです。テーマが gatsby-theme-blog/src/components/bio.js にコンポーネントを定義している場合、ユーザーサイトで src/gatsby-theme-blog/components/bio.js を作成するだけで上書きできます。

この仕組みを支えているのは、内部プラグイン webpack-theme-component-shadowing が注入する webpack resolve プラグインです。webpack がテーマの src/ ディレクトリからのインポートを解決しようとすると、このプラグインがユーザーの src/{theme-name}/ ディレクトリにシャドウファイルが存在するか確認し、存在する場合はインポート先をそちらにリダイレクトします。

ポイント: コンポーネントシャドウイングには厳格なパス規約があります。src/{theme-name}/{テーマ src 内のパス} という形式で、テーマ名はプラグイン・テーマのパッケージ名と完全に一致している必要があります。シャドウが効かない場合は、ディレクトリ名のミスマッチを確認しましょう。

Source・Transformer プラグインのパターン

Gatsby のデータレイヤーは、互いに連携する2種類のプラグインによって構築されます。

Source プラグイン

Source プラグインは sourceNodes を実装し、外部データからノードを生成します。典型例が gatsby-source-filesystem で、ローカルファイルシステムから File ノードを作成します。

この実装が面白いのは、内部で XState マシンを使っている点です(4行目)。chokidar の「準備完了/未完了」状態を管理し、キューに積まれたファイル操作を安全にフラッシュするために用いられています。

sequenceDiagram
    participant Core as Gatsby Core
    participant Source as gatsby-source-filesystem
    participant FS as File System
    participant Redux as Redux Store

    Core->>Source: sourceNodes({ actions, createNodeId })
    Source->>FS: chokidar.watch(path)
    loop For each file
        FS->>Source: file detected
        Source->>Source: createFileNode(path)
        Source->>Redux: actions.createNode(fileNode)
    end
    Note over Source: Machine transitions to "ready"
    Source-->>Core: Promise resolves

基本的なパターンは次のとおりです。ファイル監視ライブラリを使い、internal.typeinternal.contentDigest・各種メタデータフィールドを持つ構造化ノードを作成し、createNode で dispatch します。

Transformer プラグイン

Transformer プラグインは onCreateNode(フィルタリングには shouldOnCreateNode)を実装し、親ノードから子ノードを生成します。典型例が gatsby-transformer-remark です。

const { onCreateNode, shouldOnCreateNode } = require(`./on-node-create`)
exports.onCreateNode = onCreateNode
exports.shouldOnCreateNode = shouldOnCreateNode
exports.createSchemaCustomization = require(`./create-schema-customization`)
exports.setFieldsOnGraphQLNodeType = require(`./extend-node-type`)

shouldOnCreateNode のエクスポートはパフォーマンス最適化のためのものです。プラグインが対象としないノードに対して onCreateNode の呼び出しをスキップできるため、フルハンドラーの読み込みコストを避けられます。

flowchart LR
    A["gatsby-source-filesystem"] -->|"Creates File nodes"| B["File node<br/>(mediaType: text/markdown)"]
    B -->|"onCreateNode"| C["gatsby-transformer-remark"]
    C -->|"Creates child"| D["MarkdownRemark node"]
    D -->|"setFieldsOnGraphQLNodeType"| E["html, excerpt,<br/>frontmatter fields"]

親子関係はデータ設計の核心です。MarkdownRemark ノードは親の File ノードを参照しており、Gatsby は @childOf スキーマ拡張を通じて File 型に childMarkdownRemark および childrenMarkdownRemark の便利フィールドを自動的に追加します。

ページモード:SSG、DSG、SSR

Gatsby はコンポーネントのエクスポートを検査して、3つのレンダリング戦略を選択します。解決ロジックは packages/gatsby/src/utils/page-mode.ts にあります。

flowchart TD
    A["Page component"] --> B{"Exports getServerData?"}
    B -->|Yes| C["SSR"]
    B -->|No| D{"Exports config?"}
    D -->|Yes| E{"config().defer === true?"}
    D -->|No| F{"page.defer === true?"}
    E -->|Yes| G["DSG"]
    E -->|No| H["SSG"]
    F -->|Yes| G
    F -->|No| H

37〜80行目resolvePageMode 関数がこの決定ツリーを実装しています。

モード トリガー ビルド時の挙動
SSG(静的サイト生成) デフォルト クエリはビルド時に実行され、HTML もビルド時に生成される
DSG(遅延静的生成) defer: true を持つ config エクスポート ビルド時はスキップされ、初回リクエスト時に生成される
SSR(サーバーサイドレンダリング) getServerData エクスポート クエリと HTML はリクエストごとに生成される

69〜77行目には重要な処理があります。/404.html/500.html のステータスページは、コンポーネントのエクスポートに関わらず SSG に強制設定され、警告が表示されます。

if (pageMode !== `SSG` && (page.path === `/404.html` || page.path === `/500.html`)) {
  reportOnce(`Status page "${page.path}" ignores page mode ("${pageMode}")...`)
  pageMode = `SSG`
}

これは理にかなった設計です。エラーページはすぐに表示できなければならず、サーバーの稼働に依存してはいけません。

第2回で見たように、ビルドパイプラインはページモードを使ってクエリをフィルタリングします。gatsby build 中にクエリを実行するのは SSG ページのみで、DSG と SSR のページは別途バンドルされるレンダリングエンジンが処理します。

デプロイアダプター

アダプターシステムは、プラットフォームに依存しないデプロイを実現する Gatsby の解答です。デプロイ先をハードコードするのではなく、Gatsby は抽象的なマニフェストをビルドし、それをアダプターがプラットフォーム固有の設定に変換します。

packages/gatsby/src/utils/adapter/types.tsの型定義がそのコントラクトを定めています。

export type Route = IStaticRoute | IFunctionRoute | IRedirectRoute
export type RoutesManifest = Array<Route>

3種類のルート型は、URL をどのように処理するかに対応しています。

ルート型 プロパティ 対応する処理
IStaticRoute path, filePath, headers 静的ファイルの配信
IFunctionRoute path, functionId, cache サーバーレス関数の呼び出し
IRedirectRoute path, toPath, status, headers HTTP リダイレクト

packages/gatsby/src/utils/adapter/manager.tsのアダプターマネージャーは、ビルド出力からこれらのマニフェストを構築します。49行目setAdapter 関数は互換性の検証も行います。

flowchart TD
    A["Build Output"] --> B["Adapter Manager"]
    B --> C["RoutesManifest"]
    B --> D["FunctionsManifest"]
    B --> E["HeaderRoutes"]

    C --> F["Adapter.adapt()"]
    D --> F
    E --> F

    F --> G["Platform-specific config<br/>(e.g., _redirects, netlify.toml)"]

アダプターはサポートする機能の制限を宣言できます。たとえば pathPrefix をサポートしないアダプターや、特定の trailingSlash オプションにしか対応しないアダプターがあります。マネージャーは71〜99行目でこれらをチェックし、非互換性を警告します。

アダプターは特定プラグインの無効化をリクエストすることもできます(111〜119行目)。これは Redux ストアへの DISABLE_PLUGINS_BY_NAME のディスパッチとして実現されています。プラットフォームアダプターが置き換えるプラグインを穏やかに無効化する仕組みです。

ポイント: カスタムアダプターを開発する場合は、モノリポ内の gatsby-adapter-netlify を参照してください。これがリファレンス実装であり、抽象マニフェストを Netlify の _redirects ファイルや _headers ファイルに変換する方法を実際のコードで確認できます。

シリーズのまとめ

5つの記事を通じて、Gatsby のアーキテクチャを外側から内側へと丁寧に辿ってきました。グローバル CLI からモノリポ構造、ビルドパイプライン、XState による開発サーバー、Redux/LMDB/GraphQL によるデータレイヤー、そして最後にプラグインとデプロイシステムまでを解説しました。

この深掘りを通じて、いくつかの共通するテーマが浮かび上がります。

関心の分離は真剣に設計されています。 CLI は webpack を知りません。ステートマシンは HTML 生成を知りません。プラグインシステムは LMDB を知りません。各レイヤーは Redux アクション、サービス関数、マニフェスト型といったクリーンなインターフェースを通じて連携しています。

ビルドと開発の分離は本質的なものです。 これは同じものの2つのフレーバーではなく、異なる問題に対する異なるアーキテクチャです。逐次パイプラインはスループットを最適化し、ステートマシンはレスポンシブ性を最適化します。

コンポジションが設計哲学の根幹です。 Lerna パッケージからプラグイン API、テーマシャドウイング、デプロイアダプターに至るまで、あらゆるレイヤーがコンポーズ・拡張・置き換えを前提に設計されています。エラーページ、ファイルシステムルーティング、Babel 設定といった Gatsby 自身の機能さえも、プラグインとして実装されているのはその証です。

Gatsby コアへの貢献、プラグインの開発、あるいはビルドの問題をデバッグしているときも、これらのアーキテクチャレイヤーを知っていれば、これほど複雑なオープンソース JavaScript プロジェクトの中を迷わずナビゲートするための確かな地図になるはずです。