解析 WordPress `WP_List_Table` 类的源码:如何构建可扩展的后台列表页面。

各位观众老爷,晚上好!今天咱们聊聊 WordPress 后台列表页面的那些事儿,主要就是扒一扒 WP_List_Table 类的底裤,看看它是怎么把一个看似简单的列表页面,变得既强大又灵活的。

想象一下,你得开发一个插件,专门管理用户提交的反馈意见。这些反馈意见得在后台展示,能排序,能搜索,最好还能批量删除。如果让你从零开始写,那得掉多少头发啊?幸好 WordPress 提供了 WP_List_Table 这个救星,让我们能站在巨人的肩膀上。

WP_List_Table 是个啥?

简单来说,WP_List_Table 是一个抽象类,它定义了一个标准的 WordPress 后台列表页面的结构和行为。你可以继承它,然后根据自己的需求进行定制,比如定义列、添加排序、实现搜索等等。

第一步:继承 WP_List_Table

首先,我们需要创建一个类,并继承 WP_List_Table。这个类将会负责处理我们自定义列表页面的所有逻辑。

<?php

if( ! class_exists( 'WP_List_Table' ) ) {
    require_once( ABSPATH . 'wp-admin/includes/class-wp-list-table.php' );
}

class Feedback_List_Table extends WP_List_Table {

    /** ************************************************************************
     * REQUIRED. Set up a constructor that references the parent constructor.
     * We use the parent reference to set some default configs.
     ***************************************************************************/
    function __construct(){
        global $status, $page;

        //Set parent defaults
        parent::__construct( array(
            'singular'  => 'feedback',     //singular name of the listed records
            'plural'    => 'feedbacks',    //plural name of the listed records
            'ajax'      => false        //does this table support ajax?

        ) );

    }
    //...后面还有一大堆代码
}

?>

这段代码做了几件事:

  1. 包含了 class-wp-list-table.php 文件,确保 WP_List_Table 类已经加载。
  2. 创建了一个名为 Feedback_List_Table 的类,继承了 WP_List_Table
  3. 在构造函数中,调用了父类的构造函数,并设置了三个参数:
    • singular:单数形式的名称,这里是 ‘feedback’。
    • plural:复数形式的名称,这里是 ‘feedbacks’。
    • ajax:是否支持 AJAX,这里设置为 false,简化例子。

