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:resolveInput、validate、beforeOperation和afterOperation,分别覆盖数据解析、校验、写入前、写入后四个阶段。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 执行create、update或deletemutation 时触发。由于afterOperation支持create/update/delete三种操作,我们通过检查operation参数判断当前操作类型,再用item参数读取新创建用户的值。
用 resolveInput 改写入库数据
当执行create或update操作时,你可能希望在数据写入数据库前做预处理。例如,保证博客文章的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 数据:
- 初始化:将
resolvedData设置为 GraphQL mutation 的data输入值 - 默认值(内置,仅 create):
resolvedData中为undefined且配置了默认值的字段被设为默认值 - 关系字段(内置):关系字段的值被转换为 Prisma 嵌套写对象(nested write objects);嵌套 create 操作在此阶段执行并返回 ID,所有
connect、set、disconnect的项都会校验存在性;to-many 关系返回{ connect, set, disconnect }对象,to-one 关系返回{ connect }或{ disconnect: true } - 字段值(内置):某些字段类型将 GraphQL 输入转换为数据库所需的不同类型或格式(如
password的哈希转换,见packages/core/src/fields/types/password/index.ts) - 字段 hooks(用户定义):
resolveInput字段 hook 可为单个字段返回新值 - 列表 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 还接收operation、inputData、item和context参数,可用于更复杂的检查。从源码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); } } }, }), }, });beforeOperation与afterOperation很相似但用途不同:
beforeOperation的item参数包含操作执行前数据库中已存储的数据(create 时为undefined)afterOperation的item表示数据库中更新后的新数据,更新前的原数据通过originalItem提供;create 操作没有已存在的数据项,delete 操作中afterOperation的item为null(源码中为undefined,见packages/core/src/lib/core/mutations/index.ts的deleteSingle__,delete 时传入item: undefined, originalItem: item)- 若
beforeOperation抛出异常,操作返回错误,数据不会写入数据库 - 若
afterOperation抛出异常,数据仍保留在数据库中。因此afterOperation应仅用于“执行失败不构成关键问题”的副作用
从源码看,runSideEffectOnlyHook(packages/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 多出inputFieldData、itemField、resolvedFieldData、originalItemField等参数,分别对应字段的原始输入、数据库当前值、解析后值与原值,方便在单字段维度做精细化处理。
官方示例:hooks 的完整用法
仓库中的 examples/hooks/schema.ts 是一个完整的演示 schema,展示了 hooks 在生产场景下的组合用法,值得参考:
createdAt字段的resolveInput.create自动写入当前时间,updatedAt字段的resolveInput.update在更新时刷新时间戳- list 级
resolveInput.create/update从context.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' |
inputData | mutation 传入的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
- 源码实现:
validate与runSideEffectOnlyHook的运行时实现 - 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),仅供参考