跳到正文
原文
WordPress 开发者博客· Justin Tadlock·· 2026-08-04编辑评分36

重新思考 do_action():以对象表示事件、以类名作为钩子

Rethinking do_action(): Events as objects, hooks as class names

编辑导读

在 WordPress 插件和主题中,可将 do_action 的多个位置参数封装为单一类型化对象,并用事件类名或自定义字符串作为钩子。这样能减少参数顺序、命名空间和类型不明确带来的问题;自定义字符串可固定钩子名称,而类名重命名或迁移后,旧监听器将停止匹配。

在最近的一个项目中,我探索了按照 PSR-14 标准为 WordPress 构建事件分发器的概念。你可能想问,既然 WordPress 已经拥有一套名为“操作”的事件分发系统,我为什么还要尝试这样做。

这一探索既是为了乐趣,也是为了增进自己的理解。而在整个过程中,我学到了很多东西。

如果你在 WordPress 之上进行开发 you've used do_action()。你可能已经调用过它上百次,甚至上千次。你可能还在自己的插件和主题中编写过自定义钩子,让其他代码能够基于你的代码挂载行为。这是 WordPress 中最古老的可扩展性技巧之一,至今仍然像 WordPress 1.2.0 引入时一样好用。

但在某个时刻,我们都悄悄接受了一些粗糙之处,并将其视为“钩子就是这样的”。

只需对调用 do_action() 的方式稍作改变——传入一个普通对象,而不是一把参数——就能消除所有这些粗糙之处。它不需要自定义库,不需要框架,也不需要新增任何依赖。只是为你已经在使用的函数提供一个更好的默认用法。

当我探索自己的事件分发器时,我发现几乎所有的价值都集中在一个小小的习惯上。让我来解释一下。

目录

  1. 可以兼容的钩子
  2. The one-line idea
    1. 钩子命名:类,还是自定义字符串
  3. 分发基于事件的钩子
  4. 监听基于事件的钩子
  5. 事件对象为你带来了什么
  6. An old idea that WordPress had early
    1. 这种方法能带你走多远
    2. 接下来该往哪里走

用于兼容的钩子

假设你正在构建一个注册新成员的插件。我们来看一下该插件中可能出现的几个自定义钩子示例。

首先,你可能会宣告一次注册已经发生:

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

另一个插件可能会监听:

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

其次,你的插件需要得到一个答案,而不只是收到通知:是否应向该成员发送欢迎邮件?这是一个需要作出决定的问题,因此你应改用过滤器:

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

if ( $sendWelcomeEmail ) {
	// ...queue the welcome email.
}

另一个插件进行监听,根据提供的参数改变值:

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

	return $send;
}, 10, 3 );

这两种技术都没问题,而且与 WordPress 自身触发其钩子的方式不相上下。它们也带来了一些同样容易被人忽视的小烦恼:

  • 参数是按位置排列的:顺序是 $userId, $plan 还是 $plan, $userId?一共有多少个?你必须去阅读调用代码才能确定,并且记得传递 10, 2(对于过滤器则传递 10, 3),以便所有参数都能继续传递下去。
  • 名称位于全局作用域中:myplugin_member_registered 和 myplugin_send_welcome_mail 各自与站点上的所有其他插件共享同一个扁平命名空间。你会给它们添加前缀,然后指望不会出问题。
  • 没有类型约束:$plan 可能是任何东西,例如字符串、ID 或对象。大多数代码编辑器都无法提供帮助,因为就编辑器所知,它只是一个名为 $plan 的变量。

过滤器还带来了第四个专属于它的小麻烦:你必须记得返回该值。如果你忘记返回 return $send;,就会破坏链上的任何过滤器,而且很难把问题追溯到你添加的过滤器。

这些都不是钩子的问题,而是载荷的问题。无论钩子是操作还是过滤器,这些问题都会出现。钩子可以承载单个对象,也可以承载多个散装参数。而对象可以一次解决所有这些问题。

一句话概括这个思路

不要传递散装参数,而是传递一个描述所发生事件的对象。不要自行编造字符串名称,而是使用对象自身的类名作为钩子:

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

这就是完整的思路。该钩子的载荷现在是一个单一的类型化对象。名称就是该对象的类,因此从构造上就具备命名空间。未经致命错误,其他插件的 FullyQualified\ClassName 不会与你的发生冲突。

请注意,这仍然是 do_action(),而不是 apply_filters()。这并非疏忽。这项技术的优点之一在于,事件上的可变属性可以代替过滤器返回值原本需要承载的信息,同时避开过滤器版本带来的返回值纪律问题。稍后 we'll 通过欢迎邮件的决策来观察这一机制如何发挥作用。

命名钩子:使用类名或自定义字符串

为钩子命名有两种合适的方法,选择取决于一个问题:你是否需要一个保证永远不会改变的名称?

