首页
/ Strapi 内容模型生命周期钩子:Event 事件对象的类型系统与底层实现解析

Strapi 内容模型生命周期钩子:Event 事件对象的类型系统与底层实现解析

2026-09-06 11:49:59作者:裘旻烁

在 Strapi 的 TypeScript 项目中,内容模型(Content-Type)的生命周期钩子文件(src/api/<api>/content-types/<content-type>/lifecycles.ts)接收的核心入参是一个数据库层的 Event 事件对象。本文围绕该事件类型展开:讲清楚 Event 从哪里导入、包含哪些字段(actionmodelparamsstateresult)、支持哪些生命周期动作,并结合 @strapi/database@strapi/core 的源码,说明钩子是如何被加载、组装进 Event 并在实体管理器中被触发的。读完后,你可以在 TypeScript 项目中以完整的类型安全方式编写各类生命周期钩子,并理解其执行机制与状态传递规则。

一、TypeScript 项目中的标准用法:从 @strapi/database 导入 Event 类型

在 TypeScript 项目中编写生命周期钩子时,官方推荐的做法是直接从 @strapi/database 导入 Event 类型:

// src/api/<api>/content-types/<content-type>/lifecycles.ts
import type { Event } from '@strapi/database';

export default {
  async beforeCreate(event: Event) {
    const { data } = event.params;
    // 在数据写入数据库前修改 data 即可影响最终落库内容
    // ...
  },
};

对应的官方文档见 Lifecycles。这个导入方式之所以成立,是因为 @strapi/database 包在其入口文件中显式导出了 Event 类型,见 入口文件 中的 export type { Model, JoinTable, Identifiers, Migration, Event }

需要明确一个适用前提:这里的 Event数据库层的生命周期事件类型,描述的是模型级别的 CRUD 动作。它与 Strapi 应用层的 register / bootstrap / destroy 生命周期(定义在 LIFECYCLES 常量)是两个不同概念,前者由实体管理器在每次查询/写入时触发,后者由核心在启动/销毁阶段触发。

二、Event 类型完整解析:五个字段的定义与语义

Event 接口定义在 lifecycles/types.ts,完整定义如下:

export interface Event {
  action: Action;                    // 当前正在执行的生命周期动作
  model: Meta;                       // 模型元数据(来自数据库 metadata)
  params: Params;                    // 当前操作的入参(where、data、populate 等)
  state: Record<string, unknown>;    // 同一订阅者跨动作保留的状态对象
  result?: any;                     // after* 动作下的查询/操作结果
}

各字段在源码中的含义:

1. action:20 种生命周期动作

Action 是一个联合类型,覆盖实体管理器全部数据库操作的前后两个时机:

export type Action =
  | 'beforeCreate'    | 'afterCreate'
  | 'beforeFindOne'   | 'afterFindOne'
  | 'beforeFindMany'  | 'afterFindMany'
  | 'beforeCount'     | 'afterCount'
  | 'beforeCreateMany' | 'afterCreateMany'
  | 'beforeUpdate'    | 'afterUpdate'
  | 'beforeUpdateMany' | 'afterUpdateMany'
  | 'beforeDelete'    | 'afterDelete'
  | 'beforeDeleteMany' | 'afterDeleteMany';

这些动作与实际调用点一一对应。在 实体管理器 中,每个 CRUD 方法都会成对调用 db.lifecycles.run(),例如:

// findOne 的触发流程(节选自 entity-manager/index.ts)
const states = await db.lifecycles.run('beforeFindOne', uid, { params });
// ... 执行查询 ...
await db.lifecycles.run('afterFindOne', uid, { params, result }, states);

值得注意的是 before* 动作传入 { params },而 after* 动作会额外携带 result 并复用上一步返回的 states——这就是 state 字段在前后钩子之间传递的机制。

2. model:模型元数据 Meta

model 字段是 Meta 类型,定义在 metadata.ts

export interface Meta extends Model {
  columnToAttribute: Record<string, string>;  // 列名 -> 属性名映射
  indexes: Index[];                            // 索引定义
  foreignKeys: ForeignKey[];                   // 外键定义
  lifecycles: Partial<Record<Action, SubscriberFn>>; // 挂载到模型上的生命周期函数
}

