WordPress 的 do_action() 传多个散参数时,参数顺序、参数个数、类型全靠人记;而 filter 链要求每个回调都 return,忘一个就静默出错。 WordPress 官方开发者博客 2026 年 8 月 3 日 Justin Tadlock 提出的解法很直接:传一个带类型的 PHP 事件对象,用对象自己的类名当 hook 名。上下文用只读属性,监听器可改的决策用可写属性——还是原来的 do_action(),不需要新框架。
传统写法到底有多容易出错
先看最常见的一个通知型 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 则可能静默共享错误的监听器——这是类名方案的一个真实优势。
两类属性的分工
| 属性 | 可见性 | 代表什么 |
|---|---|---|
$userId | readonly | 事件发生后不可被监听器改写的事实 |
$plan | readonly | 同上 |
$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() 执行完,返回值被丢弃也不影响——真正被读的是对象属性的新状态。
监听端:一个事件两种姿态
观察型(不改,只记)
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 改成事件对象,下面几条必须提前处理:
- 盘点监听者。先全局搜旧 hook 名字符串。 WordPress 生态里「只有一个插件在监听」是幻觉,你的 hook 名很可能早就被主题、附属插件、甚至别人的代码监听了几百次。
- 加适配层,不要直接切。旧 hook 继续按原样派发,同时派发新事件对象。给适配层设一个明确的 deprecation 时间点,而不是「以后再说」。
- 新 hook 用固定字符串。既然要长期对外扩展,就别用
::class。事件对象该用,但 hook 名用'vendor/event-name'这种稳定形式。 - 定义可写字段的白名单。不要给事件对象开一堆可写属性。每个可写字段都是一次第三方能插手的机会,只留真正需要协作决策的那一两个。
- 写进插件文档。给第三方一段能直接复制粘贴的监听示例,标明哪些属性只读、哪些可改。事件对象模式的好处完全建立在「别人知道怎么正确用」上。
七个必须知道的限制
原文里明确提到的,加上这种模式天然带来的:
- 类名 hook 会随重构断裂,且无警告。
- 旧 hook 的 payload 改动会破坏第三方。原本期望
function ( $userId, $plan )的回调,改成只传一个对象后可能不再执行,或收到类型完全不同的参数。 - 对象是共享可变状态,最终结果受 priority 和执行顺序影响。
do_action()不使用返回值。监听器只return $event;是无效的,必须改属性。- 类型安全不是 Core 强制执行的。
do_action()收mixed。别人用同一个 hook 名派发另一种对象,错误只会在类型化回调真正执行时暴露。 $event::class取的是运行时类名。允许继承时,子类会派发出不同的 hook 名。需要固定事件入口就显式写基类名。- 不是 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+ |
相关阅读
- Anthropic 提示词工程最佳实践——把边界和约束写清楚,同样是「约定优于强制」的思路。
- 区块图案在模板中复用——区块主题开发里另一类「约定 + 工具」协作模式。
- OpenAI Agents API 是什么——另一种事件驱动架构的对照参考。
这是 WordPress Core 的新 API 吗?
do_action() / add_action(),属于编码约定而非 Core 功能。为什么用 <code>$event::class</code> 而不是手写字符串?
PluginA\Events\X 和 PluginB\Events\X 而互不干扰;类名重复在 PHP 里通常会在加载时直接报类名冲突,而相同字符串 hook 会静默共享错误的监听器。代价是类重命名会让外部监听器静默失效,所以只推荐用于内部 hook 。监听器里 return $event; 有用吗?
do_action() 不使用回调返回值,你返回的新对象不会回到派发方。必须直接修改事件对象上的可写属性(比如 $event->sendWelcomeEmail = false;)。技术上你也可以用 apply_filters() 传对象,但那就保留了 filter 的返回值纪律,没解决原问题。我改了旧 hook 的参数,第三方插件会不会挂?
function ( $userId, $plan ),你改成只传一个对象后,它可能不再执行或收到错误类型。必须加适配层做过渡,并设明确的 deprecation 时间点。







评论 (0)