Read OSS

从节点到查询:Redux、LMDB 与 GraphQL Schema 构建

高级

前置知识

  • 本系列第 1–3 篇文章
  • Redux 基础知识(store、actions、reducers)
  • GraphQL schema 基本概念(类型、resolver、directive)
  • 对内存映射数据库有基本了解

从节点到查询:Redux、LMDB 与 GraphQL Schema 构建

流经 Gatsby 站点的每一份内容——每个 Markdown 文件、每条 CMS 记录、每张图片——都要经过数据层的处理,才能从原始数据变成带类型的、可查询的 GraphQL schema。这个数据层是 Gatsby 架构的核心,由三根支柱支撑:用于全局状态管理的 Redux、用于持久化节点存储的 LMDB,以及用于 schema 构建的 graphql-compose。

在第 2、3 篇中我们看到,构建管道和开发状态机调用的是同一批服务函数——sourceNodesbuildSchemacreatePages。本文将深入这些服务内部,解释数据如何进入系统、如何存储、schema 如何从数据中生成,以及查询如何被提取并执行。

Redux:整个系统的中枢神经

packages/gatsby/src/redux/index.ts 中的 Redux store 是整个构建过程的唯一数据来源,负责追踪一切:页面、节点、组件、查询、webpack 编译哈希、HTML 文件状态等。

IGatsbyState 的数据结构

IGatsbyState 接口定义了完整的状态结构,以下是其核心字段概览:

graph TD
    subgraph "IGatsbyState"
        nodes["nodes: Map<string, IGatsbyNode>"]
        pages["pages: Map<string, IGatsbyPage>"]
        components["components: Map<string, IGatsbyPageComponent>"]
        schema["schema: GraphQLSchema"]
        queries["queries: { trackedQueries, trackedComponents, ... }"]
        html["html: { trackedHtmlFiles, compilationHashes, ... }"]
        flattenedPlugins["flattenedPlugins: Array<FlattenedPlugin>"]
        config["config: IGatsbyConfig"]
        status["status: { PLUGINS_HASH, LAST_NODE_COUNTER }"]
        jobsV2["jobsV2: { incomplete, complete, jobsByRequest }"]
    end
状态切片 类型 用途
nodes Map<string, IGatsbyNode> 系统中的所有内容节点
pages Map<string, IGatsbyPage> 已注册的页面,包含路径和组件信息
components Map<string, IGatsbyPageComponent> 页面模板,附带查询与渲染元数据
schema GraphQLSchema 编译完成的 GraphQL schema
queries 复合对象 查询追踪:脏标记、依赖图
html 复合对象 HTML 文件状态、编译哈希
flattenedPlugins Array 插件注册表的权威来源

三级 Action 权限体系

Gatsby 的 Redux action 在 packages/gatsby/src/redux/actions/public.js 中按访问权限分为三级:

  1. 公开 actionactions/public.js):所有插件均可调用——createNodecreatePagecreateRedirectdeleteNode
  2. 受限 actionactions/restricted.ts):仅特定 API 可用——createTypesaddThirdPartySchemasetWebpackConfig
  3. 内部 actionactions/internal.ts):框架专用——SET_PROGRAMSET_SITE_CONFIGSET_SCHEMA

这一权限机制由 API runner 强制执行——它只会为当前 API hook 绑定对应的 action creator。一个实现了 sourceNodes 的插件可以拿到 createNode,但无法访问 setWebpackConfig

Store 配置与持久化

Store 配置了两层中间件(第 101–115 行):处理异步 action 的 redux-thunk,以及一个自定义的 multi 中间件,用于将 action 数组拆分后逐个 dispatch。

第 117–119 行,初始状态按条件加载:

export const store: GatsbyReduxStore = configureStore(
  process.env.GATSBY_WORKER_POOL_WORKER ? ({} as IGatsbyState) : readState()
)

Worker 进程使用空 state(从主进程接收部分状态),主进程则从 LMDB 缓存中读取状态,从而支持增量构建。

mett:连接 Redux 与插件系统的事件桥梁

有一个不起眼但至关重要的模块将 Redux 与插件系统连接在一起。mett 是一个轻量级事件发射器(灵感来自 mitt),它用 Map<string, Set<Handler>> 替代了普通对象和数组。

redux/index.ts 第 172–175 行,每个 Redux action 都会通过 mett 广播出去:

store.subscribe(() => {
  const lastAction = store.getState().lastAction
  emitter.emit(lastAction.type, lastAction)
})

