本文约 9,204 字,预计阅读 24 分钟。文中命令与配置均经过实测,可直接参考执行。
引言:现成小工具总差那么一点
WordPress 自带的小工具(最新文章、分类、标签云、搜索、日历)功能都很基础。真实需求往往是:
- 热门文章(按浏览量排序,带缩略图);
- 作者简介(带头像、社交链接);
- 相关文章(当前分类下的同标签文章);
- 广告位(带到期时间和展示次数);
- 自定义的联系方式卡片。
这些都需要自己写。WordPress 提供了 WP_Widget 基类,继承它实现三个方法就能做出一个后台可配置、可拖拽的小工具。
原理分析:WP_Widget 的三个方法
class My_Widget extends WP_Widget {
public function __construct() {} // 注册基本信息
public function widget( $args, $instance ) {} // 前台输出
public function form( $instance ) {} // 后台表单
public function update( $new, $old ) {} // 保存逻辑
}
数据流转:
后台 form() 渲染表单 → 用户填写 → update() 清洗保存 → 存进 wp_options 的 widget_{id_base}
→ 前台 widget() 读取 instance 输出
存储位置:
SELECT option_name, option_value FROM wp_options
WHERE option_name LIKE 'widget_%';
option_value 是序列化数组,key 是实例编号(数字),value 是该实例的 instance 数组。
重要:$args 参数来自 register_sidebar() 里定义的 before_widget / after_widget / before_title / after_title,所以主题的包装 HTML 由主题决定,小工具只输出自己的内容。
保姆级实操
一、完整模板(热门文章小工具)
class MMF_Popular_Posts_Widget extends WP_Widget {
public function __construct() {
parent::__construct(
'mmf_popular_posts', // id_base,必须唯一且小写
'迷忙飞 · 热门文章', // 后台显示的名字
array(
'description' => '按浏览量显示最热门的文章,支持缩略图',
'classname' => 'mmf-popular-widget', // 会加到 wrapper 的 class 里
)
);
}
/**
* 前台输出
*/
public function widget( $args, $instance ) {
$title = ! empty( $instance['title'] ) ? $instance['title'] : '热门文章';
$num = ! empty( $instance['num'] ) ? absint( $instance['num'] ) : 5;
$days = ! empty( $instance['days'] ) ? absint( $instance['days'] ) : 30;
$thumb = ! empty( $instance['thumb'] );
$title = apply_filters( 'widget_title', $title, $instance, $this->id_base );
// 查询:按浏览量 meta 排序(需要主题或插件记录 post_views)
$q = new WP_Query( array(
'posts_per_page' => $num,
'post_status' => 'publish',
'meta_key' => 'post_views',
'orderby' => 'meta_value_num',
'order' => 'DESC',
'date_query' => array(
array( 'after' => $days . ' days ago' ),
),
'no_found_rows' => true, // 不需要分页统计,省一次查询
) );
echo $args['before_widget'];
if ( $title ) {
echo $args['before_title'] . esc_html( $title ) . $args['after_title'];
}
if ( $q->have_posts() ) {
echo '<ul class="mmf-popular-list">';
while ( $q->have_posts() ) {
$q->the_post();
echo '<li>';
if ( $thumb && has_post_thumbnail() ) {
printf(
'<a class="thumb" href="%s">%s</a>',
esc_url( get_permalink() ),
get_the_post_thumbnail( get_the_ID(), 'thumbnail', array( 'loading' => 'lazy' ) )
);
}
printf(
'<a class="title" href="%s">%s</a><span class="views">%s 次浏览</span>',
esc_url( get_permalink() ),
esc_html( get_the_title() ),
esc_html( number_format_i18n( (int) get_post_meta( get_the_ID(), 'post_views', true ) ) )
);
echo '</li>';
}
echo '</ul>';
} else {
echo '<p>暂无数据</p>';
}
echo $args['after_widget'];
wp_reset_postdata();
}
/**
* 后台表单
*/
public function form( $instance ) {
$title = isset( $instance['title'] ) ? $instance['title'] : '热门文章';
$num = isset( $instance['num'] ) ? absint( $instance['num'] ) : 5;
$days = isset( $instance['days'] ) ? absint( $instance['days'] ) : 30;
$thumb = isset( $instance['thumb'] ) ? (bool) $instance['thumb'] : true;
?>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>">标题:</label>
<input class="widefat"
id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>"
type="text" value="<?php echo esc_attr( $title ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'num' ) ); ?>">显示数量:</label>
<input class="tiny-text"
id="<?php echo esc_attr( $this->get_field_id( 'num' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'num' ) ); ?>"
type="number" step="1" min="1" max="20" value="<?php echo esc_attr( $num ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'days' ) ); ?>">时间范围(天):</label>
<input class="tiny-text"
id="<?php echo esc_attr( $this->get_field_id( 'days' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'days' ) ); ?>"
type="number" step="1" min="1" max="365" value="<?php echo esc_attr( $days ); ?>">
</p>
<p>
<input class="checkbox" type="checkbox"
id="<?php echo esc_attr( $this->get_field_id( 'thumb' ) ); ?>"
name="<?php echo esc_attr( $this->get_field_name( 'thumb' ) ); ?>"
<?php checked( $thumb ); ?>>
<label for="<?php echo esc_attr( $this->get_field_id( 'thumb' ) ); ?>">显示缩略图</label>
</p>
<?php
}
/**
* 保存
*/
public function update( $new_instance, $old_instance ) {
$instance = array();
$instance['title'] = sanitize_text_field( $new_instance['title'] );
$instance['num'] = absint( $new_instance['num'] );
$instance['days'] = absint( $new_instance['days'] );
$instance['thumb'] = ! empty( $new_instance['thumb'] );
if ( $instance['num'] < 1 || $instance['num'] > 20 ) {
$instance['num'] = 5;
}
return $instance;
}
}
add_action( 'widgets_init', function() {
register_widget( 'MMF_Popular_Posts_Widget' );
});
四个易错点:
get_field_id()/get_field_name()必须用(它们的格式是widget-mmf_popular_posts[2][title],手工拼必错);update()里必须对每个字段做清洗(sanitize_text_field、absint);widget()里所有输出要esc_*;- 循环后
wp_reset_postdata()。
二、给小工具加缓存(重要)
热门文章这种查询每次页面加载都要跑,缓到 transient 里:
public function widget( $args, $instance ) {
$cache_key = 'mmf_popular_' . $this->id; // 每个实例一个 key
$html = get_transient( $cache_key );
if ( false === $html ) {
ob_start();
// ... 输出逻辑 ...
$html = ob_get_clean();
set_transient( $cache_key, $html, HOUR_IN_SECONDS );
}
echo $args['before_widget'] . $html . $args['after_widget'];
}
// 文章更新时清缓存
add_action( 'save_post', function() {
global $wpdb;
$wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_mmf_popular_%'" );
});
三、新版区块小工具兼容
WordPress 5.8 之后,老式 WP_Widget 会被包进”传统小工具”区块继续工作,不需要重写。但如果你想让它变成原生区块,要注册区块类型(工作量大)。
务实做法:
- 老式小工具继续用,但要注意主题的
before_widget包装对区块界面可能失效; - 或者改用 区块 + 可复用区块 实现同样的效果(参见 可复用区块与区块样板)。
四、用 WP-CLI 管理小工具
wp widget list sidebar-1
wp widget add mmf_popular_posts sidebar-1 2 --title="热门推荐"
wp widget delete mmf_popular_posts-2
wp widget move mmf_popular_posts-2 --position=1
常见踩坑与失败案例
- 案例一:小工具表单保存后值丢失。根因是
form()里name属性手写成name="title"而不是get_field_name('title'),POST 上来的数据 WordPress 收不到。 - 案例二:小工具输出跑到页面顶部。和短代码一样——
widget()里如果用echo是没问题的(它不是过滤器),但如果用了ob_start却忘了return/echo结果就会空。 - 复选框永远保存为 true:
update()里写了$instance['thumb'] = isset($new['thumb']),但 HTML 里没加checked(),导致表单状态不回显。 - 案例三:小工具里的
WP_Query没加no_found_rows => true,每页多一次SQL_CALC_FOUND_ROWS查询,侧边栏有三个小工具时明显变慢。 - 缓存导致小工具内容不更新:用 transient 缓存后忘了在
save_post时清理。也可以用wp_cache的对象缓存替代。 register_widget在错误的钩子上:必须在widgets_init里,写在init里不生效。- id_base 用了大写或下划线:必须是小写字母、数字、连字符。用大写会导致小工具注册失败且不报错。
- 块主题下小工具完全不可用:块主题(
theme.json+ templates)不使用小工具系统,页头页脚走模板部件。这是设计使然,不是故障。
总结
自定义小工具的标准写法是继承 WP_Widget,实现 widget()(前台输出,注意转义和 wp_reset_postdata())、form()(后台表单,必须用 get_field_id() / get_field_name())、update()(保存时逐字段 sanitize)三个方法,然后在 widgets_init 里 register_widget()。性能上一定要给数据库查询加 transient 缓存并在 save_post 时清理——侧边栏是每页都执行的,慢查询会被放大几十倍。WordPress 5.8+ 的老式小工具会作为”传统小工具”区块继续工作,不用重写。
