Read OSS

XState 驱动的开发模式:`gatsby develop` 如何编排响应式逻辑

高级

前置知识

  • 第 1 篇:架构与 Monorepo 概览
  • 第 2 篇:构建流水线与引导流程
  • XState 基础知识(状态、转换、服务、子状态机)
  • Node.js 子进程与 IPC 通信的基本概念

XState 驱动的开发模式:gatsby develop 如何编排响应式逻辑

如果说构建流水线是一条工厂装配线,那么开发服务器就是一座空中交通管制塔。文件随时可能发生变更,Webhook 不断抵达,GraphQL mutation 持续触发——开发服务器必须妥善应对所有这些事件,有时是并发的,有时还发生在上一次重建尚未完成之际。这正是状态机大显身手的场景。

Gatsby 的 gatsby develop 命令由一个 XState 层级状态机统一编排。这是开源项目中对 XState 最为复杂的生产级应用之一。深入理解它,你就会明白为什么"文件变更后直接重跑流水线"并不足以带来良好的开发体验。

为什么选择状态机?

来看看开发服务器需要处理哪些事件:

  • 查询运行期间,某个源文件发生了变更
  • Schema 自定义过程中,收到了一个 Webhook
  • 页面重建期间,触发了 createNode mutation
  • sourceNodes 执行期间,某个插件抛出了异常,但服务器应继续运行
  • 编辑器自动保存导致文件变更连续涌入

最朴素的做法——每次事件都重启流水线——不仅性能极差,还可能引发无限循环(源插件创建了一个文件,触发重建,重建又运行了源插件……)。

状态机为 Gatsby 带来了三项核心能力:

  1. 上下文感知的事件处理:同一事件(如 ADD_NODE_MUTATION)在不同状态下会触发不同行为
  2. 事件批处理:查询运行期间的多次文件变更会被积累,统一处理一次
  3. 无限循环检测:硬性上限阻止失控的重建循环

父子进程的隔离边界

在状态机启动之前,Gatsby 就已建立了进程隔离边界。develop.ts 中的命令处理器运行在父进程中,而实际的开发服务器则运行在子进程中。

sequenceDiagram
    participant Parent as develop.ts (Parent)
    participant Child as develop-process.ts (Child)

    Parent->>Parent: Detect port, resolve SSL
    Parent->>Child: new ControllableScript(...)
    Parent->>Child: start()
    loop Every 1 second
        Child->>Parent: { type: "HEARTBEAT" }
    end
    Child->>Parent: IPC messages (forwarded)
    Parent->>Child: IPC messages (forwarded)
    Note over Child: XState machine runs here

    Child--xParent: Process crashes
    Parent->>Parent: Detect missing heartbeat

ControllableScript 类(定义于 develop.ts 第 49–154 行)在 execa.node 之上封装了完整的生命周期管理。它将子进程的引导脚本写入 .cache/ 目录下的临时文件,以 IPC 模式(stdio: ['inherit', 'inherit', 'inherit', 'ipc'])启动子进程,并提供 startstopsendonMessageonExit 等方法。

develop-process.ts 第 38–45 行 中的心跳机制简洁而务实:

if (process.send) {
  setInterval(() => {
    process.send!({ type: `HEARTBEAT` })
  }, 1000)
}

注释说明了其设计初衷:当父进程被 SIGKILL 强制终止时,Node.js 不会自动终止已派生的子进程。心跳机制的妙处在于:父进程一旦消亡,IPC 通道随之关闭,心跳发送会抛出 ERR_IPC_CHANNEL_CLOSED 错误,从而附带终止了孤立的子进程。

提示: 进程隔离同时提供了内存隔离。如果某个源插件发生内存泄漏,影响范围仅限于子进程,父进程可以干净地将其重启。

developMachine:状态与转换

顶层状态机定义于 packages/gatsby/src/state-machines/develop/index.ts。让我们来梳理它的各个状态:

