Read OSS

从 CLI 到 HTML:追踪 `gatsby build` 的构建流水线

中级

前置知识

  • 第 1 篇:架构与 Monorepo 概览
  • webpack 基础概念(loaders、plugins)
  • 对构建流水线有基本了解

从 CLI 到 HTML:追踪 gatsby build 的构建流水线

在上一篇中我们已经熟悉了 monorepo 的整体结构。这一篇,我们来完整追踪 gatsby build 的执行过程——从命令处理函数被触发,到最终 HTML 文件落地 public/ 目录。这条路径横跨数十个文件,但理解它至关重要,因为 Gatsby 的每一个功能,最终都以流水线中某个阶段的形式呈现出来。

构建命令流水线

packages/gatsby/src/commands/build.ts 中的构建命令处理函数导出为一个单一的异步函数,整体结构是线性的——每个阶段完成后才进入下一个。

flowchart TD
    A["bootstrap()"] --> B["onPreBuild hook"]
    B --> C["writeOutRequires()"]
    C --> D["buildProductionBundle()"]
    D --> E["buildRenderer()"]
    E --> F["preparePageTemplateConfigs()"]
    F --> G["Build Rendering Engines"]
    G --> H["calculateDirtyQueries()"]
    H --> I["Run Queries"]
    I --> J["buildHTMLPagesAndDeleteStaleArtifacts()"]
    J --> K["onPostBuild hook"]
    K --> L["adapterManager.adapt()"]

整条流水线可以分为三个宏观阶段:

  1. Bootstrap(第 127–131 行):初始化插件、拉取数据、构建 schema、创建页面、提取查询
  2. Bundle(第 153–295 行):四次 webpack 编译,分别产出 JS bundle 和 HTML 渲染器
  3. Render(第 302–511 行):执行查询、生成 HTML、通过 adapter 完成部署

下面逐一深入每个阶段。

初始化服务:配置、主题与插件

第 127 行的 bootstrap() 调用会委托给第 1 篇中介绍的 bootstrap 编排器,后者再调用 initialize()——这是整个代码库中最复杂的函数。它按顺序完成以下工作:

sequenceDiagram
    participant Build as build.ts
    participant Bootstrap as bootstrap/index.ts
    participant Init as services/initialize.ts
    participant Parcel as compileGatsbyFiles
    participant Config as load-config
    participant Themes as load-themes
    participant Plugins as load-plugins

    Build->>Bootstrap: bootstrap({ program })
    Bootstrap->>Init: initialize(context)
    Init->>Parcel: compileGatsbyFiles(siteDirectory)
    Note over Parcel: Compile gatsby-config.ts, gatsby-node.ts
    Init->>Config: loadConfig({ siteDirectory })
    Config->>Themes: loadThemes(config)
    Note over Themes: Recursive theme resolution
    Themes-->>Config: Merged config
    Config-->>Init: Validated config
    Init->>Plugins: loadPlugins(config, siteDirectory)
    Note over Plugins: Normalize → Validate → Flatten
    Init->>Init: startPluginRunner()
    Init-->>Bootstrap: { store, workerPool }

Parcel 编译

在读取配置之前,Gatsby 需要先编译 TypeScript 和 ESM 文件。在 initialize.ts 的第 170–173 行compileGatsbyFiles 使用 Parcel 将 gatsby-config.tsgatsby-node.ts 转换为 Node 可以直接 require 的 CommonJS 格式。这也是你能用 TypeScript 编写 Gatsby 配置的原因——Parcel 会在其他任何步骤执行之前完成转译工作。

配置加载

配置加载器(packages/gatsby/src/bootstrap/load-config/index.ts)完成三件事:

  1. 读取编译后的 gatsby-config 文件
  2. 通过 handleFlags 处理 feature flags
  3. 将配置传入 loadThemes 进行主题解析

根配置不能是函数形式——只有主题配置才可以是函数(接收主题选项作为参数)。这一差异在第 28–36 行有明确的校验逻辑。

主题解析

packages/gatsby/src/bootstrap/load-themes/index.ts 中的主题解析是递归进行的。主题本质上就是拥有自己 gatsby-config 的 Gatsby 插件,主题之间可以互相依赖,每个主题的配置都会与父级合并。解析算法以深度优先的方式遍历依赖树,收集所有主题配置,再自底向上进行合并。