第二步:定义列(get_columns()

接下来,我们要定义列表页面中要显示的列。这需要重写 get_columns() 方法。

    /** ************************************************************************
     * REQUIRED. This method dictates the table's columns and titles.
     * This should return an array where the key is the column slug (and class)
     * and the value is the column's title text.  If you need a checkbox
     * column, use the special slug 'cb'.
     *
     * The 'cb' column will automatically handle the checkboxes and register
     * the columns headers.
     *
     * @return array An associative array containing column information: 'slugs'=>'Visible Titles'
     **************************************************************************/
    function get_columns(){
        $columns = array(
            'cb'        => '<input type="checkbox" />', //Render a checkbox instead of text
            'title'     => 'Title',
            'author'    => 'Author',
            'message'   => 'Message',
            'date'      => 'Date'
        );
        return $columns;
    }

这个方法返回一个数组,数组的键是列的 slug(用于 CSS 类),值是列的标题。

  • cb:这是一个特殊的 slug,用于显示复选框。WordPress 会自动处理复选框的渲染。
  • title:反馈的标题。
  • author:反馈的作者。
  • message:反馈的内容。
  • date:反馈的日期。

第三步:定义可排序的列(get_sortable_columns()

如果想让某些列可以排序,需要重写 get_sortable_columns() 方法。

    /** ************************************************************************
     * Optional. If you want one or more columns to be sortable (ASC/DESC toggle),
     * you will need to register it here. This should return an array where the
     * key is the column that needs to be sortable, and the value is db column to
     * sort by. Often, the key and value will be the same - as is the case here.
     *
     * @return array An associative array containing all the columns that should be sortable: 'slugs'=>'data_values'
     **************************************************************************/
    function get_sortable_columns() {
        $sortable_columns = array(
            'title'     => array('title',true),     //true means it's already sorted
            'author'    => array('author',false),
            'date'      => array('date',false)
        );
        return $sortable_columns;
    }

这个方法返回一个数组,数组的键是列的 slug,值是一个包含两个元素的数组:

  • 第一个元素是用于排序的数据库字段名。
  • 第二个元素是一个布尔值,表示是否默认升序排序(true)或降序排序(false)。

第四步:定义批量操作(get_bulk_actions()

如果想要添加批量操作,比如批量删除,需要重写 get_bulk_actions() 方法。

    /** ************************************************************************
     * Optional. If you need to include bulk actions in your list table, this is
     * the place to define them. Bulk actions are an associative array where
     * the key is the internal name and the value is the display name.
     *
     * @return array An associative array containing all the bulk actions: 'slugs'=>'Visible Titles'
     **************************************************************************/
    function get_bulk_actions() {
        $actions = array(
            'delete'    => 'Delete'
        );
        return $actions;
    }

这个方法返回一个数组,数组的键是操作的 slug,值是操作的显示名称。

第五步:处理批量操作(process_bulk_action()

定义了批量操作之后,还需要处理这些操作。

    /** ************************************************************************
     * Optional. It is possible to place message above the table, such as an
     * admin notice. Be careful about doing this with anything complicated -
     * the point is that the user is querying a database, and good UI/UX
     * dictates that they should see the data first.
     *
     * @return void
     */
    function process_bulk_action() {

        //Detect when a bulk action is being triggered...
        if( 'delete'===$this->current_action() ) {
            $ids = $_GET['feedback']; //注意安全,需要过滤和验证
            if (is_array($ids)) {
                foreach ($ids as $id) {
                    // 这里执行删除操作,比如调用 wp_delete_post() 或自定义的删除函数
                    // 假设我们有一个 delete_feedback 函数
                    delete_feedback($id);
                }
                wp_redirect(admin_url('admin.php?page=feedback_list&deleted=true')); // 重定向到列表页,并添加一个参数
                exit;
            } else {
                // 处理单个删除的情况
                delete_feedback($ids);
                wp_redirect(admin_url('admin.php?page=feedback_list&deleted=true'));
                exit;
            }
        }

    }

这个方法首先检查当前的操作是否是 ‘delete’,如果是,就获取选中的 ID,然后执行删除操作。注意,这里需要进行严格的安全检查,防止 SQL 注入等安全问题。

第六步:准备数据(prepare_items()

这是最关键的一步,我们需要从数据库中获取数据,并将其格式化成 WP_List_Table 可以接受的格式。

    /** ************************************************************************
     * REQUIRED! This is where you prepare your data for display. This method will
     * usually be used to query the database, sort and filter the data, and generally
     * get it ready to be displayed. At a minimum, we should set $this->items
     * and $this->set_pagination_args().
     **************************************************************************/
    function prepare_items() {
        global $wpdb; //This is used only if making any database queries

        /**
         * First, lets decide how many records per page to show
         */
        $per_page = 5;

        /**
         * REQUIRED. Now we need to define our column headers. This includes defining
         * a CSS class and column title. This information is used to label the table
         * headers.
         */
        $columns = $this->get_columns();
        $hidden = array();
        $sortable = $this->get_sortable_columns();

        /**
         * REQUIRED. Finally, we build an array to be used by the table.
         */
        $this->_column_headers = array($columns, $hidden, $sortable);

        /**
         * Process bulk action
         */
        $this->process_bulk_action();

        /**
         * Instead of querying a database, we're going to fetch the example
         * data and process it.
         */
        // $data = $this->example_data;

        // 获取总数
        $total_items = $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}feedback");

        // 排序参数
        $orderby = !empty($_GET["orderby"]) ? sanitize_sql_orderby($_GET["orderby"]) : 'date';
        $order = !empty($_GET["order"]) ? sanitize_sql_orderby($_GET["order"]) : 'DESC';

        // 当前页码
        $current_page = $this->get_pagenum();

        // 计算偏移量
        $offset = ($current_page - 1) * $per_page;

        // 查询数据
        $data = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$wpdb->prefix}feedback ORDER BY %s %s LIMIT %d, %d", $orderby, $order, $offset, $per_page), ARRAY_A);

        /**
         * REQUIRED for pagination. Let's figure out what page the user is currently
         * looking at. We'll need this later when we add pagination links.
         */
        $current_page = $this->get_pagenum();

        /**
         * REQUIRED for pagination. Let's find out the total number of records that
         * match the search query.
         */
        $total_items = count($data); //This should be the total number of records

        /**
         * The WP_List_Table class does not handle pagination for you, so we need
         * to handle it ourselves. We will use this helper function to calculate
         * the pagination arguments.
         */
        $this->set_pagination_args( array(
            'total_items' => $total_items,                  //WE have to calculate the total number of items
            'per_page'    => $per_page,                     //WE have to determine how many items to show on a page
            'total_pages' => ceil($total_items/$per_page)   //WE have to calculate the total number of pages
        ) );

        /**
         * REQUIRED. Now we can add our *sorted* data to the items property, where
         * it will be used by the rest of the WP_List_Table class.
         */
        $this->items = $data;
    }

这个方法做了很多事情:

  1. 定义了每页显示的记录数 ($per_page)。
  2. 调用 get_columns()get_hidden_columns()get_sortable_columns() 获取列的定义。
  3. 调用 process_bulk_action() 处理批量操作。
  4. 从数据库中获取数据,并进行排序和分页。
  5. 调用 set_pagination_args() 设置分页参数。
  6. 将数据赋值给 $this->items

第七步:显示列数据(column_default()column_xxx()

现在,我们需要定义如何显示每一列的数据。WP_List_Table 提供了两种方法:

  • column_default($item, $column_name):用于显示没有特殊处理的列。
  • column_xxx($item):用于显示名为 xxx 的列,比如 column_title($item) 用于显示标题列。
    /** ************************************************************************
     * REQUIRED! This is how you display the contents of each column. Because we previously
     * declared our columns with slugs, we can easily target each.
     *
     * If you want to make a column sortable, you need to register it with the sortable
     * columns array in the get_sortable_columns() method.
     *
     * @param array $item        A singular item (one full row's worth of data)
     * @param string $column_name The name/slug of the column to be processed
     * @return string Text or HTML to be placed inside the column
     **************************************************************************/
    function column_default( $item, $column_name ) {
        switch( $column_name ) {
            case 'message':
                return substr($item[ $column_name ], 0, 50) . '...'; // 截取部分内容
            default:
                return $item[ $column_name ]; //Show the whole array for troubleshooting purposes
        }
    }

    /** ************************************************************************
     * Recommended. This is a custom column method and is responsible for what
     * is rendered in any column with a name of 'title'. When editing a column with
     * a name that is a reserved word (like 'title') always prefix it with
     * your plugin's name. For example, if the plugin is called "Example Plugin"
     * and you need to render the title column, the method name should be
     * "example_plugin_column_title".
     *
     * Remember, when defining custom column handlers, the format is "column_[column_name]"
     * and you will need to register it in the get_columns() method.
     *
     * @param array $item        A singular item (one full row's worth of data)
     * @return string Text to be placed inside the column
     **************************************************************************/
    function column_title($item) {

        //Build row actions
        $actions = array(
            'edit'      => sprintf('<a href="?page=%s&action=%s&feedback=%s">Edit</a>',$_REQUEST['page'],'edit',$item['id']),
            'delete'    => sprintf('<a href="?page=%s&action=%s&feedback=%s">Delete</a>',$_REQUEST['page'],'delete',$item['id']),
        );

        //Return the title contents
        return sprintf('%1$s <span style="color:silver">(id:%2$s)</span>%3$s',
            /*$1%s*/ $item['title'],
            /*$2%s*/ $item['id'],
            /*$3%s*/ $this->row_actions($actions)
        );
    }

    /** ************************************************************************
     * REQUIRED if displaying checkboxes or using bulk actions! The 'cb' column
     * is given special treatment when columns are processed. It ALWAYS needs to have
     * it's own method.
     *
     * @param array $item        A singular item (one full row's worth of data)
     * @return string Text to be placed inside the column
     **************************************************************************/
    function column_cb($item) {
        return sprintf(
            '<input type="checkbox" name="%1$s[]" value="%2$s" />',
            /*$1%s*/ $this->_args['singular'],  //Let's simply repurpose the table's singular label ("movie")
            /*$2%s*/ $item['id']                //The value of the checkbox should be the record's id
        );
    }
  • column_default() 方法用于显示 ‘message’ 列,它截取了部分内容,避免内容过长影响页面布局。其他列则直接显示原始数据。
  • column_title() 方法用于显示 ‘title’ 列,它添加了编辑和删除链接。
  • column_cb() 方法用于显示复选框,这是批量操作的关键。

第八步:显示列表页面(display()

最后,我们需要在 WordPress 后台页面中显示这个列表。

    /** ************************************************************************
     * REQUIRED! This is how you display the table
     **************************************************************************/
    function display() {
        $this->prepare_items(); // 准备数据
        parent::display(); // 调用父类的 display() 方法
    }

这个方法首先调用 prepare_items() 方法准备数据,然后调用父类的 display() 方法显示列表页面。

第九步:在 WordPress 后台添加菜单项

为了让用户能够访问这个列表页面,需要在 WordPress 后台添加一个菜单项。

add_action('admin_menu', 'feedback_list_menu');

function feedback_list_menu() {
    add_menu_page(
        'Feedbacks',        // 页面标题
        'Feedbacks',        // 菜单标题
        'manage_options',   // 权限
        'feedback_list',    // 菜单 slug
        'feedback_list_page' // 显示页面的函数
    );
}

function feedback_list_page() {
    $feedback_list_table = new Feedback_List_Table();
    echo '<div class="wrap">';
    echo '<h2>Feedbacks</h2>';

    // 显示成功删除的消息
    if (isset($_GET['deleted']) && $_GET['deleted'] == 'true') {
        echo '<div class="updated"><p>Feedbacks deleted successfully.</p></div>';
    }

    $feedback_list_table->display();
    echo '</div>';
}

这段代码做了两件事:

  1. 使用 add_menu_page() 函数添加了一个菜单项。
  2. 定义了一个 feedback_list_page() 函数,用于显示列表页面。在这个函数中,创建了一个 Feedback_List_Table 实例,然后调用 display() 方法显示列表。

完整代码示例

<?php

if( ! class_exists( 'WP_List_Table' ) ) {
    require_once( ABSPATH . 'wp-admin/includes/class-wp-list-table.php' );
}

class Feedback_List_Table extends WP_List_Table {

    function __construct(){
        global $status, $page;

        parent::__construct( array(
            'singular'  => 'feedback',
            'plural'    => 'feedbacks',
            'ajax'      => false

        ) );

    }

    function get_columns(){
        $columns = array(
            'cb'        => '<input type="checkbox" />',
            'title'     => 'Title',
            'author'    => 'Author',
            'message'   => 'Message',
            'date'      => 'Date'
        );
        return $columns;
    }

    function get_sortable_columns() {
        $sortable_columns = array(
            'title'     => array('title',true),
            'author'    => array('author',false),
            'date'      => array('date',false)
        );
        return $sortable_columns;
    }

    function get_bulk_actions() {
        $actions = array(
            'delete'    => 'Delete'
        );
        return $actions;
    }

    function process_bulk_action() {

        if( 'delete'===$this->current_action() ) {
            $ids = $_GET['feedback']; //注意安全,需要过滤和验证
            if (is_array($ids)) {
                foreach ($ids as $id) {
                    // 这里执行删除操作,比如调用 wp_delete_post() 或自定义的删除函数
                    // 假设我们有一个 delete_feedback 函数
                    delete_feedback($id);
                }
                wp_redirect(admin_url('admin.php?page=feedback_list&deleted=true')); // 重定向到列表页,并添加一个参数
                exit;
            } else {
                // 处理单个删除的情况
                delete_feedback($ids);
                wp_redirect(admin_url('admin.php?page=feedback_list&deleted=true'));
                exit;
            }
        }

    }

    function prepare_items() {
        global $wpdb;

        $per_page = 5;

        $columns = $this->get_columns();
        $hidden = array();
        $sortable = $this->get_sortable_columns();

        $this->_column_headers = array($columns, $hidden, $sortable);

        $this->process_bulk_action();

        // 获取总数
        $total_items = $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->prefix}feedback");

        // 排序参数
        $orderby = !empty($_GET["orderby"]) ? sanitize_sql_orderby($_GET["orderby"]) : 'date';
        $order = !empty($_GET["order"]) ? sanitize_sql_orderby($_GET["order"]) : 'DESC';

        // 当前页码
        $current_page = $this->get_pagenum();

        // 计算偏移量
        $offset = ($current_page - 1) * $per_page;

        // 查询数据
        $data = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$wpdb->prefix}feedback ORDER BY %s %s LIMIT %d, %d", $orderby, $order, $offset, $per_page), ARRAY_A);

        $this->set_pagination_args( array(
            'total_items' => $total_items,
            'per_page'    => $per_page,
            'total_pages' => ceil($total_items/$per_page)
        ) );

        $this->items = $data;
    }

    function column_default( $item, $column_name ) {
        switch( $column_name ) {
            case 'message':
                return substr($item[ $column_name ], 0, 50) . '...'; // 截取部分内容
            default:
                return $item[ $column_name ]; //Show the whole array for troubleshooting purposes
        }
    }

    function column_title($item) {

        $actions = array(
            'edit'      => sprintf('<a href="?page=%s&action=%s&feedback=%s">Edit</a>',$_REQUEST['page'],'edit',$item['id']),
            'delete'    => sprintf('<a href="?page=%s&action=%s&feedback=%s">Delete</a>',$_REQUEST['page'],'delete',$item['id']),
        );

        return sprintf('%1$s <span style="color:silver">(id:%2$s)</span>%3$s',
            $item['title'],
            $item['id'],
            $this->row_actions($actions)
        );
    }

    function column_cb($item) {
        return sprintf(
            '<input type="checkbox" name="%1$s[]" value="%2$s" />',
            $this->_args['singular'],
            $item['id']
        );
    }

    function display() {
        $this->prepare_items();
        parent::display();
    }
}

add_action('admin_menu', 'feedback_list_menu');

function feedback_list_menu() {
    add_menu_page(
        'Feedbacks',
        'Feedbacks',
        'manage_options',
        'feedback_list',
        'feedback_list_page'
    );
}

function feedback_list_page() {
    $feedback_list_table = new Feedback_List_Table();
    echo '<div class="wrap">';
    echo '<h2>Feedbacks</h2>';

    // 显示成功删除的消息
    if (isset($_GET['deleted']) && $_GET['deleted'] == 'true') {
        echo '<div class="updated"><p>Feedbacks deleted successfully.</p></div>';
    }

    $feedback_list_table->display();
    echo '</div>';
}

// 模拟删除函数
function delete_feedback($id) {
    global $wpdb;
    $wpdb->delete( $wpdb->prefix . 'feedback', array( 'id' => $id ) );
}

// 模拟数据表创建和插入
register_activation_hook( __FILE__, 'create_feedback_table' );
function create_feedback_table() {
    global $wpdb;
    $table_name = $wpdb->prefix . 'feedback';

    $charset_collate = $wpdb->get_charset_collate();

    $sql = "CREATE TABLE $table_name (
        id mediumint(9) NOT NULL AUTO_INCREMENT,
        time datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
        title varchar(255) NOT NULL,
        author varchar(255) NOT NULL,
        message text NOT NULL,
        date datetime NOT NULL,
        PRIMARY KEY  (id)
    ) $charset_collate;";

    require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
    dbDelta( $sql );

    // 插入一些模拟数据
    $wpdb->insert(
        $table_name,
        array(
            'time' => current_time( 'mysql' ),
            'title' => 'Feedback 1',
            'author' => 'John Doe',
            'message' => 'This is the first feedback message.',
            'date' => current_time( 'mysql' )
        )
    );
    $wpdb->insert(
        $table_name,
        array(
            'time' => current_time( 'mysql' ),
            'title' => 'Feedback 2',
            'author' => 'Jane Smith',
            'message' => 'This is the second feedback message.',
            'date' => current_time( 'mysql' )
        )
    );
    $wpdb->insert(
        $table_name,
        array(
            'time' => current_time( 'mysql' ),
            'title' => 'Feedback 3',
            'author' => 'Peter Jones',
            'message' => 'This is the third feedback message.',
            'date' => current_time( 'mysql' )
        )
    );
    $wpdb->insert(
        $table_name,
        array(
            'time' => current_time( 'mysql' ),
            'title' => 'Feedback 4',
            'author' => 'Alice Brown',
            'message' => 'This is the fourth feedback message.',
            'date' => current_time( 'mysql' )
        )
    );
    $wpdb->insert(
        $table_name,
        array(
            'time' => current_time( 'mysql' ),
            'title' => 'Feedback 5',
            'author' => 'Bob White',
            'message' => 'This is the fifth feedback message.',
            'date' => current_time( 'mysql' )
        )
    );
    $wpdb->insert(
        $table_name,
        array(
            'time' => current_time( 'mysql' ),
            'title' => 'Feedback 6',
            'author' => 'Charlie Green',
            'message' => 'This is the sixth feedback message.',
            'date' => current_time( 'mysql' )
        )
    );

}
?>

重要提示:

  • 安全第一: 在处理用户输入时,一定要进行过滤和验证,防止安全漏洞。
  • 性能优化: 如果数据量很大,需要考虑性能优化,比如使用缓存。
  • 可扩展性: WP_List_Table 提供了很多扩展点,可以根据自己的需求进行定制。

总结

WP_List_Table 类是一个强大的工具,可以帮助我们快速构建可扩展的 WordPress 后台列表页面。通过继承它,并重写相应的方法,我们可以轻松地实现各种功能,比如排序、搜索、批量操作等等。记住,安全和性能是永远需要考虑的因素。

好了,今天的讲座就到这里。希望大家有所收获,早日摆脱掉头发危机!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注