关键点在于最后一行:你在 lifecycles.ts 中导出的钩子函数,最终会作为 lifecycles 属性挂到模型的 Meta 元数据上,因此你可以在任意钩子内通过 event.model 读取模型结构信息(字段、索引、外键等)。

3. params:操作入参

Params 接口声明了所有可能出现的查询/写入参数:

export interface Params {
  select?: any;      // 字段筛选
  where?: any;       // 查询条件
  _q?: any;          // 全文搜索关键词
  orderBy?: any;     // 排序
  groupBy?: any;     // 分组
  offset?: any;      // 分页偏移
  limit?: any;       // 分页数量
  populate?: any;    // 关联填充
  data?: any;        // create/update 的数据体
}

实际取用哪个属性取决于 actionbeforeCreate / beforeUpdate / beforeDeleteMany 等操作关注 params.databeforeFindMany / beforeCount 关注 wherelimitoffsetorderBy 等。由于所有字段均为可选,使用前建议做防御性取值。

4. state:订阅者私有的跨动作状态

state 是一个普通对象,由生命周期 Provider 在 run() 执行过程中维护。从 Provider 实现 可以看到:

async run(action, uid, properties, states = new Map()) {
  if (isLifecycleHooksDisabled) return states;
  for (let i = 0; i < subscribers.length; i += 1) {
    const subscriber = subscribers[i];
    if (typeof subscriber === 'function') {
      const state = states.get(subscriber) || {};
      const event = this.createEvent(action, uid, properties, state);
      await subscriber(event);
      if (event.state) {
        states.set(subscriber, event.state || state);
      }
      // ...
    }
  }
  return states;
}

states 是一个以订阅者为 key、状态对象为 value 的 Map,在 before* 与对应的 after* 动作之间被传递(参见 entity-managerstates 的两次 run 调用)。这使你可以实现诸如“在 beforeCreate 记录原始值,在 afterCreate 做对比”之类的跨钩子逻辑。

5. result:after 动作的操作结果

result 仅在 after* 动作中提供,例如 afterFindOne 时它就是查询到的实体,afterCreate 时是创建后的实体。源码中对应 db.lifecycles.run('afterFindOne', uid, { params, result }, states) 的调用方式。

三、钩子如何被加载并挂载到模型

lifecycles.ts 并不是由数据库包直接扫描的,而是经由核心层的 API 加载器进入模型元数据。调用链如下:

  1. 加载文件API 加载器loadContentTypes() 中读取 content-types 目录下的每个内容模型目录,通过 loadDir() 将目录内各文件(包括 lifecycles.js)逐个导入并汇总为 ContentTypeDefinition。加载中会以 { schema: {}, actions: {}, lifecycles: {} } 作为默认值兜底(DEFAULT_CONTENT_TYPE)。
  2. 转换为模型transform-content-types-to-models.ts 将内容模型定义转换为数据库模型时执行 lifecycles: contentType?.lifecycles ?? {},把钩子函数原样带到模型的 lifecycles 字段——这正是 Meta.lifecycles 的数据来源。
  3. 形状校验:内容模型的校验器(validator.ts)用 yup 校验 lifecycles 对象,确保每个键对应的值都是函数(yup.mixed().nullable().isFunction()),即不允许把非函数挂到生命周期钩子上。

因此,编写钩子文件时应遵循两条约定:文件必须放在 content-types/<content-type>/lifecycles.ts(编译后为 .js),且默认导出一个对象,键名严格对应 20 种 Action 之一。

四、执行机制:内置订阅者与模型钩子的关系

@strapi/database 内部通过 createLifecyclesProvider()(见 lifecycles/index.ts)维护一个订阅者列表,初始就注册了两个内置订阅者:

let subscribers = [
  subscriberUtils.timestampsLifecyclesSubscriber,
  subscriberUtils.modelsLifecyclesSubscriber,
];
  • timestamps 订阅者timestamps.ts):在 beforeCreate / beforeCreateMany 中用 _.defaults(data, { createdAt: now, updatedAt: now }) 初始化时间戳,在 beforeUpdate / beforeUpdateMany 中刷新 updatedAt。这解释了为什么 Strapi 实体的 createdAt / updatedAt 无需手动赋值——它本身就是一条生命周期钩子。
  • models 订阅者models-lifecycles.ts):这是你的业务钩子被调用的入口:
