Read OSS

Gatsby 的架构:探索一个拥有 105 个包的 Monorepo

中级

前置知识

  • React 基础知识
  • 熟悉 npm/yarn 包管理
  • 了解 monorepo 的基本概念

Gatsby 的架构:探索一个拥有 105 个包的 Monorepo

Gatsby 是迄今为止最具雄心的开源 JavaScript 项目之一。在我们熟悉的 gatsby buildgatsby develop 命令背后,是一个包含约 105 个包的 monorepo,涵盖 source 插件、transformer 插件、GraphQL 数据层、基于 XState 的开发服务器,以及完整的部署适配器抽象。理解这些模块如何协同运作,是深度参与贡献或充分利用这个框架的关键所在。

本文是五篇系列文章的第一篇。我们将从俯瞰整个仓库结构开始,追踪一条 CLI 命令从全局安装到项目本地执行的完整路径,并介绍构建流水线与开发服务器之间的核心架构差异——这一差异贯穿后续所有内容。

Monorepo 结构:Lerna + Yarn Workspaces

Gatsby 使用 Lerna 配合 Yarn Workspaces 来管理各个包。根目录的配置十分简洁,但已足以说明整体结构:

lerna.json

{
  "packages": ["packages/*"],
  "npmClient": "yarn",
  "useWorkspaces": true,
  "version": "independent"
}

其中 "version": "independent" 这一行至关重要——每个包独立进行版本管理和发布。这样一来,核心框架的变更就不会强制触发所有生态插件的版本升级。根目录 package.json 中配置的 Yarn Workspaces 则负责处理符号链接,让各包之间可以直接相互 require,无需先发布到 npm。

graph TD
    subgraph "Core"
        gatsby["gatsby"]
        cli["gatsby-cli"]
        coreutils["gatsby-core-utils"]
        pluginutils["gatsby-plugin-utils"]
    end

    subgraph "Source Plugins"
        fs["gatsby-source-filesystem"]
        contentful["gatsby-source-contentful"]
        drupal["gatsby-source-drupal"]
        wordpress["gatsby-source-wordpress"]
    end

    subgraph "Transformer Plugins"
        remark["gatsby-transformer-remark"]
        sharp["gatsby-transformer-sharp"]
        yaml["gatsby-transformer-yaml"]
    end

    subgraph "Feature Plugins"
        image["gatsby-plugin-image"]
        mdx["gatsby-plugin-mdx"]
        feed["gatsby-plugin-feed"]
        offline["gatsby-plugin-offline"]
    end

    subgraph "Adapters"
        netlify["gatsby-adapter-netlify"]
    end

    cli --> gatsby
    gatsby --> coreutils
    gatsby --> pluginutils
    fs --> coreutils
    remark --> fs

这些包自然地划分为三个层级:

层级 示例 职责
核心层 gatsbygatsby-cligatsby-core-utilsgatsby-plugin-utilsgatsby-page-utils 框架运行时、CLI、共享工具
生态插件层 gatsby-source-filesystemgatsby-transformer-remarkgatsby-plugin-image 数据采集、转换与功能扩展
适配器与工具层 gatsby-adapter-netlifygatsby-graphiql-explorercreate-gatsby 部署适配器、工具链、脚手架

提示: 想知道某个功能在哪个包里?命名规范就是你的向导。gatsby-source-* 负责数据采集,gatsby-transformer-* 负责节点转换,gatsby-plugin-* 负责添加构建或运行时功能,gatsby-adapter-* 则处理各部署平台的特定逻辑。

CLI 委托模式

Gatsby 架构中最精妙的设计之一,是它的两阶段 CLI 解析机制。实际上存在两个 CLI:一个全局安装的 gatsby-cli 包,以及一个项目本地的 gatsby 包。当你执行 gatsby build 时,全局 CLI 并不会自己执行构建逻辑——它会将任务委托给项目本地安装的版本。

第一阶段:全局入口

全局入口位于 packages/gatsby-cli/src/index.ts,职责虽然简单,却至关重要:

  1. Node.js 版本校验(第 22–38 行):确保运行时满足最低版本要求
  2. 全局错误处理:注册 unhandledRejectionuncaughtException 处理器
  3. CLI 初始化:在第 76 行调用 createCli(process.argv)
sequenceDiagram
    participant User
    participant GlobalCLI as gatsby-cli (global)
    participant Yargs
    participant LocalGatsby as gatsby (local)

    User->>GlobalCLI: gatsby build
    GlobalCLI->>GlobalCLI: Check Node.js version
    GlobalCLI->>GlobalCLI: Set up error handlers
    GlobalCLI->>Yargs: createCli(process.argv)
    Yargs->>GlobalCLI: resolveLocalCommand("build")
    GlobalCLI->>LocalGatsby: resolveCwd.silent("gatsby/dist/commands/build")
    LocalGatsby-->>GlobalCLI: build command handler
    GlobalCLI->>LocalGatsby: Execute build(args)

第二阶段:本地命令解析

核心逻辑在 packages/gatsby-cli/src/create-cli.ts 中实现。resolveLocalCommand 函数通过 resolveCwd.silent() 找到项目本地安装的 gatsby:

const cmdPath =
  resolveCwd.silent(`gatsby/dist/commands/${command}`) ||
  // Old location of commands
  resolveCwd.silent(`gatsby/dist/utils/${command}`)

