Payload Hooks 完整参考:Collection Hook、Field Hook 与 Hook Context 实战指南
【免费下载链接】payloadPayload is the open-source, fullstack Next.js framework, giving you instant backend superpowers. Get a full TypeScript backend and admin panel instantly. Use Payload as a headless CMS or for building powerful applications.项目地址: https://gitcode.com/GitHub_Trending/pa/payload
Payload(本仓库packages/payload)是 Next.js 原生的全栈 Headless CMS 框架,Hooks(钩子)是其扩展数据写入、读取与删除流程的核心机制。这篇指南以 HOOKS.md 为主干,结合本仓库源码中 Hook 类型与 Collection 操作实现,系统讲解 Collection 级 Hook、Field 级 Hook 的全部时机点、Hook Context 的共享与防循环用法、事务性传req的最佳实践,并通过「自动生成 slug」「发布时自动写入日期」「基于 Context 控制 Next.js 缓存 revalidation」等可直接复制到项目中的示例,帮助你彻底掌握在 Payload 中“在什么时机、用什么层级、如何安全地执行副作用与业务逻辑”。
一、先理解分层:Collection Hook 与 Field Hook 不是可以互换的
Payload 的 Hook 注册在两层,且语义各不相同(这也是 SKILL.md 中反复强调的约定):
| 层级 | 挂载位置 | 核心参数 | 返回值语义 | 典型用途 |
|---|---|---|---|---|
| Collection Hook | Collection 配置的hooks对象 | { doc, data, req, operation, id, context, originalDoc, previousDoc, ... } | 作用于整篇文档 | 跨字段业务逻辑、自动填充日期、级联删除、发送通知 |
| Field Hook | 单个字段的hooks对象 | { value, siblingData, previousSiblingData, req, operation, context, ... } | 返回该字段的新值 | 字段格式化、字段级计算(virtual 字段)、字段级脱敏 |
在源码层面,这两者的类型定义也是分开维护的:Collection 配置中hooks属性的类型位于 packages/payload/src/collections/config/types.ts#L677,而 Field Hook 的参数结构FieldHookArgs与FieldHook类型则定义在 packages/payload/src/fields/config/types.ts#L172。
记住一个判断准则:如果要“计算某个字段的值”或“只加工单个字段”,请在该字段上写 Field Hook;如果要基于整篇文档做跨字段逻辑或对外副作用,请写 Collection Hook。特别是“为文档计算字段值”时,永远不要用 Collection 级afterRead去原地修改doc,而应使用字段自身的afterRead(配合virtual: true)。
二、Collection Hooks:六个时机点完整覆盖写入与删除流程
下面是一个把 Collection 五大类 Hook 全部用上的Posts示例,来自 HOOKS.md:
export const Posts: CollectionConfig = { slug: 'posts', hooks: { // Before validation —— 在数据校验之前执行,适合做数据格式化/归一化 beforeValidate: [ async ({ data, operation }) => { if (operation === 'create') { data.slug = slugify(data.title) } return data }, ], // Before save —— 在最终入库前执行,适合承载业务逻辑 beforeChange: [ async ({ data, req, operation, originalDoc }) => { if (operation === 'update' && data.status === 'published') { data.publishedAt = new Date() } return data }, ], // After save —— 文档已落库,适合触发副作用(通知、日志、第三方同步) afterChange: [ async ({ doc, req, operation, previousDoc }) => { if (operation === 'create') { await sendNotification(doc) } return doc }, ], // After read —— 在读取结果返回给调用方之前,适合附加计算数据 afterRead: [ async ({ doc, req }) => { doc.viewCount = await getViewCount(doc.id) return doc }, ], // Before delete —— 在真正删除之前,适合清理关联数据 beforeDelete: [ async ({ req, id }) => { await cleanupRelatedData(id) }, ], }, }2.1 各 Hook 的执行时机与返回约定
Payload 在 Collection 上实际提供了八个注册时机,其中文档示例覆盖了五个核心项,完整执行时序如下(对应create/update/delete等操作的底层实现,可参见本仓库 collections/operations/create.ts、collections/operations/update.ts 等操作文件):
beforeOperation:操作真正开始前执行,可读取/改写操作参数(args),甚至可以抛错中断后续流程;beforeValidate:在数据校验前执行。最常见的用途是数据格式化——如示例中依据title生成slug,也可用于对用户提交数据进行清洗、补默认值;afterValidate:校验通过后、写库前执行,适合在校验结果之上再补充处理;beforeChange:正式入库(save)之前执行。承载核心业务逻辑的位置——如示例中发布文章时自动写入publishedAt;afterChange:写入成功后执行。承担一切副作用——发邮件/通知、写审计日志、同步搜索索引等;afterRead:读取结果返回之前执行,可给doc附加计算字段(注意不要在这里通过 Collection Hook 修改单字段值,见上文分层原则);beforeDelete:删除动作落库前执行,是级联清理(删除头像文件、删除关联子文档)的标准位置;afterDelete:删除成功后执行,可做后续通知或清理。
关键返回约定(务必牢记):
beforeValidate/beforeChange/afterValidate类 Hook 若修改了数据,必须返回修改后的data,否则改动不会生效;afterChange/afterRead这类 Hook 若修改了文档对象,需返回修改后的doc;afterDelete等删除类 Hook 返回的doc通常是已删除文档的快照,用于通知等场景;- 任何一个 Hook 抛出错误,都会中止该操作并向上传播(这为下一节的“事务原子性”提供了基础保障)。
三、Field Hooks:字段级格式化与读取脱敏
Field Hook 挂载在单个字段的hooks属性中,与 Collection Hook 最大的区别是:它只负责本字段值的加工,且必须返回该字段的新值。以下示例在 Email 字段上演示了“写入时归一化 + 读取时按角色脱敏”:
import type { EmailField, FieldHook } from 'payload' const beforeValidateHook: FieldHook = ({ value }) => { return value.trim().toLowerCase() } const afterReadHook: FieldHook = ({ value, req }) => { // Hide email from non-admins if (!req.user?.roles?.includes('admin')) { return value.replace(/(.{2})(.*)(@.*)/, '$1***$3') } return value } const emailField: EmailField = { name: 'email', type: 'email', hooks: { beforeValidate: [beforeValidateHook], afterRead: [afterReadHook], }, }3.1 用途拆解:一个读改写,各自独立生效
beforeValidate中的value.trim().toLowerCase():把用户输入的" John@Doe.com "归一化为john@doe.com,在写入路径上清洗数据;afterRead中的正则脱敏:把john@doe.com变为jo***@doe.com,仅在读取路径上对非 admin 用户生效——底层数据从未被修改,只是每个请求的响应值不同。
这对组合展示了 Field Hook 的核心价值:同一字段可以在写入与读取两个方向施加完全不同的加工策略,且互不影响。
3.2 Field Hook 可用的上下文参数
Field Hook 的参数结构与类型定义详见 packages/payload/src/fields/config/types.ts。除示例中出现的value、req之外,实际开发中常用的还包括:
| 参数 | 含义 |
|---|---|
value | 当前字段当前阶段的值(beforeValidate/beforeChange时为待校验/待保存值,afterRead时为已落库值) |
siblingData | 与当前字段同一层级的兄弟字段数据(如date字段读取同级的_status,见第五节) |
previousSiblingData | 变更前的兄弟字段数据(beforeChange时可用,便于判断哪个字段发生了变化) |
req | Payload 请求对象(含req.payload、req.user、req.context),可用于做权限判断或发起子操作 |
operation | 当前操作类型,'create'/'update'/'read'等 |
context | 可跨 Hook、跨嵌套操作共享的自定义数据对象 |
id | 当前文档 ID(update/delete等场景下可用) |
提示:当把 Hook 抽取到独立文件/命名常量时,务必为变量标注
FieldHook、CollectionAfterChangeHook等类型,或用satisfies收窄;否则type: 'email'这类字面量会被拓宽为string,导致联合类型判定失败(详见 SKILL.md)。
四、Hook Context:跨 Hook 共享数据与防止死循环
Payload 在每个请求上挂载了一个context对象,随req贯穿一次操作的完整生命周期。它的两大价值是:
- 在 Hook 之间传递/复用昂贵的计算结果,避免重复 IO;
- 写入标记位来防止 Hook 递归触发(infinite loop)。
4.1 跨 Hook 共享数据
以下示例来自 HOOKS.md:beforeChange中抓取一次昂贵数据存入context,afterChange中直接复用:
import type { CollectionConfig } from 'payload' export const Posts: CollectionConfig = { slug: 'posts', hooks: { beforeChange: [ async ({ context }) => { context.expensiveData = await fetchExpensiveData() }, ], afterChange: [ async ({ context, doc }) => { // Reuse from previous hook await processData(doc, context.expensiveData) }, ], }, fields: [{ name: 'title', type: 'text' }], }因为同一请求生命周期内req.context是同一份对象引用,beforeChange写入的context.expensiveData在随后的afterChange中必然可见。这种模式同时是 SKILL.md 中“在req.context中缓存昂贵操作”这一性能建议的落地方式。
4.2 用 Context 标记位阻断 Hook 自触发循环
Hook 中执行的嵌套操作可能再次触发同一个 Hook,从而造成无限循环。例如在afterChange里更新文档自身来累加views,会不断重入afterChange。正确的做法是在嵌套调用时携带context标记,并在 Hook 入口处检查它:
hooks: { afterChange: [ async ({ doc, req, context }) => { if (context.skipHooks) return await req.payload.update({ collection: 'posts', id: doc.id, data: { views: doc.views + 1 }, context: { skipHooks: true }, // 标记位,禁止再次进入本 Hook req, // 同时保持事务上下文(见第六节) }) }, ] }这种“入口检查 + 嵌套调用写标记”的守卫模式,在 SKILL.md 的安全陷阱清单中被列为防无限循环的标准解法,可复用于计数、touch 更新时间、级联同步等任何“操作会再次命中自身 Hook”的场景。
五、实战示例:自动设置发布时间(Date Field Auto-Set)
开启草稿(versions: { drafts: true })后,Payload 会自动注入_status字段,取值为draft/published/changed。利用Field 级beforeChange+siblingData,可以精准实现“文档首次被发布的那一刻自动写入publishedOn”:
import type { DateField } from 'payload' const publishedOnField: DateField = { name: 'publishedOn', type: 'date', admin: { date: { pickerAppearance: 'dayAndTime', }, position: 'sidebar', }, hooks: { beforeChange: [ ({ siblingData, value }) => { if (siblingData._status === 'published' && !value) { return new Date() } return value }, ], }, }几个值得注意的设计细节:
- 读取
siblingData._status判断“本次保存是否为发布”,而不必自行添加status字段——在启用草稿/版本后_status已由 Payload 托管,自行新增会导致语义重复(参见 SKILL.md); - 条件中的
!value保证只有首次发布时才写入时间;文档后续再次编辑(即使_status仍为published)也不会覆盖已有日期; - 这是典型的“字段自动填充”场景,同类的常见变体还包括:
beforeChange中若siblingData._status === 'published'则写入发布人、beforeValidate中依据标题生成 slug(见第二节 Collection 示例)等。
六、实战示例:Next.js 页面缓存的 Context 受控 Revalidation
将 Payload 用作 Next.js 全栈 CMS 时,编辑后台保存的内容需要实时反映到前台页面,此时需要触发 Next.js 的revalidatePath。下面的代码来自 HOOKS.md,演示了afterChange+afterDelete双 Hook结合Context 开关的完整方案:
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload' import { revalidatePath } from 'next/cache' import type { Page } from '../payload-types' export const revalidatePage: CollectionAfterChangeHook<Page> = ({ doc, previousDoc, req: { payload, context }, }) => { if (!context.disableRevalidate) { if (doc._status === 'published') { const path = doc.slug === 'home' ? '/' : `/${doc.slug}` payload.logger.info(`Revalidating page at path: ${path}`) revalidatePath(path) } // Revalidate old path if unpublished if (previousDoc?._status === 'published' && doc._status !== 'published') { const oldPath = previousDoc.slug === 'home' ? '/' : `/${previousDoc.slug}` payload.logger.info(`Revalidating old page at path: ${oldPath}`) revalidatePath(oldPath) } } return doc } export const revalidateDelete: CollectionAfterDeleteHook<Page> = ({ doc, req: { context } }) => { if (!context.disableRevalidate) { const path = doc?.slug === 'home' ? '/' : `/${doc?.slug}` revalidatePath(path) } return doc }6.1 三处值得学习的设计要点
- 发布/取消发布双向失效:新增发布走
doc._status === 'published'分支;把已发布文章改为草稿(previousDoc._status === 'published'且当前不再 published)时,需要让旧 slug 对应的旧路径也失效——否则已渲染的旧页面缓存会残留; - home 页特判:slug 为
home的页面路径是根路径/,其余页面为/${slug}; - Context 作为“全局开关”:通过
req.context.disableRevalidate决定本次操作是否跳过 revalidation。这让你在数据导入、脚本批量写入、或 Hook 内部执行内部操作时,可以主动抑制缓存刷新——这在本质上是第四节“context 守卫”在真实业务场景(避免批量写造成缓存风暴)中的应用。
6.2 为什么需要显式的 revalidation Hook
在不启用 Payload 与 Next.js 前台之间实时通信机制的前提下,前台页面若依赖 ISR/静态渲染缓存,后台的增删改并不会自动让前台缓存失效。正因如此,Payload 推荐把revalidatePath放进afterChange/afterDeleteHook——写入成功的副作用(刷新缓存)与数据操作绑定在同一生命周期内。若你的PageCollection 开启了草稿模式(versions.drafts),则_status === 'published'的判断就是“仅对真正对外可见的发布态触发失效”的安全阀。
七、事务安全:嵌套操作务必把req穿进去
在 Hook 中发起“嵌套操作”(如写审计日志、级联更新子文档)时,必须把当前 Hook 收到的req透传给每一次子操作,否则子操作会脱离当前事务独立执行。这会破坏事务原子性,造成“主操作失败但子操作已生效”的部分更新脏数据:
// ❌ 错误:未传 req,审计日志在独立事务/无事务中执行 afterChange: [ async ({ doc, req }) => { await req.payload.create({ collection: 'audit-log', data: { docId: doc.id }, // 缺少 req —— 与主操作不同事务,主操作回滚时审计仍写入 }) }, ] // ✅ 正确:传入 req,子操作与主操作处于同一事务 afterChange: [ async ({ doc, req }) => { await req.payload.create({ collection: 'audit-log', data: { docId: doc.id }, req, // 保持事务原子性 }) }, ]对应的底层语义(详见本仓库 ADAPTERS.md):
- MongoDB(需副本集 replica set):同一
req下的操作共享一个数据库 session; - PostgreSQL:所有操作落在同一个 Drizzle 事务中;
- SQLite:需在 adapter 配置
transactionOptions: {}开启事务后才具备该保证(默认关闭); - 反之,不传
req时各操作相互独立。
什么时候req是必需的?在 Hook 中的一切写操作(create/update/delete)、必须同生共死的一组操作、依赖req.context或req.user的操作。什么时候可以省略?与本事务无关的只读查询、显式disableTransaction: true的管理员操作。这一原则在本仓库的 e2e 测试目录(如 test/hooks)中也是被反复验证的规范。
八、模式选择与最佳实践汇总
为便于快速决策,将 HOOKS.md 中的最佳实践与上文案例整理成如下决策表:
| 想做的事 | 放到哪个 Hook | 示例 |
|---|---|---|
| 数据格式化、slug 生成、归一化 | beforeValidate | data.slug = slugify(data.title) |
| 跨字段业务逻辑、自动填充时间/作者 | beforeChange | 发布时写入publishedOn |
| 副作用:通知、日志、搜索索引、缓存刷新 | afterChange/afterDelete | sendNotification、revalidatePath |
| 计算字段、字段级脱敏 | Field 级afterRead(可配virtual: true) | 邮箱按角色脱敏、拼接 fullName |
| 级联删除关联数据 | beforeDelete | cleanupRelatedData(id) |
| 在 Hook 之间共享昂贵计算结果 | req.context | context.expensiveData |
| 阻断 Hook 自触发、抑制批量操作副作用 | context标记位 | if (context.disableRevalidate) return |
除此之外还有三条贯穿始终的硬性要求:
- Hook 数组中的每个函数若有返回值,都必须显式
return(返回data或doc),否则你的改动会被丢弃; - 嵌套写操作永远把
req传下去,这是事务原子性的前提(见 ADAPTERS.md); - 尽量为 Hook 函数标注精确类型(
CollectionAfterChangeHook、FieldHook等),既能获得参数类型提示,也避免字面量拓宽导致的联合类型解析问题。
九、延伸阅读与源码索引
- 本篇主文档:HOOKS.md,是整个 Hook 用法的权威速查入口;
- Collection/Field 配置的更多约定与默认值:SKILL.md;
- Collection
hooks类型定义:packages/payload/src/collections/config/types.ts#L677; - Field Hook 参数与类型定义:packages/payload/src/fields/config/types.ts#L172;
- Hook 被实际执行的写入/更新/删除操作实现:collections/operations/create.ts、collections/operations/update.ts、collections/operations/delete.ts;
- 事务与嵌套操作中
req透传细节:ADAPTERS.md; - 社区/e2e 中 Hook 的完整落地场景可参考仓库测试目录:test/hooks。
掌握本节内容后,你可以在 Payload 项目中胜任三类高频任务:一是用beforeValidate/beforeChange在写入前做数据清洗与业务增强;二是用afterChange/afterRead/beforeDelete精确编排副作用与计算字段;三是用context与req让 Hook 链既高效又安全——既不重复计算,也不破坏事务,更不会把自己写成死循环。
【免费下载链接】payloadPayload is the open-source, fullstack Next.js framework, giving you instant backend superpowers. Get a full TypeScript backend and admin panel instantly. Use Payload as a headless CMS or for building powerful applications.项目地址: https://gitcode.com/GitHub_Trending/pa/payload
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考