export const modelsLifecyclesSubscriber: Subscriber = async (event) => {
  const { model } = event;
  if (model.lifecycles && event.action in model.lifecycles) {
    await model.lifecycles[event.action]?.(event);
  }
};

也就是说:Provider 对每个动作广播 Event,内置的 models 订阅者检查当前模型是否定义了该动作的钩子,有则调用。before*after* 钩子都会收到同一个可变对象引用params.dataparams.where 等),所以在 before* 钩子里直接修改 event.params.data 的字段,会真实影响随后的数据库写入——这是生命周期钩子“拦截并改写数据”的基本原理。

Provider 还提供 subscribe()(返回取消订阅函数)、clear()disable() / enable() 等方法。从 run() 开头对 isLifecycleHooksDisabled 的判断可以看出,禁用开关会短路整个生命周期管道,disable() 之后所有钩子(含时间戳内置钩子)都不会执行。

五、实用编写示例与注意事项

完整的多钩子示例

// src/api/article/content-types/article/lifecycles.ts
import type { Event } from '@strapi/database';

export default {
  async beforeCreate(event: Event) {
    const { data } = event.params;
    // 规范化:去除标题首尾空白
    if (typeof data.title === 'string') {
      data.title = data.title.trim();
    }
    // 记录原始值供 afterCreate 使用(state 跨钩子保留)
    event.state.originalTitle = data.title;
  },

  async afterCreate(event: Event) {
    // result 仅在 after* 动作中可用
    const created = event.result;
    // 例如触发通知、埋点等
  },

  async beforeFindMany(event: Event) {
    const { where, limit } = event.params;
    // 可在这里改写查询条件,例如强制注入软删除过滤
  },
};

编写时的关键注意事项

  1. 默认导出对象,键名即动作名:键名必须是 Action 联合类型中的 20 个值之一;核心校验器对未知键的处理依赖 yup 的 noUnknown(),拼错键名会在校验阶段暴露问题。
  2. before* 修改参数即生效:钩子内对 event.params.data / event.params.where 的修改会直接影响数据库操作,属于“写时”语义,不要在里面执行耗时 I/O 阻塞主流程。
  3. result 只在 after* 中可靠存在Event 类型中 result 是可选字段,before* 钩子里不要假设它有值。
  4. state 属于订阅者级别:Provider 以订阅者为 key 维护 states Map,你的内容模型钩子经由内置的 models 订阅者被调用,跨 before*/after* 的状态传递依赖实体管理器在两次 run() 之间透传 states(如 entity-manager/index.tsafterCreate 调用携带的 states 参数)。
  5. 与时间戳钩子的顺序:内置 timestampsLifecyclesSubscriber 排在订阅者列表首位,先于 models 订阅者执行,因此你在 beforeCreate 中看到的数据已经带有 createdAt / updatedAt 默认值(若未显式提供)。
  6. 数据库层,而非控制器层:这些钩子在 @strapi/database 实体管理器内触发,任何经过该模型的数据库操作(包括 Strapi 内部行为)都会命中;如果你只需要在某个 HTTP 接口中执行逻辑,controller 或 policy 是更贴合的位置。

六、关键源码索引

内容 文件
Event / Action / Params 类型定义 packages/core/database/src/lifecycles/types.ts
生命周期 Provider(订阅、执行、状态传递) packages/core/database/src/lifecycles/index.ts
内置时间戳钩子 packages/core/database/src/lifecycles/subscribers/timestamps.ts
模型钩子分发订阅者 packages/core/database/src/lifecycles/subscribers/models-lifecycles.ts
各动作的实际触发点 packages/core/database/src/entity-manager/index.ts
内容模型目录加载(lifecycles 文件导入) packages/core/core/src/loaders/apis.ts
内容模型转数据库模型(lifecycles 挂载) packages/core/core/src/utils/transform-content-types-to-models.ts
钩子形状校验 packages/core/core/src/domain/content-type/validator.ts
官方文档 docs/docs/docs/01-core/database/04-lifecycles.md

以上调用链均可在当前仓库中逐行验证:从 loaders/apis.ts 导入 lifecycles.ts,到 transform-content-types-to-models.ts 写入模型,再到 entity-manager/index.ts 中成对的 lifecycles.run('beforeXxx'/'afterXxx') 触发,构成了一条完整且类型安全的生命周期管道。

登录后查看全文
热门项目推荐
相关项目推荐