Keystone 6 Hooks 实战指南:在 CRUD GraphQL 操作中嵌入自定义业务逻辑
2026/9/24 15:01:38 网站建设 项目流程

Keystone 6 Hooks 实战指南:在 CRUD GraphQL 操作中嵌入自定义业务逻辑

【免费下载链接】keystoneThe superpowered headless CMS for Node.js — built with GraphQL and React项目地址: https://gitcode.com/gh_mirrors/key/keystone

Keystone 6 为每个 list 自动生成完整的 CRUD GraphQL API,而hooks机制允许你在这套核心操作的生命周期中注入自定义业务逻辑。本指南以官方文档docs/content/docs/guides/hooks.md为骨架,结合packages/core源码实现,系统讲解如何用 hooks 改写入参、校验数据、触发副作用,并深入对比 list hooks 与 field hooks 的使用场景。

什么是 Hook?

Hook 是定义在 schema 配置 中的函数,在 GraphQL 操作执行时被触发。Keystone 支持四种核心 hook:resolveInputvalidatebeforeOperationafterOperation,分别覆盖数据解析、校验、写入前、写入后四个阶段。Hook 支持async函数,除了resolveInput需要返回值外,其余 hook 无需返回值;在批量操作(如createMany)时,每个被操作的数据项都会各自触发一次 hook。

先看一个最基础的示例:每次创建新用户时在控制台打印日志。

import { config, list } from '@keystone-6/core'; import { text } from '@keystone-6/core/fields'; export default config({ lists: { User: list({ fields: { name: text(), email: text(), }, hooks: { afterOperation: ({ operation, item }) => { if (operation === 'create') { console.log(`New user created. Name: ${item.name}, Email: ${item.email}`); } } }, }), }, });

这个函数会在 GraphQL API 执行createupdatedeletemutation 时触发。由于afterOperation支持create/update/delete三种操作,我们通过检查operation参数判断当前操作类型,再用item参数读取新创建用户的值。

用 resolveInput 改写入库数据

当执行createupdate操作时,你可能希望在数据写入数据库前做预处理。例如,保证博客文章的title字段首字母大写。resolveInputhook 允许我们拿到 GraphQL mutation 传入的数据并修改后再保存。

