作者: Justin Tadlock
原文日期: 2026 年 8 月 3 日
原文: Rethinking do_action(): Events as objects, hooks as class names
来源: WordPress Developer Blog
先看 Hook 参数带来的小麻烦
假设一个插件负责注册新会员。注册完成后,它可以发出一个 action,让其他插件或主题接入:
do_action( 'myplugin_member_registered', $userId, $plan );
add_action( 'myplugin_member_registered', static function ( $userId, $plan ) {
// ...
}, 10, 2 );
如果调用方需要获得一个决定,而不只是通知,就可以使用 filter。例如,插件询问是否发送欢迎邮件:
$sendWelcomeEmail = apply_filters( 'myplugin_send_welcome_mail', true, $userId, $plan );
if ( $sendWelcomeEmail ) {
// ...把欢迎邮件加入发送队列。
}
add_filter( 'myplugin_send_welcome_email', static function ( $send, $userId, $plan ) {
if ( 'free' === $plan ) {
$send = false;
}
return $send;
}, 10, 3 );
注意,原文这两个示例中的 Hook 字符串分别写作 myplugin_send_welcome_mail 和 myplugin_send_welcome_email,两者不一致。实际代码中,发出 filter 的标签必须与监听器注册的标签相同;这里保留并指出原文的差异,不把它误当作可直接运行的一组配对代码。
这类写法符合 WordPress Hook 的常见用法,不过参数仍有几处不便:
- 参数依赖位置。 监听器必须记住参数顺序,还要在注册监听器时填写
10, 2或10, 3,才能接收全部参数。 - Hook 名称共享全局命名空间。 每个插件都需要加前缀,以降低名称冲突的机会。
- 载荷没有类型结构。 只看到变量名
$plan,编辑器和静态分析工具很难知道它究竟是字符串、ID 还是对象。 - Filter 监听器必须返回结果。 忘记
return $send;会影响过滤链,而且问题不容易定位。
这些摩擦主要来自传递的数据形式。无论 Hook 是 action 还是 filter,都可以把多个零散参数换成一个对象。
一个约定:对象承载事件,类名充当 Hook 名称
核心写法只有一行:
do_action( $event::class, $event );
do_action() 仍然是 WordPress 自带函数,不需要增加事件库或框架。区别在于它现在只接收一个有类型的事件对象;对象的类名则提供带命名空间的 Hook 标签。
Hook 标签可以直接使用事件类名,也可以使用自定义字符串:
do_action( MemberRegistered::class, $event );
使用 ::class 简洁,也方便 IDE 在类重命名时同步更新引用。但类移动到另一个命名空间或改名时,Hook 标签也会变化,仍指向旧名称的监听器就不会再匹配。需要长期固定名称时,可以用字符串:
do_action( 'myplugin/member-registered', $event );
这样可以调整类的命名空间,而不改变 Hook 标签。简单的选择规则是:接受 Hook 名称随类名变化,就用 ::class;要求标签稳定,就用固定字符串。两种方式都不改变 Hook 的全局可监听特性,主要区别在名称如何演进。
定义事件对象
下面的事件记录会员 ID、会员方案,以及监听器可以修改的欢迎邮件决定:
namespace MyPlugin\Members;
final class MemberRegistered
{
public function __construct(
public readonly int $userId,
public readonly string $plan,
public bool $sendWelcomeEmail = true,
) {}
}
$userId 和 $plan 是只读上下文:监听器可以据此作出判断,但不应重写注册事实。$sendWelcomeEmail 则故意保持可变,它承载了发起方随后要读取的决定。
事件类可以按普通类继续扩展:可以添加方法;需要约束状态变化时,也可以把属性设为私有并提供经过验证的 setter;派生值则可以通过 getter 暴露。公开属性只是这个示例采用的简化形式。
发出事件并读取监听器的决定
注册器先完成账户创建和方案分配,再发出事件。监听器执行完后,注册器读取事件对象上的变化:
namespace MyPlugin\Members;
final class MemberRegistrar
{
public function register( int $userId, string $plan ): void
{
// ...创建账户、分配方案等。
$event = new MemberRegistered( userId: $userId, plan: $plan );
// 会员已经注册。先发出通知,让感兴趣的代码读取或调整事件。
do_action( $event::class, $event );
if ( $event->sendWelcomeEmail ) {
// ...把欢迎邮件加入发送队列。
}
}
}
对象通过引用语义传递,监听器对可变属性所作的调整会反映在注册器持有的同一对象上。因此,sendWelcomeEmail 可以承担类似 filter 返回值的作用:监听器直接修改事件,发起方在 do_action() 执行后读取它。这样就不需要让每个 filter 监听器都记得返回值。
监听事件:观察或修改
只读取上下文并执行副作用的监听器,可以保持事件不变。例如,把注册信息写入审计日志:
use MyPlugin\Members\MemberRegistered;
add_action( MemberRegistered::class, static function ( MemberRegistered $event ): void {
error_log( sprintf(
'Member #%d registered on the %s plan.',
$event->userId,
$event->plan
) );
} );
如果需要影响后续决策,监听器也可以修改允许写入的属性:
use MyPlugin\Members\MemberRegistered;
add_action( MemberRegistered::class, function ( MemberRegistered $event ): void {
// 免费方案会员跳过付费欢迎流程。
if ( 'free' === $event->plan ) {
$event->sendWelcomeEmail = false;
}
} );
回调参数明确写成 MemberRegistered $event 后,编辑器可以根据类型提示事件属性。监听器不必记忆多个位置参数,也不必通过 10, 3 这类数字猜测载荷结构。
这种做法解决了什么
把对象作为单一载荷后,常见收益包括:
- 监听器可以对事件参数作类型声明,获得编辑器补全和静态分析支持。
- Hook 名称可以是完整类名或自定义的命名字符串,降低多个插件使用同一短名称的风险。
- action 也能表达需要回传决定的场景:由事件上明确可变的属性承载结果,不再依赖监听器返回值。
- 事件类可独立存放,让扩展点的结构更容易被阅读;代码库变大后,也可以按需要整理到
Event或Hook目录。
每个 Hook 仍需按实际契约决定哪些字段只读、哪些字段允许改变。可变字段就是监听器与派发方之间的协作接口,应当有清晰含义。
与 PSR-14 的关系和边界
事件、监听器和派发器是通用的解耦模式:一处代码报告某件事发生,其他部分可以在不了解派发方内部结构的情况下响应。WordPress 的 do_action() 与 add_action() 已经具备这种基本形态;WordPress Plugin API 自 1.2 版(2004 年)起就包含 Hook 机制。这里借用的是 PSR-14 所熟悉的事件对象思路,但不等于完整实现 PSR-14。
如果需要以下能力,仅采用事件对象约定还不够:
- 让一个监听器阻止后续监听器运行的传播控制;
- 根据事件决定适用监听器的自定义提供器;
- 一次注册一组监听器的 subscriber 对象;
- 可替换的派发器,例如在测试中捕获事件。
许多插件的自定义 Hook 并不需要这些能力。若后来确实需要,可以再引入更完整的事件系统;如果已经遇到大量监听器编排、停止传播或替换派发机制的需求,就值得考虑专用派发器、listener provider 与 subscriber 类。
还有一种常见的命名讨论:同一流程是否需要 before、执行中和 after 三个 Hook。原作者的建议是先判断它们是否真的是不同事件;如果注册前与注册后代表不同状态,可以设计不同的事件类,例如 MemberRegistering 和 MemberRegistered。这比把一个事件类硬塞进多个语义不同的 Hook 更清楚。
采用前要规划旧 Hook 的兼容
这篇文章展示了事件对象形式如何设计新的扩展点,没有给出把既有多参数 Hook 迁移到新签名的完整兼容步骤。对于已经供其他插件使用的公开 Hook,修改载荷形状会影响依赖旧参数的监听器;在切换前应单独规划兼容与弃用方式,不能假定原有监听器会自动适配。
对一般自定义 Hook,可以先尝试把事件对象作为载荷,让数据、类型和扩展点命名更集中;当需求超出普通 action/listener 的能力后,再升级到完整事件系统。
许可说明: 原文页面未列出明确的文章转载许可。










暂无评论内容