WordPress 社区新闻

重新思考 do_action():事件作为对象,钩子作为类名

查看官方原文 ↗ 发布于

在最近的一个项目中,我探索了按照PSR-14标准为WordPress构建事件调度器的概念。你可能会问,既然WordPress已经有自己的系统来分发事件,叫做动作,为什么我还要尝试。

这部分是有趣的探索,部分是为了自我启迪。而且我一路上学到了很多。

如果你是在WordPress上构建的,你已经用过do_action().你可能已经叫过一百次了。甚至可能有成千上万。你可能已经在自己的插件和主题里写了自定义钩子,让其他代码可以把行为挂在你的代码上。这是WordPress中最古老的扩展技巧之一,至今依然像1.2.0版引入时一样出色。

但在某个阶段,我们都默默接受了一些粗糙的边缘,认为这就是“开歌的运作方式”。

你点名的方式稍作改变——传递一个普通的物品而不是一堆争论——就能让所有争论变得平滑。它不需要自定义库。没有框架。没有新的依赖。只是给你已经用的函数做个更好的默认选择。do_action()

当我探索自己的事件调度器时,发现几乎所有的价值都存在于一个小习惯中。让我来解释一下我的意思。

目录

  1. 容忍的挂钩
  2. 单行概念
    1. 命名钩子:类,还是自定义字符串
  3. 派遣基于事件的钩子
  4. 聆听基于事件的钩子
  5. 事件对象给你带来了什么
  6. WordPress 早期提出的一个老想法
    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, $userId10, 210, 3
  • 该名称存在于全局范围:并且每个插件与网站上的其他插件共享一个扁平命名空间。你在它们前缀,然后抱有希望。myplugin_member_registeredmyplugin_send_welcome_mail
  • 没有类型:可以是字符串、ID、对象等任何东西。大多数代码编辑器帮不上忙,因为据他们所知,这只是一个名为 . 的变量。$plan$plan

过滤器本身又多了第四个纸割:你必须记得返回数值。如果你忘记了,链条上的任何滤网都会损坏,而且很难追溯到你加的滤网。return $send;

这些都不是钩子的问题。它们是有效载荷问题,无论钩子是动作还是过滤器,都会显现出来。一个钩子可以承载单一对象,也可以承载多个松散的论元。而一个物体能一次性修复所有这些。

单行概念

与其传递松散的论点,不如传递一个描述发生事情的物品。与其发明字符串名,不如用对象自己的类名作为钩子:

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

这就是全部的目的。钩子的有效载荷现在是一个单一的类型对象。名称是该对象的类,因此它是通过构造来命名的。没有其他插件会和你的发生碰撞而不会导致致命错误。FullyQualified\ClassName

注意这仍然是 ,不是。这不是疏忽。该技术的部分优点在于事件上的可变性质可以替代滤波器返回值的任意值,但不存在滤波器版本的返回纪律问题。我们稍后会通过欢迎邮件的决定来观察这件事的发展。do_action()apply_filters()

命名钩子:类,还是自定义字符串

给钩子起名字有两种好方法,选择归结为一个问题:你想要一个保证永远不会改变的名字吗?

WordPress 中的每一个钩子都是全球的。任何人都可以叫任何名字。所以这不是关于谁能听;关键是重构时名字是否会保留。add_action()

当你对钩子名跟踪该类时,使用类名

do_action( MemberRegistered::class, $event );

这完全不需仪式,命名空间是免费的,当你重命名或移动类时,IDE也会同时重命名钩子。关键就是:标签就是类路径,所以当你移动到另一个命名空间或重命名时,钩子名也会变。然后,任何还指向旧名字的声音都默默地停止匹配。当然,代码中完善的弃用程序应该能缓解这类问题,但那是另一个话题。MemberRegisteredadd_action()

想彻底锁定名字时,使用自定义字符串

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

因为标签是 Oracle,而不是类路径,你可以随意重命名或重定位,钩子名永远不会移动。它从定义上保证是稳定的。监听器可以硬编码,完全不引用你的类。myplugin/member-registeredMemberRegisteredmyplugin/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并且是。这是听众用来做决定的上下文,而不是应该重写的内容。$planreadonly
  • $sendWelcomeEmail是故意变的:这是插件在派遣后读出的“答案”,关闭它就像听众说“跳过欢迎邮件”。这就是从纯动作中产生的滤波器式行为,因为该属性是可写的。

