用事件对象重想 WordPress 的 do_action():让 Hook 名称成为类名

作者: 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 的能力后,再升级到完整事件系统。

许可说明: 原文页面未列出明确的文章转载许可。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容