WordPress 钩子入门:action、filter 与插件开发
action 和 filter 的区别,说到底只有一句话:action 是「走到这里时顺便做一件事」,filter 是「把这个值改一下再交回去」。前者不需要返回值,后者必须返回。新手把 filter 回调写成不 return,页面不会报错,只会安静地丢内容——标题变空白、正文消失,这类问题能查一下午。
一、本质区别对照
| 维度 | action | filter |
|---|---|---|
| 用途 | 在某个时机插入执行代码 | 修改传入的数据再返回 |
| 是否需要返回值 | 不需要,返回值会被忽略 | 必须 return,否则数据被置空 |
| 注册函数 | add_action() | add_filter() |
| 触发函数 | do_action() | apply_filters() |
| 典型场景 | 输出统计代码、保存文章后同步数据、发送邮件 | 改标题、改摘要、改价格、改查询参数 |
底层实现其实是同一套:两者都存在 $wp_filter 全局数组里,add_action() 内部就是调用了 add_filter()。区别只是 do_action() 丢弃返回值,apply_filters() 把第一个回调的返回值依次传给下一个。所以严格说 action 也能返回值,只是没人接。
写自定义钩子时同理:想让别人改数据就用 apply_filters(),第一个参数是钩子名,第二个是默认值,后面才是附加参数;想让别人在你的流程里插一脚就用 do_action()。命名一律加前缀,避免和其他插件撞名。这两行代码是插件之间互相扩展的唯一正规通道,主题里硬改别人的插件文件是最不该做的事。
二、add_action 的四个参数
签名是 add_action( $hook, $callback, $priority = 10, $accepted_args = 1 )。后两个参数是绝大多数钩子失效的原因。
- priority:数字越小越早执行。默认 10。想让自己的代码在主题之后跑,写 20;想在所有人之前拦截,写 1 甚至负数。
- accepted_args:回调能接收几个参数。钩子传了 3 个参数,但你写默认的 1,回调里就只能拿到第一个,后两个是 null。要接全部参数必须显式写 3。
add_action( 'wp_footer', 'xk_footer_note', 20 );
function xk_footer_note() {
echo '<!-- built by www.wordpressx.com -->';
}
// 移除别人挂的钩子,优先级必须与添加时完全一致
remove_action( 'wp_footer', 'some_plugin_footer', 10 );
// save_post 传 3 个参数,必须声明 accepted_args 为 3
add_action( 'save_post', 'xk_on_save', 10, 3 );
function xk_on_save( $post_id, $post, $update ) {
// $post 是 WP_Post 对象,$update 是布尔值
}
remove_action() 的三个参数必须与 add_action() 完全对得上,优先级差一个数字就移除不掉。这也是「插件功能关不掉」的常见原因:插件用 99 挂的钩子,你按 10 去移除,当然没反应。
回调的命名要带自己的前缀。xk_、公司缩写_ 都行,但一定要加,因为 PHP 函数是全局的,两个插件都定义 footer_note() 会直接 fatal error。用匿名函数可以天然避开重名,代价是没法 remove_action(),调试时也看不出是谁挂的。
三、五个必会的钩子
| 钩子 | 类型 | 触发时机 | 常见用途 |
|---|---|---|---|
| wp_head | action | </head> 之前 | 注入 meta、统计代码、预加载 |
| wp_footer | action | </body> 之前 | 脚本、客服悬浮窗、延迟加载 |
| init | action | 每次请求初始化时 | 注册自定义文章类型、重写规则 |
| the_content | filter | 输出正文时 | 追加声明、插入广告、替换关键词 |
| save_post | action | 文章保存之后 | 同步外部数据、生成缓存、写自定义字段 |
两个使用注意:the_content 是过滤器不是动作,很多人调用 add_action( 'the_content', ... ) 也能跑起来,因为底层同一套,但语义上是错的,而且一旦回调没 return,正文就没了。wp_head 里不要 echo 大段 HTML,它会被塞进 head,拖慢首屏渲染,需要结构化的内容就拆到 wp_body_open 或 wp_footer。
再补几个实际开发里绕不开的:after_setup_theme 用来注册缩略图尺寸和菜单位置,比 init 更早;wp_enqueue_scripts 是唯一正确的引入样式脚本的时机,直接写在 wp_head 里会打乱依赖顺序;pre_get_posts 用来改主查询,比如让分类页多显示几篇,注意必须判断 is_main_query() && ! is_admin();wp_ajax_ 前缀加动作名是处理 AJAX 的标准入口,配套还要写 wp_ajax_nopriv_ 给未登录用户。把这些名字记住,比背函数名有用得多。
四、写一个最小插件
新建目录 wp-content/plugins/xk-demo/,里面放一个 xk-demo.php,文件头部的注释块是 WordPress 识别插件的依据,少一行「Plugin Name」后台就看不到它。
<?php
/**
* Plugin Name: XK Demo Hooks
* Description: 演示 action 与 filter 的最小插件
* Version: 1.0.0
* Author: 信科网络
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// filter:正文末尾追加一行声明
add_filter( 'the_content', 'xk_demo_append_note' );
function xk_demo_append_note( $content ) {
if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
return $content;
}
return $content . '<p>本文由信科网络技术团队整理,转载请注明来源。</p>';
}
// action:保存文章时记录时间
add_action( 'save_post', 'xk_demo_save_time', 10, 3 );
function xk_demo_save_time( $post_id, $post, $update ) {
if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
return;
}
if ( 'post' !== $post->post_type ) {
return;
}
update_post_meta( $post_id, '_xk_saved_at', current_time( 'mysql' ) );
}
这个文件能直接启用。几个必写的防护:文件顶部判断 ABSPATH 防止被直接访问;save_post 里排除修订版本和自动保存,否则每敲一次自动保存就往 meta 表写一行;the_content 里判断主查询,否则摘要、RSS、相关推荐全会被追加一遍声明。
五、钩子不生效时怎么查
钩子机制最大的特点是静默失败。名字拼错、优先级不对、回调没注册上,都不会有任何提示。按下面顺序查基本能定位:
- 先确认钩子名存在。在
wp-content/下搜do_action( '你的钩子名'或apply_filters(,搜不到就是名字错了或这个版本没有。 - 装 Query Monitor,看 Hooks & Actions 面板,它会列出当前页面所有已触发的钩子和挂在上面的事件,一眼能看出你的回调在不在。
- 用
has_action()判断是否注册成功,返回数字是优先级,返回 false 就是没有。 - 怀疑优先级时,把 priority 改成
PHP_INT_MAX试一次,如果突然生效,说明被后面的回调覆盖了。 - 确认文件真的被加载。插件没启用、
functions.php里早期 return、或者代码写在了if ( is_admin() )分支外,都会导致不执行。 - 最后看
wp-content/debug.log,路径不对、函数不存在这类问题会在里面留下 Fatal error 或 Warning,比在页面上猜快得多。
add_action( 'wp_footer', function () {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
echo '<pre>';
var_dump( has_action( 'the_content', 'xk_demo_append_note' ) );
var_dump( has_filter( 'the_content' ) );
echo '</pre>';
}, PHP_INT_MAX );
这段调试代码只在管理员登录时输出,不会影响访客。上线前记得删掉,或者套一层 defined( 'WP_DEBUG' ) && WP_DEBUG。
七、写在 functions.php 还是写成插件
两者技术上没有区别,钩子注册在哪都会执行。真正的差别在生命周期:functions.php 属于主题,换主题代码就没了;插件属于站点,换主题照样生效。判断标准只有一条——这段逻辑和「显示」有关还是和「数据」有关。
| 位置 | 适合放什么 | 换主题后 | 风险 |
|---|---|---|---|
| 子主题 functions.php | 布局调整、模板函数、样式脚本引入 | 需要重新迁移 | 写错直接白屏,连后台都进不去 |
| 自建插件 | 文章类型、字段、同步、接口 | 不受影响 | 插件停用后数据仍在,但功能消失 |
| 代码片段插件 | 临时补丁、一次性修复 | 不受影响 | 片段多了难以追踪来源 |
实践中的分法是:先用代码片段插件验证钩子确实生效,确认没问题再落到子主题或独立插件。functions.php 里写错一个分号,前台会白屏,后台也进不去,只能靠 FTP 或文件管理器把文件改回去——这也是建议把改动先放子主题、并且保留一份备份的原因。
八、两个会搞坏站点的坑
无限递归
在 save_post 回调里调用 wp_update_post(),会再次触发 save_post,形成死循环,表现为保存文章时转圈直到 504。判断方法很直接:打开 debug.log 看到同一个函数名连续刷几十行堆栈,就是递归。update_post_meta() 不会触发 save_post,所以能用 meta 解决就别改文章本身。标准写法是先移除再挂回。
function xk_sync_title( $post_id ) {
remove_action( 'save_post', 'xk_sync_title' );
wp_update_post( array(
'ID' => $post_id,
'post_title' => trim( get_the_title( $post_id ) ),
) );
add_action( 'save_post', 'xk_sync_title' );
}
add_action( 'save_post', 'xk_sync_title' );
污染 REST 与 AJAX 响应
挂在 init 或 wp_head 上的 echo 一旦在 REST 请求里执行,返回的 JSON 前面就会多出一段 HTML,前端解析直接失败,控制台报 Unexpected token <。判断条件用 if ( ! defined( 'REST_REQUEST' ) || ! REST_REQUEST ),或者更省事:所有输出都挂在 wp_footer,REST 请求根本不触发它。
钩子冲突导致的症状通常很怪,比如某段文字出现两次、价格算错、后台保存报错,这类问题可以按插件冲突的方法逐个停用定位。要系统性地写一个自己的插件,插件定制那篇给了目录结构和加载顺序。改主题时钩子和模板要一起看,主题定制里讲了子主题的继承规则。如果钩子里写错导致整站白屏,按白屏排查先关插件再定位文件。