这一模式确保了全局安装的 gatsby-cli@5.x 能够与项目中 node_modules 里的 gatsby@4.x 正常协作。全局 CLI 只是一个轻量的路由器,实际的命令逻辑来自项目所依赖的 gatsby 版本。

项目本地的 gatsby 包也有自己的 bin 入口(packages/gatsby/cli.js)——仅三行代码,直接 require ./dist/bin/gatsby.js。这是通过 npx gatsby build 直接运行时的备用路径。

提示: 排查 CLI 问题时,务必确认 resolveLocalCommand 实际解析到的是哪个 gatsby 版本。全局 CLI 的期望与本地包导出之间的版本不匹配,是许多令人困惑的报错的常见根源。

构建 vs 开发:两种架构

Gatsby 真正有趣的设计在这里展开。gatsby buildgatsby develop 不仅输出结果不同——它们采用的是截然不同的架构模式。

gatsby build:顺序执行的命令式流水线

packages/gatsby/src/commands/build.ts 中的构建命令是一个直观的 async 函数,按顺序依次执行各个步骤:

flowchart LR
    A[bootstrap] --> B[writeOutRequires]
    B --> C[Build JS Bundle]
    C --> D[Build HTML Renderer]
    D --> E[Run Queries]
    E --> F[Generate HTML]
    F --> G[onPostBuild]
    G --> H[Adapter Deploy]

每个步骤完成后才开始下一步,没有并发,没有事件处理,也没有状态机。这是一条经典的构建流水线:加载配置 → 采集数据 → 构建 schema → 创建页面 → 打包 JS → 渲染 HTML → 部署。

gatsby develop:基于 XState 的响应式状态机

开发命令的架构则截然不同。它需要处理一个本质上具有响应式特征的问题:文件会变更、webhook 会触发、GraphQL mutation 会执行,开发服务器必须响应所有这些事件——有时是同时响应,有时是在另一次重新构建仍在进行时响应。

packages/gatsby/src/commands/develop.ts 会启动一个 ControllableScript 子进程,在该子进程(packages/gatsby/src/commands/develop-process.ts)内部,一个 XState 状态机被创建并开始运行:

const machine = developMachine.withContext({
  program,
  parentSpan,
  app,
  reporter,
  pendingQueryRuns: new Set([`/`]),
  shouldRunInitialTypegen: true,
})

const service = interpret(machine)
service.start()

这并非一个表面上的设计选择——而是架构层面的必然。我们将在第三篇文章中深入探讨这个状态机。

flowchart TD
    subgraph "Parent Process (develop.ts)"
        A[ControllableScript] -->|IPC| B[Child Process]
        A -->|heartbeat| C[Crash Detection]
    end

    subgraph "Child Process (develop-process.ts)"
        B --> D[XState developMachine]
        D --> E[initializing]
        E --> F[initializingData]
        F --> G[runningQueries]
        G --> H[startingDevServers]
        H --> I[waiting]
        I -->|file change| J[recompiling]
        I -->|node mutation| K[recreatingPages]
        J --> I
        K --> G
    end

Gatsby 的核心:packages/gatsby/src/ 目录结构

packages/gatsby/src/ 目录是框架核心逻辑的所在地。目录体量庞大,但内部结构遵循清晰的规律。

目录 职责
bootstrap/ 初始化流水线:配置加载、插件加载、主题解析
commands/ CLI 命令处理器:build.tsdevelop.tsserve.tsclean.ts
services/ 作为独立函数的各构建阶段:initializesourceNodesbuildSchema
state-machines/ 用于 develop 的 XState 状态机:develop/data-layer/query-running/waiting/
redux/ Redux store、reducer、action creator、类型定义与持久化
schema/ GraphQL schema 的构建、推断、扩展与 resolver
query/ Query 的提取、编译、验证与执行
datastore/ 基于 LMDB 的持久化节点存储
internal-plugins/ 随 Gatsby 一同打包的内置插件,使用其自身的插件 API
utils/ 大量工具函数:webpack 配置、API runner、页面数据、适配器等

Bootstrap 编排器

Bootstrap 序列——构建与开发共用的初始化流水线——定义在 packages/gatsby/src/bootstrap/index.ts 中:

const context = {
  ...bootstrapContext,
  ...(await initialize(bootstrapContext)),
}
await customizeSchema(context)
await sourceNodes(context)
await buildSchema(context)
// ... createPages, extractQueries, etc.

../services 导入的每个函数代表一个独立的构建阶段。这种服务函数模式使各阶段具备可复用性——同一个 sourceNodes 服务既用于构建流水线,也用于开发状态机。

flowchart TD
    A[initialize] --> B[customizeSchema]
    B --> C[sourceNodes]
    C --> D[buildSchema]
    D --> E[createPages]
    E --> F[extractQueries]
    F --> G[writeOutRedirects]
    G --> H[postBootstrap]

    style A fill:#e1f5fe
    style C fill:#e8f5e9
    style D fill:#e8f5e9
    style E fill:#fff3e0
    style F fill:#fff3e0

下一篇

在下一篇文章中,我们将从头到尾追踪一条 gatsby build 命令的完整执行路径——跟随数据流经 initialize 服务(基于 Parcel 的编译、配置加载、主题解析),穿过连接核心与插件的 API runner 桥接层,历经四个不同的 webpack 阶段,最终生成 HTML 文件。我们还会深入探讨 .cache/ 目录是如何支撑增量构建的。