WordPress 中的每个钩子都是全局的。任何人都可以用任意名称调用 add_action()。因此,重点不在于谁被允许监听,而在于重构时名称是否保持不变。

如果你可以接受钩子名称跟随类名,请使用类名:

do_action( MemberRegistered::class, $event );

这种方式无需任何繁琐操作,天然具备命名空间;当你重命名或移动类时,IDE 也会同步重命名钩子。代价也正是如此:标签就是类路径,因此当你将 MemberRegistered 移入另一个命名空间或重命名它时,钩子名称也会随之改变。于是,任何仍指向旧名称的 add_action() 都会悄无声息地停止匹配。当然,代码中完善的弃用流程应能缓解此类问题,但那是另一个话题了。

如果你希望名称永久固定,请使用自定义字符串:

do_action( 'myplugin/member-registered', $event );

由于标签是 myplugin/member-registered 而不是类路径,因此你可以随意重命名或迁移 MemberRegistered,钩子名称绝不会改变。根据定义,它保证是稳定的。监听器可以硬编码 myplugin/member-registered,完全无需引用你的类。

经验法则:如果你不介意名称跟随类名,就使用 ::class;如果你希望名称永不改变,就使用固定字符串。

调度基于事件的钩子

让我们构建一个精简的示例,通过注册一名新成员来展示这一概念的工作方式。示例足够小,一次坐下来就能看完,而且其结构对应于你可能见过的注册流程:创建账户、宣布注册,然后让监听器决定接下来发生什么。

所有示例类都位于同一个命名空间 MyPlugin\Members 中,这正是最终钩子名称保持唯一的原因。

首先,看看事件本身,它是一个普通对象。它携带监听器需要了解的两项信息(谁注册了,以及选择了什么套餐),外加一个允许监听器修改的属性(是否发送欢迎邮件):

namespace MyPlugin\Members;

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

请注意这两类属性:

  • $userId 和 $plan 都是 readonly。这是监听器用于作出决策的上下文,而不是它应该重写的内容。 
  • $sendWelcomeEmail 特意设计为可变:它是插件在调度后读回的“答案”,将其关闭就是监听器表达“此次跳过欢迎邮件”的方式。尽管使用的是普通动作,但由于该属性可写,因此产生了类似过滤器的行为。

MemberRegistered 是一个与普通类无异的类。我将它保留为公共属性,因为这个事件只需要这些;但这不妨碍你像处理其他类一样,为事件添加自定义方法。当你需要限制某个值的更改方式时,将属性设为私有,并提供一个用于验证该值的 setter。当某个值需要派生时,添加 getter。当监听器总是重复相同的操作时,将其封装到辅助方法中。公共属性只是这一连续范围的最基础端点,并非事件所遵循的特殊规则。

这里就是成员完成注册、事件被触发的位置:一个小注册器类创建账户,随后立即触发事件,并在决定是否将欢迎邮件加入队列之前读回结果:

namespace MyPlugin\Members;

final class MemberRegistrar
{
	public function register( int $userId, string $plan ): void
	{
		// ...create the account, assign the plan, etc.

		$event = new MemberRegistered( userId: $userId, plan: $plan );

		// The member is registered. Announce it before anything else
		// happens, and let anything interested read or adjust it.
		do_action( $event::class, $event );

		if ( $event->sendWelcomeEmail ) {
			// ...queue the welcome email.
		}
	}
}

这确实就是该技术的精髓。你只需将单个事件对象传入 do_action():

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

将其与 do_action() 的函数签名进行对比:

do_action( string $hook_name, mixed ...$arg );

$hook_name 只是一个字符串,符合 $event::class 的要求。而 $arg 是混合可变参数。因此,$event 对象会作为 $arg[0] 传入,WordPress 端不会进行任何验证或转换。

你的插件通常会在此之前准备好创建账户和分配套餐的逻辑,而发送邮件的逻辑本身则存放在其他地方,但这超出了此处所述技术的范围。

注意,MemberRegistrar 中的任何位置都没有 apply_filters() 调用。取而代之的是 $sendWelcomeEmail 属性在完成这项工作:监听器对其进行自定义,MemberRegistrar 在 do_action() 执行后读回结果,并且不存在任何 return,因此不会有监听器在过程中将其遗忘。

监听基于事件的钩子

现在你通过操作钩子传递事件对象,其他代码便可以使用事件对象本身作出响应。监听器有两种形式,而这种模式同时支持两者。

一个观察事件的监听器会读取事件并作出响应,但不对其进行修改。这里,一个日志监听器会记录每次注册以供审计:

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 和 $event->plan 并作出响应,但从不修改 $sendWelcomeEmail。这就是观察者:它响应事件,而不改变事件。

而一个修改事件的监听器会改变事件;由于对象按句柄传递,MemberRegistrar 会读回这些更改。这里,免费套餐成员会跳过付费欢迎流程,而注册器无需了解任何套餐相关信息:

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

