Read OSS

扩展 Gatsby:Plugin API、主题系统与部署适配器

中级

前置知识

  • 本系列第 1-4 篇文章
  • 从用户角度对 Gatsby 插件使用有基本了解

扩展 Gatsby:Plugin API、主题系统与部署适配器

每一个 Gatsby 站点,本质上都是插件的组合体。即便是最简单的站点,也会运行十几个内部插件,涵盖文件系统路由、错误页面、Babel 配置以及 webpack 主题 shadowing 等功能。理解插件系统,不只是插件作者的必修课——要真正搞懂 Gatsby 的运作原理,这一关也绕不过去。

在本系列的最后一篇文章中,我们将完整梳理 Gatsby 的可扩展性体系:插件是如何被发现和加载的、插件可以实现的三个 API 接口、主题如何通过递归配置合并与组件 shadowing 进行组合、source 和 transformer 插件的标准模式、SSG/DSG/SSR 页面渲染模式系统,以及将 Gatsby 与具体托管平台解耦的部署适配器抽象。

插件解析与三个 API 接口

在第 2 篇中我们已经看到,插件加载器位于 packages/gatsby/src/bootstrap/load-plugins/index.ts,在初始化阶段运行。其处理流程为:normalizevalidate optionsload internalsflattencollate APIsvalidate exports

collatePluginAPIs 步骤会对每个插件的导出内容逐一检查,与以下三个有据可查的 API 接口进行比对:

接口 文件 核心 API 运行环境
Node gatsby-node.js sourceNodescreatePagesonCreateNodeonCreateWebpackConfigcreateSchemaCustomization Node.js(构建时)
Browser gatsby-browser.js onClientEntrywrapPageElementwrapRootElementonRouteUpdate 浏览器(客户端运行时)
SSR gatsby-ssr.js onRenderBodyonPreRenderHTMLwrapPageElementwrapRootElement Node.js(HTML 生成)

第 64–76 行的校验逻辑会收集"非法导出"——即插件中不符合任何已知 API 名称的导出项。这一机制能有效捕获拼写错误(例如写成 exports.createPage 而非 exports.createPages),帮助插件作者与框架规范保持一致。

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" },
}

提示: handleMultipleReplaceRenderers 检查(第 74 行)确保最多只有一个插件实现 replaceRenderer API。如果多个插件同时尝试替换渲染器(例如两种不同的 HTML 渲染策略),Gatsby 会在启动时给出清晰的错误提示,而不是等到构建阶段才报错。

内部插件:架构设计的范本

Gatsby 通过位于 packages/gatsby/src/internal-plugins/ 的内部插件大量使用自身的 Plugin API。这些插件由 loadInternalPlugins 自动加载,是理解插件契约的最佳参考。

内部插件 用途
internal-data-bridge 从 Redux state 创建 SiteSitePage 节点
dev-404-page 生成包含路由列表的开发环境 404 页面
prod-404-500 生成生产环境的 404 和 500 错误页面
load-babel-config 加载并合并 Babel 配置
bundle-optimisations 配置 webpack bundle 拆分策略
webpack-theme-component-shadowing 为主题启用组件 shadowing 功能
functions 处理 Gatsby Functions(serverless)
partytown 集成 Partytown,将脚本移至主线程之外执行

其中 webpack-theme-component-shadowing 插件尤为值得深入研究。它的 gatsby-node.js 位于 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 自己吃自己狗粮的体现——组件 shadowing 功能本身就是作为插件实现的,用的正是任何第三方插件都可以使用的 onCreateWebpackConfig API。

主题系统:递归配置合并与组件 Shadowing

Gatsby 中的主题是拥有自己 gatsby-config 的插件。这个简单的定义,赋予了主题系统强大的组合能力。

递归配置合并

loadThemes 遇到携带 gatsby-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"]

组件 Shadowing

组件 shadowing 是让主题在无需 fork 的前提下实现自定义的核心机制。如果一个主题在 gatsby-theme-blog/src/components/bio.js 定义了某个组件,用户站点只需创建 src/gatsby-theme-blog/components/bio.js 即可覆盖它。