import { config, list } from '@keystone-6/core'; import { text } from '@keystone-6/core/fields'; export default config({ lists: { Post: list({ fields: { title: text({ validation: { isRequired: true } }), content: text({ validation: { isRequired: true } }), }, hooks: { resolveInput: ({ resolvedData }) => { const { title } = resolvedData; if (title) { return { ...resolvedData, // Ensure the first letter of the title is capitalised title: title[0].toUpperCase() + title.slice(1) } } // We always return resolvedData from the resolveInput hook return resolvedData; } }, }), }, });

注意resolveInputhook 必须始终返回修改后的resolvedData,即使你没有做任何修改。

resolveInput在 create/update 时被调用,resolvedData中的值是经过字段类型输入解析器处理后的结果。例如password字段会把明文转换成加密哈希。如果只想查看原始输入,请使用inputData参数。在 update 操作中,还可以通过item参数访问数据库中当前存储的值。所有 hook 都会收到context参数,提供完整的 context API 访问能力。

理解 resolvedData 的解析阶段

从源码看,resolveInput是 数据解析过程 的最终阶段。在packages/core/src/lib/core/mutations/index.ts中,getResolvedData函数按以下顺序处理 create/update 数据:

  1. 初始化:将resolvedData设置为 GraphQL mutation 的data输入值
  2. 默认值(内置,仅 create)resolvedData中为undefined且配置了默认值的字段被设为默认值
  3. 关系字段(内置):关系字段的值被转换为 Prisma 嵌套写对象(nested write objects);嵌套 create 操作在此阶段执行并返回 ID,所有connectsetdisconnect的项都会校验存在性;to-many 关系返回{ connect, set, disconnect }对象,to-one 关系返回{ connect }{ disconnect: true }
  4. 字段值(内置):某些字段类型将 GraphQL 输入转换为数据库所需的不同类型或格式(如password的哈希转换,见packages/core/src/fields/types/password/index.ts
  5. 字段 hooks(用户定义)resolveInput字段 hook 可为单个字段返回新值
  6. 列表 hooks(用户定义)resolveInput列表 hook 可为整个resolvedData对象返回新值

字段级 hook 和列表级 hook 的resolveInput都在访问控制(access control)之后执行,这保证了只有通过权限校验的数据才会进入你的业务逻辑。

用 validate 校验输入数据

在将解析后的数据写入数据库前,常常需要根据业务规则校验。例如,空字符串""在 GraphQL 中是合法的String值,但业务上可能不允许博客标题为空。validatehook 可以拒绝这类输入:

import { config, list } from '@keystone-6/core'; import { text } from '@keystone-6/core/fields'; export default config({ lists: { Post: list({ fields: { title: text({ validation: { isRequired: true } }), content: text({ validation: { isRequired: true } }), }, hooks: { validate: ({ resolvedData, addValidationError }) => { const { title } = resolvedData; if (title === '') { addValidationError('The title of a blog post cannot be the empty string'); } } }, }), }, });

validatehook 接收的是默认值和resolveInputhook 完成之后的resolvedData。通过addValidationError函数上报错误信息,可以多次调用来收集不同问题。Keystone 会中止操作,并将这些错误消息转换为 GraphQL 错误返回给调用方。

validatehook 还接收operationinputDataitemcontext参数,可用于更复杂的检查。从源码packages/core/src/lib/core/hooks.ts可见,字段级校验 hooks 会并行执行(Promise.all),错误消息会以`${list.listKey}.${fieldKey}: ${msg}`的格式聚合;列表级校验 hook 的错误则以`${list.listKey}: ${msg}`格式聚合,最终抛出validationFailureError,在 GraphQL API 层表现为ValidationFailureError

重要提醒:不要把数据校验(validation)和访问控制(access control)混淆。若想判断用户是否被允许执行某操作,应配置 访问控制规则,而不是在validate里实现权限逻辑。

用 beforeOperation / afterOperation 触发副作用

系统数据变更时,你可能需要触发外部副作用,例如用户首次创建账号后发送欢迎邮件:

import { config, list } from '@keystone-6/core'; import { text } from '@keystone-6/core/fields'; // Keystone leaves it up to you to decide how best to implement email in your system import { sendWelcomeEmail } from './lib/welcomeEmail'; export default config({ lists: { User: list({ fields: { name: text(), email: text(), }, hooks: { afterOperation: ({ operation, item }) => { if (operation === 'create') { sendWelcomeEmail(item.name, item.email); } } }, }), }, });

beforeOperationafterOperation很相似但用途不同:

  • beforeOperationitem参数包含操作执行前数据库中已存储的数据(create 时为undefined
  • afterOperationitem表示数据库中更新后的新数据,更新前的原数据通过originalItem提供;create 操作没有已存在的数据项,delete 操作中afterOperationitemnull(源码中为undefined,见packages/core/src/lib/core/mutations/index.tsdeleteSingle__,delete 时传入item: undefined, originalItem: item
  • beforeOperation抛出异常,操作返回错误,数据不会写入数据库
  • afterOperation抛出异常,数据仍保留在数据库中。因此afterOperation应仅用于“执行失败不构成关键问题”的副作用

从源码看,runSideEffectOnlyHookpackages/core/src/lib/core/hooks.ts)对beforeOperation/afterOperation的执行顺序有明确规定:先并行执行字段级 hooks,再执行列表级 hooks。一个值得注意的细节是,对于 create/update 操作,字段级操作 hooks 只在原始输入中显式包含该字段时才执行(通过检查inputData的 key 集合判断);而 delete 操作时字段级 hooks 始终执行。

与 Prisma 写入的关系

createSingle__/updateSingle__packages/core/src/lib/core/mutations/index.ts)中可以看到完整的调用链:访问控制 →resolveInputForCreateOrUpdate(包含getResolvedData解析 +validate校验)→beforeOperation()context.prisma[list.listKey].create/update(...)afterOperation(result)。也就是说,beforeOperation执行时 Prisma 尚未写入,afterOperation执行时数据已落库。delete 操作则是:校验 →beforeOperation→ Prisma delete →afterOperation

List Hooks 与 Field Hooks 的选择

以上示例都是 list 级别的 hooks。Keystone 同样支持在单个字段上配置 hooks,所有同名的 hooks 都可用,参数也一致,只是额外多一个fieldKey参数。

字段级 hooks 适合表达字段专属规则。例如,把邮件格式校验写成一个字段 hook,代码会清晰得多:

import { config, list } from '@keystone-6/core'; import { text } from '@keystone-6/core/fields'; export default config({ lists: { User: list({ fields: { name: text(), email: text({ validation: { isRequired: true }, hooks: { validate: ({ addValidationError, resolvedData, fieldKey }) => { const email = resolvedData[fieldKey]; if (email !== undefined && email !== null && !email.includes('@')) { addValidationError(`The email address ${email} provided for the field ${fieldKey} must contain an '@' character`); } }, }, }), }, }), }, });

packages/core/src/types/config/hooks.ts的类型定义中,字段级 hooks 相比列表级 hooks 多出inputFieldDataitemFieldresolvedFieldDataoriginalItemField等参数,分别对应字段的原始输入、数据库当前值、解析后值与原值,方便在单字段维度做精细化处理。

官方示例:hooks 的完整用法

仓库中的 examples/hooks/schema.ts 是一个完整的演示 schema,展示了 hooks 在生产场景下的组合用法,值得参考:

  • createdAt字段的resolveInput.create自动写入当前时间,updatedAt字段的resolveInput.update在更新时刷新时间戳
  • list 级resolveInput.create/updatecontext.req中提取客户端 IP 和 User-Agent,写入createdBy/updatedBy字段,实现审计追踪
  • list 级validate.create/update对标题、正文和反馈做敏感词过滤(/profanity/i),并在delete时检查preventDelete标记阻止删除
  • beforeOperation记录将要写入的数据,afterOperation分别针对 create(input → item)、update(originalItem → item)、delete(originalItem → deleted)打印差异日志

Hook 参数速查

列表级与字段级 hook 的常用参数汇总如下(完整签名见 Hooks API 参考):

参数说明
listKey被操作的列表 key
fieldKey被操作的字段 key(仅字段级 hook)
operation操作类型:'create'/'update'/'delete'
inputDatamutation 传入的data原始值(delete 时为undefined
inputFieldData输入数据中该字段的值(仅字段级 hook,delete 时为undefined
item数据库当前存储的数据项(create 时为undefined
originalItem更新/删除前的原始数据项(仅afterOperation,create 时为undefined
resolvedData经过默认值、关系解析器、字段解析器与resolveInputhooks 处理后的数据(delete 时为undefined
context发起操作的 KeystoneContext 对象
addValidationError(msg)上报校验错误(仅validatehook)

相关资源

  • Hooks API Reference:mutation 生命周期各阶段执行代码的完整参考,包含每个 hook 的参数表与resolvedData解析阶段详解
  • Hooks Guide:本文对应的官方指南
  • examples/hooks:可运行的完整 hooks 示例 schema
  • 源码实现:validaterunSideEffectOnlyHook的运行时实现
  • mutation 执行链:hook 在 create/update/delete 全流程中的实际调用位置

【免费下载链接】keystoneThe superpowered headless CMS for Node.js — built with GraphQL and React项目地址: https://gitcode.com/gh_mirrors/key/keystone

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询