看看第二个监听器的签名:function ( MemberRegistered $event )。现在你的编辑器会自动补全 $event->userId 和 $event->plan。无需记忆位置参数,无需记住 10, 3,也无需猜测载荷中包含什么。

事件对象为你带来的好处

退一步看看,通过传递对象而非零散参数,有哪些 things 发生了变化:

  • 类型化的监听器/操作:每个回调都会对事件进行类型提示,因此你可以获得针对载荷的自动补全和静态分析,而不必手动追踪未加类型的变量。
  • 无冲突的名称:钩子是一个完全限定的类名(或你自己定义的带命名空间的字符串)。无需再为 myplugin_ 前缀费心猜测。
  • 通过普通操作实现类似过滤器的修改:这是最容易被低估的一点。监听器可以改变事件,而且由于对象按句柄传递,触发事件的代码会读回这些更改。你可以通过选择将哪些属性保留为可写来决定具体哪些内容可以被修改。

这一切都来自一种约定,所使用的函数早在多年前就已随 WordPress 一同推出。

假设你遵循标准的 PHP 编码规范,需要注意的是,每个事件/钩子都会有自己的文件。这样做的一个附带好处是,你的扩展点会变得自带文档。随着代码库不断壮大,你甚至可能决定将它们拆分到各自的 Event 或 Hook 子文件夹中。

WordPress 早期就有的一个旧想法

如果 event、listener 和 dispatcher 这些词让你感到似曾相识,那是因为这是软件设计中最古老的模式之一。在更广泛的编程领域中,根据你问的人以及看待它的角度不同,它会有各种不同的名称。名称会变,想法不会。

程序的某个部分会宣布发生了某件事,而其他任意数量的部分——第一部分对它们一无所知——则有机会作出响应。

  • 事件是一个对象,其中携带有关已发生事项的信息。
  • 监听器是任何接收事件并作出响应的可调用对象。
  • 分发器将事件交给监听器。

do_action() 和 add_action() 的工作方式基本就是这样。而真正让我感到非同寻常的是:自插件 API 在 2004 年的 1.2 版中推出以来,WordPress 就已经具备这种机制了。人们很容易忘记,WordPress 早在如此早期的时候就押注于可扩展性。

以自身的类名传递一个类型明确的对象,并不是什么生硬附加在钩子上的巧妙 hacks。它就是 PHP 领域其余部分在 PSR-14 中标准化采用的那种思维模型,只不过通过 WordPress 一直以来的 API 表达出来。你并不是在采用一种新的范式。你终于开始使用自己多年来一直身处其中的范式,并让对象发挥其应有的作用。

我不想夸大二者的相似之处。PSR-14 是一份正式契约,其中有一些组成部分在 WordPress 中并不存在。我稍后会讲到缺失的部分。但它的核心结构(事件、监听器、分发器)就存在于 do_action() 之中,而且一直如此。

这种方法最能将你带向何方

说实话?这涵盖了你可能编写的大多数自定义钩子。两个真正棘手的问题——安全的名称以及结构化、类型明确的有效载荷——在你以类名传递对象的那一刻便已解决。对于那些原本会被归为过滤器的钩子,你还会免费获得第三项好处:不必担心监听器忘记返回某个值,因为对象本身就承载着答案。

如果你去寻找,就会发现你无法获得 PSR-14 这类正式事件系统在原有基础上所增加的大部分内容:

  • 传播控制:监听器可以表示“停止,不再让其他人运行”。这超出了 remove_action() 和优先级已经为你提供的能力。
  • 自定义监听器提供程序,用于决定哪些监听器适用于某个事件。
  • 订阅者对象,可一次注册一批监听器(不过你也可以将某种类似机制附加到动作钩子上)。
  • 可替换的分发器,你可以整体替换它,例如在测试中捕获事件。

关键在于:大多数钩子从来都不需要上述任何东西,而且其中的每一样日后都可以叠加上去,无需抛弃你已经编写的内容。你不会把自己逼进死角。你只是在选择一个更好的默认方案,同时让未来的大门保持敞开。

接下来该往何处走

如果你发现自己希望某个监听器能够阻止其余监听器运行,或者希望能在大型代码库中手动布置数十个监听器,又或者希望能为测试替换整个分发机制,那么这种约定很可能已经不够用了。到了那一步,就值得考虑一套更完整的事件系统,其中包含分发器、监听器提供程序和订阅者类。

但那要等到你真正撞上瓶颈的那一天再做决定。在那之前,下次你想要 do_action() 时,不妨也试试获取一个事件对象。这用的是你已经熟悉的同一个钩子,只是发送了更好的数据载荷。

感谢 @juanmaguitar 和 @bph 对本文提出反馈。

来源:WordPress 开发者博客 · https://developer.wordpress.org/news/2026/08/rethinking-do_action-events-as-objects-hooks-as-class-names/

部分链接为推广/关联链接。