这一功能依赖内部插件 webpack-theme-component-shadowing 注入的 webpack resolve 插件来实现。当 webpack 解析来自主题 src/ 目录的 import 时,该插件会检查用户的 src/{theme-name}/ 目录下是否存在对应的 shadow 文件,如果有,则将 import 重定向到该文件。

提示: 组件 shadowing 遵循严格的文件路径约定:src/{theme-name}/{path-within-theme-src}。其中主题名称必须与插件/主题的包名完全一致。如果你的 shadow 没有生效,请仔细检查目录名是否拼写有误。

Source 与 Transformer 插件模式

Gatsby 的数据层由两类协同工作的插件共同填充。

Source 插件

Source 插件通过实现 sourceNodes 从外部数据创建节点。标准参考实现是 gatsby-source-filesystem,它从本地文件系统创建 File 节点。

其实现颇为有趣——它在内部使用了一个 XState 状态机(第 4 行)来管理 chokidar 的 ready/not-ready 状态,并刷新排队中的文件操作。

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 分发到 Redux store。

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 导出是一项性能优化——它让 Gatsby 跳过对插件不关心的节点调用 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 schema 扩展,自动在 File 类型上添加 childMarkdownRemarkchildrenMarkdownRemark 便捷字段。

页面渲染模式:SSG、DSG 与 SSR

Gatsby 支持三种渲染策略,通过检查组件的导出内容来确定采用哪种。判断逻辑位于 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(延迟静态生成) config 导出包含 defer: true 构建时跳过,在首次请求时生成
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 篇中所见,构建流水线会根据页面模式来筛选需要执行的查询:只有 SSG 页面会在 gatsby build 期间运行查询,DSG 和 SSR 页面则由单独打包的渲染引擎负责处理。

部署适配器

适配器系统是 Gatsby 实现平台无关部署的解决方案。Gatsby 不再硬编码具体的部署目标,而是构建抽象的 manifest,再由适配器将其转换为特定平台的配置格式。

packages/gatsby/src/utils/adapter/types.ts 中的类型定义描述了这一契约:

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

三种路由类型分别对应不同的 URL 处理方式:

路由类型 属性 对应用途
IStaticRoute pathfilePathheaders 静态文件服务
IFunctionRoute pathfunctionIdcache Serverless 函数调用
IRedirectRoute pathtoPathstatusheaders HTTP 重定向

packages/gatsby/src/utils/adapter/manager.ts 中的适配器管理器负责从构建产物中组装这些 manifest。第 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 store 分发 DISABLE_PLUGINS_BY_NAME action。平台适配器正是通过这种方式,优雅地替换其所取代的插件。

提示: 如果你正在开发自定义适配器,建议先研究 monorepo 中的 gatsby-adapter-netlify。它是官方参考实现,清晰展示了如何将抽象 manifest 转换为 Netlify 的 _redirects_headers 文件。

系列总结

在这五篇文章中,我们从最外层的全局 CLI 出发,逐层深入 Gatsby 的架构:monorepo 结构、构建流水线、基于 XState 的开发服务器、Redux/LMDB/GraphQL 数据层,最终抵达插件与部署系统。

梳理下来,有几个贯穿始终的主题值得记住:

关注点分离是真实存在的。 CLI 不了解 webpack,状态机不了解 HTML 生成,插件系统不了解 LMDB。每一层都通过清晰的接口通信——Redux action、service 函数、manifest 类型。

构建与开发的分离是根本性的。 这不是同一事物的两种风格,而是针对不同问题设计的不同架构。顺序流水线为吞吐量而优化,状态机为响应性而优化。

组合是核心设计哲学。 从 Lerna packages 到 Plugin API,从主题 shadowing 到部署适配器,每一层的设计都以组合、扩展和替换为前提。就连 Gatsby 自身的功能(错误页面、文件系统路由、Babel 配置)也是以插件形式实现的。

无论你是在为 Gatsby 核心贡献代码、开发插件,还是只想排查一个构建问题,了解这些架构层次都能为你建立一张清晰的思维导图,帮助你在这个有史以来最复杂的开源 JavaScript 项目之一中自由穿行。