插件加载

插件加载器(packages/gatsby/src/bootstrap/load-plugins/index.ts)接收合并后的配置,依次完成:

  1. 规范化:将字符串形式的插件引用转换为 { resolve, options } 对象
  2. 校验:对照 schema 验证插件选项
  3. 加载内置插件(随 Gatsby 一同打包的插件)
  4. 扁平化:将插件树展平为单一数组
  5. 整理:记录每个插件实现了哪些 API
  6. 验证导出:与已知 API 列表(api-node-docsapi-browser-docsapi-ssr-docs)进行比对

最终结果是一个扁平数组,以 SET_SITE_FLATTENED_PLUGINS 的形式分发到 Redux(第 79–82 行)。这个数组是所有插件及其实现 API 的权威注册表。

API Runner:连接核心与插件的桥梁

每当 Gatsby 需要调用插件钩子时——无论是 sourceNodescreatePages,还是 onCreateWebpackConfig——都会经过 packages/gatsby/src/utils/api-runner-node.js 中的 API runner。

API runner 是 Gatsby 插件架构的中枢神经。每次 API 调用时,它会:

  1. 从扁平插件列表中查找实现了目标 API 的插件
  2. 为每个插件构建一个包含以下内容的上下文对象
    • 绑定好的 action creators(createNodecreatePagecreateRedirect 等)
    • 数据访问函数(getNodegetNodesgetNodesByType
    • 工具函数(createNodeIdcreateContentDigest
    • schema 类型构建器(buildObjectTypebuildUnionType 等)
    • 作用域限定于该插件的 cache 实例
    • 用于结构化日志的 reporter
  3. 顺序(而非并行)调用每个插件的实现
sequenceDiagram
    participant Core as Gatsby Core
    participant Runner as api-runner-node
    participant PluginA as gatsby-source-filesystem
    participant PluginB as gatsby-transformer-remark
    participant Redux as Redux Store

    Core->>Runner: apiRunnerNode("sourceNodes", { parentSpan })
    Runner->>Runner: Look up plugins implementing "sourceNodes"
    Runner->>Runner: Construct context (actions, getNode, cache...)
    Runner->>PluginA: sourceNodes(context)
    PluginA->>Redux: createNode(fileNode)
    PluginA-->>Runner: done
    Runner->>PluginB: sourceNodes(context)
    PluginB-->>Runner: done
    Runner-->>Core: results

传递给插件的 action creators 经过了"双重绑定":首先通过 bindActionCreators 绑定到 Redux,然后再经过一个包装函数绑定到具体的插件和 API 调用,注入 traceIdplugin.namedeferNodeMutation 等元数据(第 87–100 行)。这确保了插件分发的每一个 action 都携带可追溯的来源信息。

提示: 插件顺序执行是有意为之的设计——它保证了行为的确定性。插件 A 的 sourceNodes 总会在插件 B 之前运行,当插件之间存在节点依赖时,这一点尤为重要。

四个 Webpack 阶段

Gatsby 使用四套独立的 webpack 配置,各有其用途。packages/gatsby/src/utils/webpack.config.js 中的工厂函数接收 stage 参数,并据此生成对应的配置:

flowchart TD
    subgraph "Development"
        A["develop"] -->|"Hot reload, CSS injection"| A1["Browser bundle"]
        B["develop-html"] -->|"No HMR, SSR target"| B1["HTML renderer"]
    end

    subgraph "Production"
        C["build-javascript"] -->|"Minified, chunked"| C1["Browser JS/CSS"]
        D["build-html"] -->|"Node target, SSR"| D1["HTML renderer"]
    end

    style A fill:#e8f5e9
    style B fill:#e8f5e9
    style C fill:#e1f5fe
    style D fill:#e1f5fe
阶段 目标环境 用途
develop Browser 开发服务器,支持 React Fast Refresh 和 CSS 热更新
develop-html Node 开发模式下的 SSR 渲染器(不含 HMR 插件)
build-javascript Browser 生产环境 JS 和 CSS bundle,支持代码分割
build-html Node 用于静态生成的生产环境 HTML 渲染器

第 40–44 行的注释写得相当清楚:

// Four stages or modes:
//   1) develop: for `gatsby develop` command, hot reload and CSS injection into page
//   2) develop-html: same as develop without react-hmre in the babel config for html renderer
//   3) build-javascript: Build JS and CSS chunks for production
//   4) build-html: build all HTML files

插件可以通过 onCreateWebpackConfig 钩子修改任意阶段的配置,该钩子会接收 stage 参数,因此插件可以针对不同阶段做差异化处理。

在构建命令中,只会用到生产环境的两个阶段。第 160 行buildProductionBundle 运行 build-javascript 阶段,第 184 行buildRenderer 运行 build-html 阶段。

查询执行与 HTML 生成

webpack bundle 就绪后,Gatsby 进入查询执行阶段。第 302 行calculateDirtyQueries 负责判断哪些查询需要重新执行(在增量构建中,只有发生变化的查询才会运行)。

第 306–308 行有一处关键优化:

queryIds.pageQueryIds = queryIds.pageQueryIds.filter(
  query => getPageMode(query) === `SSG`
)

构建时只会执行 SSG 页面的查询。DSG 页面将查询延迟到首次请求时执行,SSR 页面则在每次请求时都执行查询。这正是渲染模式系统的核心机制——我们将在第 5 篇中详细介绍。

对于多核机器,查询会在 worker pool 中并行执行(第 330 行)。查询执行完毕后,第 507 行buildHTMLPagesAndDeleteStaleArtifacts 负责生成最终的 HTML 文件。

缓存管理与构建持久化

在整个构建过程中,状态会通过 LMDB 持久化到 .cache/ 目录。packages/gatsby/src/redux/index.ts 中的 Redux store 明确定义了哪些状态切片需要持久化:

const persistedReduxKeys = [
  `nodes`, `typeOwners`, `statefulSourcePlugins`, `status`,
  `components`, `jobsV2`, `staticQueryComponents`,
  `webpackCompilationHash`, `pageDataStats`, `pages`,
  `staticQueriesByTemplate`, `pendingPageDataWrites`,
  `queries`, `html`, `slices`, `slicesByTemplate`,
]

下次构建时,这些切片会在 store 创建阶段通过 readState() 从 LMDB 中读回。Gatsby 正是通过对比当前状态与持久化状态,来判断哪些查询"需要重新执行"、哪些 HTML 文件"已过期"。

flowchart LR
    subgraph ".cache/ Directory"
        A["data/datastore (LMDB)"] -->|Nodes| B["Redux State"]
        C["caches-lmdb/"] -->|Plugin caches| D["Per-plugin data"]
        E["redux.state (deprecated)"] -.->|Legacy| B
    end

    subgraph "Build"
        F["Previous Build State"] --> G["Calculate Dirty Queries"]
        G --> H["Only rebuild changed pages"]
    end

    B --> F

第 133–146 行saveState() 函数使用 writeToCache 将上述 key 对应的状态写入缓存。值得注意的是其中有一个 GATSBY_DISABLE_CACHE_PERSISTENCE 开关——这是为了解决超大型站点在 Node.js v8.serialize 序列化时遇到的 buffer 大小限制问题而添加的。

Adapter 集成

构建流水线的最后一步是通过 adapter 完成部署。第 645–648 行

if (adapterManager) {
  await adapterManager.storeCache()
  await adapterManager.adapt()
}

adapter manager 在初始化阶段创建(initialize.ts 第 189–192 行),它从构建产物中收集 RoutesManifestFunctionsManifest,再传递给对应平台的 adapter,由后者将其转换为部署平台的原生格式。我们将在第 5 篇中深入介绍 adapter 机制。

提示: 如果构建在中途失败,.cache/ 目录可能残留不完整的状态。运行 gatsby clean 可以将其彻底清除,强制进行完整重建。遇到难以排查的缓存相关问题时,这是最有效的"重置"手段。

下一篇

至此,我们已经梳理了构建流水线作为线性传送带的完整运转过程。但 gatsby develop 面临的是一个更具挑战性的问题:它需要在实时响应变更的同时保持状态的一致性。下一篇,我们将深入探讨协调开发服务器的 XState 状态机——这是整个代码库中架构上最具特色的部分。