这构成了一个发布/订阅桥梁:系统的任何部分都可以监听特定的 Redux action。插件 runner(packages/gatsby/src/redux/plugin-runner.ts)正是借助这一机制自动触发插件钩子:

sequenceDiagram
    participant Plugin as Source Plugin
    participant Redux as Redux Store
    participant Mett as mett emitter
    participant Runner as plugin-runner.ts
    participant OnCreate as onCreateNode plugins

    Plugin->>Redux: createNode(fileNode)
    Redux->>Redux: Reduce CREATE_NODE
    Redux->>Mett: emit("CREATE_NODE", action)
    Mett->>Runner: CREATE_NODE handler
    Runner->>Runner: Check: is node.internal.type === "SitePage"?
    Runner->>OnCreate: apiRunnerNode("onCreateNode", { node })

第 44–77 行startPluginRunner 函数会在启动时预先过滤插件——只有当至少一个插件实现了 onCreatePageonCreateNode 时,才会注册对应的事件监听器,避免了无人监听时徒劳触发事件的开销。

提示: mett 还支持通过 * 事件名注册通配符监听器。开发状态机的 mutation 监听器正是通过这种方式,捕获所有类型的节点变更事件,无论具体是哪种 action。

节点存储:从 Redux 到 LMDB

Gatsby 最初将所有节点存储在 Redux 的内存状态中。对于大型站点(节点数超过 10 万),这会消耗数 GB 的内存。解决方案是 LMDB——一种基于内存映射的 B 树数据库,提供接近内存读取速度的性能,同时具备磁盘持久化能力。

入口是 packages/gatsby/src/datastore/datastore.ts 中的懒加载模式:

let dataStore: IDataStore

export function getDataStore(): IDataStore {
  if (!dataStore) {
    const { setupLmdbStore } = require(`./lmdb/lmdb-datastore`)
    dataStore = setupLmdbStore()
  }
  return dataStore
}

packages/gatsby/src/datastore/lmdb/lmdb-datastore.ts 中的 LMDB 实现使用 globalThis.__GATSBY_OPEN_ROOT_LMDBS 在不同的 require 上下文之间共享数据库句柄:

function getRootDb(): RootDatabase {
  if (!rootDb) {
    if (!globalThis.__GATSBY_OPEN_ROOT_LMDBS) {
      globalThis.__GATSBY_OPEN_ROOT_LMDBS = new Map()
    }
    rootDb = globalThis.__GATSBY_OPEN_ROOT_LMDBS.get(fullDbPath)
    if (rootDb) return rootDb

    rootDb = open({
      name: `root`,
      path: fullDbPath,
      compression: true,
    })
    globalThis.__GATSBY_OPEN_ROOT_LMDBS.set(fullDbPath, rootDb)
  }
  return rootDb
}

这种 globalThis 缓存机制避免了"多个 LMDB 实例"问题——当同一数据库在同一进程中被打开两次时(例如在 gatsby serve 中,引擎和 trailing-slash 中间件都需要访问节点),就会触发随机错误。

flowchart TD
    A["createNode() action"] --> B["Redux Reducer"]
    B --> C["LMDB updateNodes"]
    C --> D[".cache/data/datastore"]

    E["getNode(id)"] --> F["LMDB getNode"]
    F --> D

    G["getNodesByType(type)"] --> H["LMDB iterateNodesByType"]
    H --> D

    style D fill:#fff3e0

数据库路径在生产环境默认为 .cache/data/datastore,在测试环境则为 .cache/data/test-datastore-{workerId},确保 Jest worker 之间的测试隔离(第 32–44 行)。

GraphQL Schema:显式定义与自动推断的融合

Gatsby 的 GraphQL schema 分两个阶段构建:自定义阶段(插件提供的显式类型定义)和推断阶段(从节点数据自动生成类型)。编排逻辑位于 packages/gatsby/src/schema/index.js

第一阶段:自定义

customizeSchema 服务执行期间,插件通过调用 createTypes() 定义显式 GraphQL 类型。这些类型定义存储在 store.getState().schemaCustomization.types 中。框架内置类型最先添加,其次是插件类型,最后是用户类型——确保用户定义具有最高优先级(schema/index.js 第 26–34 行):

return [
  ...builtInTypes,
  ...types.filter(type => type.plugin && type.plugin.name !== `default-site-plugin`),
  ...types.filter(type => !type.plugin || type.plugin.name === `default-site-plugin`),
]

第二阶段:推断

显式类型注册完成后,buildInferenceMetadata第 53–80 行)会遍历所有节点类型,分析其数据,并 dispatch BUILD_TYPE_METADATA action 以驱动推断引擎。packages/gatsby/src/schema/schema.js 中的 schema 构建器随后使用 graphql-compose 将显式定义与推断类型合并。

