跳到主内容

WordPress 钩子事件对象模式:do_action 传类型化对象的工程实践

100%
WordPress 钩子事件对象模式:do_action 传类型化对象的工程实践

WordPress 的 do_action() 传多个散参数时,参数顺序、参数个数、类型全靠人记;而 filter 链要求每个回调都 return,忘一个就静默出错。 WordPress 官方开发者博客 2026 年 8 月 3 日 Justin Tadlock 提出的解法很直接:传一个带类型的 PHP 事件对象,用对象自己的类名当 hook 名。上下文用只读属性,监听器可改的决策用可写属性——还是原来的 do_action(),不需要新框架。

这是作者在自有项目中的编码约定与设计模式探索,不是 WordPress Core 已通过的功能提案:文中没有对应的 Trac ticket 、 RFC 或目标版本。示例代码用了构造器属性提升、具名参数和 readonly 属性,需要 PHP 8.1+。

传统写法到底有多容易出错

先看最常见的一个通知型 action:

do_action( 'myplugin_member_registered', $userId, $plan );

add_action( 'myplugin_member_registered', static function ( $userId, $plan ) {
    // ...
}, 10, 2 );

三个隐性契约:参数顺序必须一致、两边都要记住传了几个参数、回调必须显式写 10, 2(WordPress 默认不会把所有参数透传)。而且 $plan 是什么完全看不出来——字符串、 ID 还是对象,编辑器给不了任何提示。

filter 更麻烦一层:

$sendWelcomeEmail = apply_filters( 'myplugin_send_welcome_mail', true, $userId, $plan );

add_filter( 'myplugin_send_welcome_email', static function ( $send, $userId, $plan ) {
    if ( 'free' === $plan ) {
        $send = false;
    }
    return $send;
}, 10, 3 );

每个回调都必须返回处理后的值。忘写 return $send;,值就被吞成 null,后续整条链拿到错误结果,而且不报错。

顺带一提,上面这段原文里派发用的是 myplugin_send_welcome_mail,监听器用的是 myplugin_send_welcome_email——两个名字对不上,照抄不会触发。这看起来是原文笔误,实际用的时候必须统一。

核心模式:对象作 payload,类名作 hook

一句话概括作者的提议:

do_action( $event::class, $event );

为什么 ::class 能直接当 hook 名?因为它返回完整类名字符串:

namespace MyPlugin\Members;

final class MemberRegistered
{
    public function __construct(
        public readonly int    $userId,
        public readonly string $plan,
        public          bool   $sendWelcomeEmail = true,
    ) {}
}

那么 MemberRegistered::class 就是 MyPlugin\Members\MemberRegistered。 WordPress 的 do_action() 第一个参数就是字符串,完整类名直接可用,不需要任何特殊 API 。而且 PHP 里如果两个插件声明了完全相同的全限定类名,通常会在类加载时就报冲突;两个相同的普通字符串 hook 则可能静默共享错误的监听器——这是类名方案的一个真实优势。

两类属性的分工

属性可见性代表什么
$userIdreadonly事件发生后不可被监听器改写的事实
$planreadonly同上
$sendWelcomeEmail可写监听器可以参与决定的结果,事件派发方在 do_action() 返回后读取

这就是关键设计:把 filter 的返回值搬到了对象内部。监听器不再负责「返回下一个值」,而是修改共享对象上的一个决策字段。

派发端:do_action 之后读属性

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 ) {
            // ...queue the welcome email.
        }
    }
}

整个流程里没有 apply_filters(),没有中间变量。do_action() 执行完,返回值被丢弃也不影响——真正被读的是对象属性的新状态。

PHP 对象在函数之间传递时传的是同一个句柄,不是深拷贝。所以监听器改的属性,派发方一定看得到。但反过来也成立:所有监听器改的是同一个对象,最终结果受 priority 和执行顺序影响。它不适合需要「前一个监听器的输出喂给后一个」的值变换管道。

监听端:一个事件两种姿态

观察型(不改,只记)

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
    ) );
} );

回调参数声明了具体类,$event->userId 直接有属性补全。日志、审计、统计、同步这类副作用行为走这条。

修改型(改决策字段)

use MyPlugin\Members\MemberRegistered;

add_action( MemberRegistered::class, function ( MemberRegistered $event ): void {
    // Free-plan members skip the paid welcome sequence.
    if ( 'free' === $event->plan ) {
        $event->sendWelcomeEmail = false;
    }
} );

不需要 return。就这么简单。

hook 名用类名还是固定字符串

这是作者给的唯一需要你做决定的地方,判断标准是一句话:你是否希望 hook 名永远不随代码重构而改变?

方案 A:MemberRegistered::class方案 B:'myplugin/member-registered'
重构友好度IDE 重命名类时会自动更新相关引用类怎么改名都不影响 hook 名
公共 API 稳定性类被重命名或移出 namespace,hook 名就变了,外部监听器静默失效字符串本身是稳定的公共契约
注册成本不用定义字符串字符串要维护,改错同样静默失效
命名空间隔离完整类名天然带 namespace自定义字符串仍是全局 hook,没有强制隔离

作者的推荐很明确:内部 hook 用类名,对外暴露的长期公共 API 用固定字符串,并且用类名时要配套 deprecation 流程。

为什么「静默失效」是这个模式最大的坑

WordPress 不会因为 add_action() 找不到匹配的派发而报错——这是它作为弱约定系统的固有特性。用类名当 hook 名后,你多了一条新的断裂路径:类重命名 / 移动 namespace = hook 名变化 = 第三方监听器全部掉线,且没有任何警告。