stateDiagram-v2
    [*] --> initializing
    initializing --> initializingData: DONE
    initializingData --> runningPostBootstrap: DONE
    runningPostBootstrap --> runningQueries
    runningQueries --> startingDevServers: first run, no compiler
    runningQueries --> recompiling: source files dirty
    runningQueries --> recreatingPages: nodes mutated
    runningQueries --> waiting: clean
    startingDevServers --> waiting
    startingDevServers --> initialGraphQLTypegen: typegen enabled
    initialGraphQLTypegen --> waiting
    recompiling --> waiting

    waiting --> runningQueries: EXTRACT_QUERIES_NOW
    waiting --> recreatingPages: mutations flushed

    recreatingPages --> runningQueries: DONE
    reloadingData --> runningQueries: DONE

    state "Global Events" as ge
    note right of ge
        WEBHOOK_RECEIVED → reloadingData
        ADD_NODE_MUTATION → batched
        SOURCE_FILE_CHANGED → marked dirty
    end note

全局事件处理器

在状态机配置的顶层(第 29–57 行),定义了三个全局事件处理器:

  • ADD_NODE_MUTATION:通过 addNodeMutation action 将 mutation 加入队列
  • SOURCE_FILE_CHANGED:通过 markSourceFilesDirty 将源文件标记为已变更
  • WEBHOOK_RECEIVED:立即转换至 reloadingData 状态

这些全局处理器可被各个子状态覆盖。例如,initializing 状态在 第 62–66 行 中明确将三者都设为 undefined

initializing: {
  on: {
    ADD_NODE_MUTATION: undefined,
    SOURCE_FILE_CHANGED: undefined,
    WEBHOOK_RECEIVED: undefined,
  },
  // ...
}

这样的设计合情合理:初始引导阶段完整的流水线本就会运行一遍,此时处理这些 mutation 毫无意义。

waiting 状态

waiting 状态(第 266–313 行)是开发服务器就绪后的空闲状态。它会调用子状态机 waitForMutations 来批量处理传入的节点 mutation。当积累的 mutation 达到阈值,或源文件发生变更时,子状态机完成并触发父状态机转换至 recreatingPages

第 267–273 行always 守卫提供了一条快速通道:如果在转换到 waiting 的过程中已有查询请求进入,则跳过等待,直接进入 runningQueries

waiting: {
  always: [
    {
      target: `runningQueries`,
      cond: ({ pendingQueryRuns }) =>
        !!pendingQueryRuns && pendingQueryRuns.size > 0,
    },
  ],
  // ...
}

子状态机:数据层与查询运行

开发状态机将复杂的工作流委托给子状态机,以 XState 服务的形式调用。主要有两类子状态机。

数据层状态机

数据层模块(packages/gatsby/src/state-machines/data-layer/index.ts)通过可组合的状态片段定义了三个状态机:

状态机 使用场景 状态流程
initializeDataMachine 首次启动 customizingSchema → sourcingNodes → buildingSchema → creatingPages → writingOutRedirects → done
reloadDataMachine 收到 Webhook customizingSchema → sourcingNodes → buildingSchema → creatingPages → done
recreatePagesMachine sourceNodes 之外发生节点 mutation buildingSchema → creatingPages → done

这种组合方式十分优雅——各状态被定义为独立片段(loadDataStatesinitialCreatePagesStatesrecreatePagesStatesdoneState),再按需组合:

export const initializeDataMachine = createMachine({
  initial: `customizingSchema`,
  states: {
    ...loadDataStates,
    ...initialCreatePagesStates,
    ...doneState,
  },
}, options)

正因如此,recreatePagesMachine 完全跳过了开销较大的 customizingSchemasourcingNodes 步骤——当 mutation 发生在 sourceNodes 之外时,只需重建 Schema 并重新创建页面,这正是正确的行为。

flowchart TD
    subgraph "initializeDataMachine"
        I1[customizingSchema] --> I2[sourcingNodes]
        I2 --> I3[buildingSchema]
        I3 --> I4[creatingPages]
        I4 --> I5[writingOutRedirects]
        I5 --> I6[done]
    end

    subgraph "recreatePagesMachine"
        R1[buildingSchema] --> R2[creatingPages]
        R2 --> R3[done]
    end

查询运行状态机

