搜索全站
常规配置 24 分钟阅读

WordPress 自定义小工具开发:WP_Widget 类完整写法

想在侧边栏放一个自定义模块(热门文章、作者简介、标签云增强),就得自己写小工具。本文给出 WP_Widget 类的完整模板(widget/form/update 三方法)、表单安全处理、缓存优化,以及新版区块小工具的兼容写法。

U
UFO_PF 2021-06-15 · 阅读 24 分钟
Wo 常规配置
阅读提示
本文约 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

常见踩坑与失败案例

  1. 案例一:小工具表单保存后值丢失。根因是 form() 里 name 属性手写成 name="title" 而不是 get_field_name('title'),POST 上来的数据 WordPress 收不到。
  2. 案例二:小工具输出跑到页面顶部。和短代码一样——widget() 里如果用 echo 是没问题的(它不是过滤器),但如果用了 ob_start 却忘了 return/echo 结果就会空。
  3. 复选框永远保存为 true:update() 里写了 $instance['thumb'] = isset($new['thumb']),但 HTML 里没加 checked(),导致表单状态不回显。
  4. 案例三:小工具里的 WP_Query 没加 no_found_rows => true,每页多一次 SQL_CALC_FOUND_ROWS 查询,侧边栏有三个小工具时明显变慢。
  5. 缓存导致小工具内容不更新:用 transient 缓存后忘了在 save_post 时清理。也可以用 wp_cache 的对象缓存替代。
  6. register_widget 在错误的钩子上:必须在 widgets_init 里,写在 init 里不生效。
  7. id_base 用了大写或下划线:必须是小写字母、数字、连字符。用大写会导致小工具注册失败且不报错。
  8. 块主题下小工具完全不可用:块主题(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+ 的老式小工具会作为”传统小工具”区块继续工作,不用重写。

U
UFO_PF作者

专注 WordPress 生态的实战作者,内容以「可复制、可验证」为第一原则。

留下你的想法

邮箱地址不会被公开。带 * 的为必填项。