更隐蔽的是组合情形:类名固定,但第三方注册监听器时写的是手写字符串(有人会这么干,因为 ::class 语法看起来「太聪明」),两边对不上,同样静默。

所以真要用这个模式,我的做法是:类名定下就别改,或者改的时候在同一个发布里同时更新所有 add_action 引用;对外的扩展点一律用固定字符串,让第三方不必依赖你的类是否还存在。

迁移旧 hook 的风险清单

如果你是想把已有插件的散参数 hook 改成事件对象,下面几条必须提前处理:


  1. 盘点监听者。先全局搜旧 hook 名字符串。 WordPress 生态里「只有一个插件在监听」是幻觉,你的 hook 名很可能早就被主题、附属插件、甚至别人的代码监听了几百次。

  2. 加适配层,不要直接切。旧 hook 继续按原样派发,同时派发新事件对象。给适配层设一个明确的 deprecation 时间点,而不是「以后再说」。

  3. 新 hook 用固定字符串。既然要长期对外扩展,就别用 ::class。事件对象该用,但 hook 名用 'vendor/event-name' 这种稳定形式。

  4. 定义可写字段的白名单。不要给事件对象开一堆可写属性。每个可写字段都是一次第三方能插手的机会,只留真正需要协作决策的那一两个。

  5. 写进插件文档。给第三方一段能直接复制粘贴的监听示例,标明哪些属性只读、哪些可改。事件对象模式的好处完全建立在「别人知道怎么正确用」上。

七个必须知道的限制

原文里明确提到的,加上这种模式天然带来的:

  1. 类名 hook 会随重构断裂,且无警告。
  2. 旧 hook 的 payload 改动会破坏第三方。原本期望 function ( $userId, $plan ) 的回调,改成只传一个对象后可能不再执行,或收到类型完全不同的参数。
  3. 对象是共享可变状态,最终结果受 priority 和执行顺序影响。
  4. do_action() 不使用返回值。监听器只 return $event; 是无效的,必须改属性。
  5. 类型安全不是 Core 强制执行的。do_action() 收 mixed。别人用同一个 hook 名派发另一种对象,错误只会在类型化回调真正执行时暴露。
  6. $event::class 取的是运行时类名。允许继承时,子类会派发出不同的 hook 名。需要固定事件入口就显式写基类名。
  7. 不是 PSR-14 事件系统。缺少传播控制、自定义 listener provider 、 subscriber 批量注册、可替换 dispatcher 。priority 和 remove_action() 只提供有限能力。

还有一条容易被忽略的:这不能当安全边界用。WordPress 里每个 hook 都是全局的,任何代码都能 add_action( 'MyPlugin\Members\MemberRegistered', $cb )。事件对象的可写字段只是插件间的协作协议,不是权限控制。

[h3]和 PSR-14 的关系[/h3]

作者把这个模式和 PSR-14 事件分发器做了对照:event 是携带信息的对象,listener 是响应的 callable,dispatcher 负责分发。 WordPress 的 do_action() / add_action() 在 2004 年插件 API 1.2 就有了核心模型,但缺少上面那四项正式事件系统能力。如果你的项目后面真的需要「让监听器叫停后续执行」「批量管理监听器」「测试时替换整个分发机制」,那时再上完整事件系统,别在今天用 do_action() 硬凑。

什么时候值得用,什么时候不值得

场景建议
内部事件、 payload 参数 ≥ 3 个、类型明确值得,收益最大
需要 IDE 补全和 PHPStan/Psalm 静态分析值得
需要「 filter 式」决策但不想维护返回值链值得,可写属性正好替代
要防止 hook 名碰撞值得,类名带 namespace
事件本来就是「值变换管道」不适合,用 apply_filters()
需要停止后续监听器不适合,WordPress 没有传播控制
要支持 PHP 8.0 以下需改写,readonly 要 8.1+

相关阅读


这是 WordPress Core 的新 API 吗?
不是。原文是作者在自有项目里的设计探索,没有对应 Trac ticket 、 RFC 、 commit 或目标版本。整套东西只依赖 2004 年就存在的 do_action() / add_action(),属于编码约定而非 Core 功能。

为什么用 <code>$event::class</code> 而不是手写字符串?
两个理由。完整类名天然带 PHP namespace,两个插件可以各有一个 PluginA\Events\X 和 PluginB\Events\X 而互不干扰;类名重复在 PHP 里通常会在加载时直接报类名冲突,而相同字符串 hook 会静默共享错误的监听器。代价是类重命名会让外部监听器静默失效,所以只推荐用于内部 hook 。

监听器里 return $event; 有用吗?
没用。do_action() 不使用回调返回值,你返回的新对象不会回到派发方。必须直接修改事件对象上的可写属性(比如 $event->sendWelcomeEmail = false;)。技术上你也可以用 apply_filters() 传对象,但那就保留了 filter 的返回值纪律,没解决原问题。

我改了旧 hook 的参数,第三方插件会不会挂?
会,而且不报错。旧监听器期望 function ( $userId, $plan ),你改成只传一个对象后,它可能不再执行或收到错误类型。必须加适配层做过渡,并设明确的 deprecation 时间点。

这篇有帮助吗?
云上的幻象
云上的幻象查看主页

七彩云博客,分享 WordPress 建站实战与 AI 工具测评,覆盖服务器运维、站长工具、软件资源与电商运营干货,专注原创实用的主题插件、网站加速与安全优化教程。

906文章4评论

相关文章

评论 (0)

欢迎你,新朋友,感谢参与互动!文明发言,理性交流 · 首次评论将在审核后展示