查询运行状态机(packages/gatsby/src/state-machines/query-running/index.ts)负责处理完整的查询生命周期:

  1. extractingQueries → 从组件文件中提取查询
  2. waitingPendingQueries → 50ms 延迟(见下文)
  3. writingRequires → 写入 async-requires 文件
  4. calculatingDirtyQueries → 与上次运行结果进行差异比较
  5. runningStaticQueries → 执行 useStaticQuery 查询
  6. runningPageQueries → 执行页面查询
  7. runningSliceQueries → 执行 slice 查询
  8. waitingForJobs → 等待异步任务完成(如图片处理)
  9. done

第 46–54 行waitingPendingQueries 状态值得关注。它通过 PAGE_QUERY_ENQUEUING_TIMEOUT 引入了 50ms 的延迟,原因在于:提取到的查询是通过 Redux middleware 中的 setTimeout(x, 0) 加入队列的——这意味着当提取"完成"时,它们实际上还没有落入 store。第 35 行的注释也坦率地指出这是一个已知问题:"FIXME: this has to be fixed properly。"

事件处理与无限循环防护

开发状态机中最精妙的部分,是 runningQueries 状态的退出条件(第 166–213 行)。查询运行结束后,状态机会依次评估一系列守卫条件:

flowchart TD
    A[Queries Done] --> B{Nodes mutated during queries?}
    B -->|No| C{First run? No compiler?}
    B -->|Yes| D{Recompile count >= 6?}

    D -->|Yes| E["PANIC: Infinite loop detected"]
    D -->|No| F["recreatingPages<br/>(increment count)"]

    C -->|Yes| G[startingDevServers]
    C -->|No| H{Source files dirty?}

    H -->|Yes| I[recompiling]
    H -->|No| J[waiting]

第 15 行RECOMPILE_PANIC_LIMIT 常量设为 6:

const RECOMPILE_PANIC_LIMIT = 6

如果查询运行期间节点被 mutation 的次数连续超过 6 次,状态机就会转换至 waiting 状态并触发 panicBecauseOfInfiniteLoop action。这有效防范了极端情况——例如某个查询解析器创建了一个节点,从而触发重建,重建又运行了该查询……

计数器由 incrementRecompileCount 在查询运行期间发生节点 mutation 时递增(第 185 行),并在进入 waiting 状态时由 resetRecompileCount 重置(第 274 行)。也就是说,只要成功完成了一次"查询 → waiting → 查询"的完整循环,计数器就会清零——只有连续发生的"查询中 mutation"才会累计计入限制。

提示: 在插件开发过程中如果遇到 RECOMPILE_PANIC_LIMIT 错误,通常意味着你的 onCreateNode 处理器在创建或修改节点时触发了自身。解决方法是在创建新节点之前,先判断节点类型是否符合预期。

开发服务器的启动

状态机首次进入 startingDevServers 时,会调用 startWebpackServer 服务。该函数(位于 packages/gatsby/src/utils/start-server.ts)将以下组件串联起来:

  • Express:HTTP 服务器
  • webpack-dev-middleware:提供支持 HMR 的 JS bundle
  • webpack-hot-middleware:向浏览器推送热更新
  • WebSocket:传输 GraphQL 查询结果的实时更新
  • GraphiQL Explorer:挂载于 /__graphql 的 GraphQL IDE
  • CORS middleware:处理跨域请求

webpack 的 develop 阶段(在第 2 篇中有所介绍)生成的 bundle 由 webpack-dev-middleware 提供服务。当源文件发生变更时,webpack compiler 会重新编译并热更新对应的模块。

退出 startingDevServers 时,三个 action 会依次触发:assignServers(将 compiler 和 listener 引用保存至 context)、spawnWebpackListener(启动文件监听)以及 markSourceFilesClean(重置脏标记)。

全局视角

Gatsby 开发服务器中的 XState 架构,堪称响应式系统设计的范本。通过将开发生命周期建模为显式的状态与转换,Gatsby 实现了以下目标:

  • 正确性:事件永远不会"丢失"——它们要么被立即处理,要么被排队等待合适的状态处理
  • 可观测性:每一次转换都可追溯(详细模式下通过 logTransitions 输出日志)
  • 健壮性:任意状态中的错误都会转换至 waiting 并记录错误日志,而不是直接崩溃

下一篇文章,我们将深入这些状态机内部流转的数据层——以 Redux 作为中央状态存储,以 LMDB 作为持久化节点数据库,以及将原始数据转化为可查询 API 的 GraphQL Schema 构建流水线。