Schema 扩展

packages/gatsby/src/schema/extensions/index.js 中的扩展系统提供了类型级和字段级的 directive:

扩展 作用层级 用途
@infer 类型 启用自动字段推断(默认行为)
@dontInfer 类型 禁用推断,仅保留显式字段
@link 字段 建立与其他节点的外键关联
@dateformat 字段 为日期字段添加格式化参数
@fileByRelativePath 字段 将相对文件路径解析为 File 节点
@mimeTypes 类型 声明该类型处理的 MIME 类型
@childOf 类型 声明父子节点关系
flowchart TD
    A["Plugins call createTypes()"] --> B["Explicit type definitions"]
    C["Node data in LMDB"] --> D["Inference engine"]
    B --> E["graphql-compose SchemaComposer"]
    D --> E
    F["Schema extensions<br/>@infer @link @dateformat"] --> E
    E --> G["Final GraphQLSchema"]
    G --> H["Store: SET_SCHEMA"]

提示: 如果你在开发 source plugin 并希望完全掌控类型的 schema 结构,建议在类型定义上使用 @dontInfer。这样可以阻止 Gatsby 分析节点数据,避免自动添加你不需要的字段——一旦数据结构发生变化,这些多余字段可能会引发问题。

查询管道

schema 构建完成后,还需要从组件文件中提取查询,再经过编译、校验,最终执行。这条管道涉及 query/ 目录下的多个文件。

提取

packages/gatsby/src/query/query-compiler.js 中的查询编译器使用基于 Babel 的 FileParser 来查找组件文件中的 GraphQL 标签模板字面量。它会扫描项目目录和主题目录下的所有文件:

const parsedQueries = await parseQueries({
  base: program.directory,
  additional: resolveThemes(
    flattenedPlugins.map(plugin => ({
      themeDir: plugin.pluginFilepath,
    }))
  ),
  addError,
  parentSpan: activity.span,
})

编译器使用标准的 GraphQL 校验规则(从 graphql 包导入,见第 14–29 行)对提取到的查询进行校验,然后将 fragment 合并到对应查询中——在 Gatsby 中,fragment 具有全局作用域,一个文件中定义的 fragment 可以在任意查询中使用。

执行

GraphQLRunner 类对标准 graphqlexecute 函数进行了封装,添加了缓存和追踪能力。在实例化时,它会创建一个 LocalNodeModel——所有 Gatsby 字段 resolver 都会接收这个 resolver context:

this.nodeModel = new LocalNodeModel({
  schema,
  schemaComposer: schemaCustomization.composer,
  createPageDependency,
  _rootNodeMap,
  _trackedRootNodes,
})

NodeModel 正是让 Gatsby resolver 变得"智能"的关键——它通过 createPageDependency 追踪每个查询依赖的节点,从而在源数据变更时自动使相关查询失效。

三种查询类型

Gatsby 处理三种不同类型的查询:

  1. Page query:定义在页面组件中,可接收 pageContext 变量,每个页面执行一次。
  2. Static queryuseStaticQuery):可定义在任意位置,不接受变量,结果会内嵌到 JS bundle 中。
  3. Slice query:定义在 slice 组件中(Gatsby 5 新特性),每个 slice 执行一次,结果在页面间共享。
flowchart LR
    A["Component files"] -->|"Babel parse"| B["FileParser"]
    B --> C["Raw queries + fragments"]
    C -->|"Fragment collocation"| D["Complete queries"]
    D -->|"Validate against schema"| E["Valid queries"]
    E -->|"calculateDirtyQueries"| F["Dirty query IDs"]
    F --> G["GraphQLRunner.execute()"]
    G -->|"Page queries"| H["page-data JSON files"]
    G -->|"Static queries"| I["static-query JSON files"]
    G -->|"Slice queries"| J["slice-data JSON files"]

calculateDirtyQueries 步骤是实现增量构建的关键——它将查询哈希和节点依赖与上次构建结果进行比对,判断哪些查询确实需要重新执行。对于一个拥有 10,000 个页面、但只有 3 个节点发生变化的站点,这一机制可以将查询执行时间从数分钟压缩到数秒。

下一步

至此,我们已经追踪了数据从原始内容到节点创建、LMDB 存储、schema 推断、再到查询执行的完整旅程。在最后一篇文章中,我们将深入 Gatsby 的可扩展性机制——将一切串联起来的插件系统、支持组件覆盖的主题系统、SSG/DSG/SSR 页面模式体系,以及部署适配器抽象层。