MemberRegistered是像其他类一样的类别。我一直只用公共属性,因为这个事件只需要这些,但没有什么阻止你给事件自定义方法,就像对待其他类一样。当你想保护值的变化时,可以把属性设为私有,并开放一个验证它的设定器。当推导出一个值时,加入一个 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只是一个字符串,满足。并且是混合变数。所以 WordPress 端没有验证或转换,这个对象就被传递成了$event::class$arg$event$arg[0]

你的插件自然会在此之前就有账户创建和计划分配的逻辑,而发送邮件的逻辑则放在别处,但这超出了这里描述的技术范围。

注意,在 .而该属性则承担起了这个工作:监听者自定义它,执行完成后再读回来,听众在过程中不会忘记任何内容。apply_filters()MemberRegistrar$sendWelcomeEmailMemberRegistrardo_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

而一个变异的监听器会改变事件,因为对象会通过handle传递,它会读取回这个变化。在这里,免费计划会员跳过付费欢迎流程,注册商对计划一无所知: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->plan10, 3

事件对象给你带来了什么

退一步,数一数通过传递对象而非松散参数所改变的因素:

  • 类型监听者/动作:每次回调类型都会提示事件,这样你就能对负载进行自动补全和静态分析,而不是手动追踪未类型变量。
  • 无碰撞名称:钩子是一个完全限定的类名(或者你自己的命名空格字符串)。没有前缀轮盘。myplugin_
  • 通过单作用实现滤波式突变:这部是被低估的。监听器可以更改事件,由于对象通过句柄传递,分发该事件的代码会读取这些更改。你通过哪些属性可以写出,决定哪些内容是可变的。

所有这些都来自一次约定,使用了多年前随WordPress附带的功能。

假设你遵循标准的PHP编码规范,值得注意的是每个事件/钩子都会有自己的文件。这样做的一个额外好处是,你的扩展点数会成为自我记录的。随着代码库的增长,你甚至可以决定将它们拆分到单独的或子文件夹中。EventHook

WordPress 早期提出的一个老想法

如果“事件”、“监听者”和“调度者”这几个词让你耳熟,那是因为这是软件设计中最古老的模式之一。更广泛的编程界对它有不同称呼,取决于你问谁、怎么看。名字会变;但这个想法并不重要。

程序中的一部分会宣布发生了什么事,其他许多部分——第一部分一无所知——有机会回应。

  • 事件是一个携带关于某件事信息的物体。
  • 监听者是指任何接收事件并做出反应的可调用对象。
  • 调度员将活动交给听众。

这基本上就是运作方式和工作原理。我觉得真正令人惊讶的是:自从2004年插件API在1.2版本发布以来,WordPress就已经有了这个功能。人们很容易忘记WordPress早期就押注于可扩展性。do_action()add_action()

把一个类型对象用自己的类名传递,并不是某个巧妙的黑客工具,直接附加在钩子上。这正是 PHP 世界其他平台在 PSR-14 中所采用的思维模型,WordPress 一直以来的 API 中体现出来。你并没有采用新的范式。你终于开始用你站了多年的那个,让它承载它的重量。

我不想夸大相似之处。PSR-14 是一份正式合同,包含 WordPress 中不存在的部分。我马上会说到缺少的部分。但核心形态(事件、监听器、调度器)就在 中,一直如此。do_action()

这种方法能带你走多远

说实话?这涵盖了你可能写过的大多数自定义钩子。两个真正难的问题——安全名称和结构化、类型化的负载——在你通过一个对象的类名时即刻解决。对于那些本应是过滤器的钩子,你还免费获得了第三个胜利:听者无需忘记返回值,因为对象本身就承载着答案。

如果你去找,你可能没有得到的是像PSR-14这样正式事件系统所附加的大部分内容:

  • 传播控制:监听者说“停,别人跑”。这超出了你已经能给你的东西和优先级。remove_action()
  • 自定义听众服务决定哪些听众申请活动。
  • 订阅对象,可以同时注册一批监听者(不过你也可以把这种方式绑定到动作钩子上)。
  • 例如,你可以批发更换一个可更换的调度器,用于测试中的事件捕捉。

关键是:大多数钩子根本不需要这些,每一段都可以以后叠加,而不会丢弃你写的内容。你不会把自己逼到死角。你选择了一个更好的默认选项,同时保持门开着。

接下来该怎么办

如果你发现自己需要一个监听器来停止其他操作,手动在庞大的代码库中布线几十个监听器,或者希望能用测试替换整个调度机制,那你可能已经不适应这种惯例了。接下来,值得考虑一个更完整的事件系统,包括调度员、监听器提供者和订阅类。

但那是你真正撞墙那天的决定。在那之前,下次你拿到事件对象时,也试着去拿一个事件对象。这其实是你已经知道的那个钩子,只是送出了更好的有效载荷。