返回

让WooCommerce折扣筛选不再刷新:AJAX实战指南

php

好的,这是您要求的博客文章内容:

修复 WooCommerce 折扣商品筛选器:告别页面刷新,拥抱 AJAX

搞网店的时候,我们经常想给用户提供各种方便的筛选功能,比如按折扣高低筛选商品。但有时候,满怀期待写好的筛选器,用起来却不尽人意——选了筛选条件,整个页面“唰”一下刷新了,而不是我们想要的局部动态加载。这体验,啧啧,确实不太行。

今天咱们就来聊聊这个事儿。碰到的具体情况是:想做一个根据折扣高低(最高折扣优先或最低折扣优先)来筛选商品的自定义功能,结果不但排序可能不大对劲,更烦人的是,每次筛选都导致整个页面重新加载,完全没有 AJAX 那种丝滑的感觉。

一、问题在哪?

我们先看看出问题的代码大概长啥样(这里用的是用户提供的示例代码结构):

// functions.php 或插件文件

// 通过 pre_get_posts 修改主查询
function filter_products_by_discount( $query ) {
    // 确保在前台、是主查询、并且 URL 参数里有 discount_filter
    if ( ! is_admin() && $query->is_main_query() && isset( $_GET['discount_filter'] ) && ! empty( $_GET['discount_filter'] ) ) {
        $discount_filter = $_GET['discount_filter'];

        // 根据参数值,添加排序规则
        if ( $discount_filter === 'highest' ) {
            // 注意:直接用 posts_orderby 可能有坑,后面会说
            add_filter( 'posts_orderby', 'orderby_discount_highest' );
        } elseif ( $discount_filter === 'lowest' ) {
            add_filter( 'posts_orderby', 'orderby_discount_lowest' );
        }
    }
}
add_action( 'pre_get_posts', 'filter_products_by_discount' );

// 定义排序函数(最高折扣,按售价降序)
function orderby_discount_highest( $orderby ) {
    // 直接使用元数据字段名,这依赖于查询是否正确 JOIN 了 meta 表
    // 更稳妥的方式是在 pre_get_posts 里设置 'orderby' => 'meta_value_num' 和 'meta_key'
    global $wpdb;
    // 这里直接返回字符串,假设JOIN了 wc_product_meta_lookup 表(WooCommerce 3.0+)
    // 或者需要JOIN postmeta 表
    // 返回 "{$wpdb->prefix}wc_product_meta_lookup.min_price DESC" 或类似的
    // 原代码的 "wc_product_meta._sale_price DESC" 非常不推荐,表名和字段名可能有误或不适用
    return " T_REPLACE_ME_WITH_CORRECT_ORDERBY_FOR_HIGHEST"; // 占位符,需要修正
}

// 定义排序函数(最低折扣,按售价升序)
function orderby_discount_lowest( $orderby ) {
    global $wpdb;
     // 同上,需要修正
    return " T_REPLACE_ME_WITH_CORRECT_ORDERBY_FOR_LOWEST"; // 占位符,需要修正
}


// 用于显示筛选下拉框的短代码
function discount_filter_shortcode() {
    ob_start(); // 开始输出缓冲
    ?>
    <form method="get" action="">
        <select name="discount_filter" onchange="this.form.submit();">
            <option value="">选择折扣排序</option>
            <option value="highest" <?php selected( isset($_GET['discount_filter']) ? $_GET['discount_filter'] : '', 'highest' ); ?>>折扣最高</option>
            <option value="lowest" <?php selected( isset($_GET['discount_filter']) ? $_GET['discount_filter'] : '', 'lowest' ); ?>>折扣最低</option>
        </select>
        <!-- 可以考虑加一个隐藏的 input 来保留其他可能的 URL 参数 -->
        <?php
        // 例如,保留当前分类、搜索词等
        // foreach ($_GET as $key => $value) {
        //     if ($key !== 'discount_filter') {
        //         echo '<input type="hidden" name="' . esc_attr($key) . '" value="' . esc_attr($value) . '" />';
        //     }
        // }
        ?>
    </form>
    <?php
    return ob_get_clean(); // 返回缓冲内容
}
add_shortcode( 'discount_filter', 'discount_filter_shortcode' );

瞅瞅这段代码,尤其是短代码里的 <select> 标签,它有个 onchange="this.form.submit();"。这就是“罪魁祸首”了。这个 JavaScript 片段的意思是,只要下拉框选项一变,立马提交整个表单。表单默认是 method="get",提交后浏览器会带着参数(?discount_filter=highestlowest)重新请求当前页面,所以,页面不刷新才怪呢!

另外,pre_get_posts 钩子里的实现方式也有改进空间:

  1. 筛选逻辑缺失 :这段代码只加了 排序 规则,并没有确保查询结果 只包含 打折商品。如果想只显示打折品,还需要加 meta_query 来检查 _sale_price 是否存在且有效。
  2. 排序方式 :直接用 posts_orderby 过滤器来添加基于 meta 数据的排序,有时会因为 WordPress 主查询没有自动 JOIN 必要的 meta 表而出错或效率低下。更推荐在 pre_get_posts 里直接设置查询变量 $query->set('meta_key', '_sale_price');$query->set('orderby', 'meta_value_num');(对数字排序),并配合 meta_query

二、为啥会这样?剖析原因

简单来说,问题根源有两个:

  1. 前端交互方式不对 :用了最传统的表单提交方式 (form.submit()),天然就会导致页面跳转或刷新。要实现局部更新,得靠 AJAX 技术。
  2. 后端处理逻辑不够完善
    • 没有强制筛选出打折商品。
    • 排序实现方式不够健壮,可能因为依赖的表未被正确关联(JOIN)而失效。_sale_price 通常存在于 wp_postmeta 表,WooCommerce 高版本可能会用 wp_wc_product_meta_lookup 表优化查询,直接在 posts_orderby 里写死表名和字段名风险较大。

三、怎么办?动手修复!

咱们得兵分两路,前端改用 AJAX,后端优化查询逻辑。

方案一:引入 AJAX - 告别刷新烦恼 (推荐)

这是解决页面刷新问题的正道。思路是:用户在前端操作下拉框 -> JavaScript 捕获这个动作 -> 阻止默认的表单提交 -> 通过 AJAX 把筛选条件(比如 highest)发送给后端的一个专门处理函数 -> 后端根据条件查询商品数据 -> 返回处理好的商品列表 HTML -> 前端 JavaScript 接收到 HTML -> 替换掉页面上旧的商品列表区域。

1. 后端:准备 AJAX 接口和查询逻辑

我们需要在 functions.php 或插件里加点东西:

// functions.php 或你的插件文件

/**
 * 注册 WordPress AJAX 处理函数
 * wp_ajax_nopriv_ 用于未登录用户,wp_ajax_ 用于已登录用户
 */
add_action('wp_ajax_nopriv_filter_discounted_products_ajax', 'filter_discounted_products_ajax_handler');
add_action('wp_ajax_filter_discounted_products_ajax', 'filter_discounted_products_ajax_handler');

function filter_discounted_products_ajax_handler() {
    // 安全第一:验证 Nonce
    check_ajax_referer('discount_filter_nonce', 'security');

    // 获取前端传来的筛选条件
    $filter_value = isset($_POST['filter']) ? sanitize_text_field($_POST['filter']) : '';

    // 准备 WP_Query 参数
    $args = array(
        'post_type' => 'product',
        'post_status' => 'publish',
        'posts_per_page' => wc_get_loop_prop('posts_per_page'), // 获取商店设置的每页数量
        'meta_query' => array(
            'relation' => 'AND', // 确保是并且的关系
            array( // 条件一:必须有售价(即正在打折)
                'key'     => '_sale_price',
                'value'   => 0,
                'compare' => '>',
                'type'    => 'NUMERIC'
            ),
             array( // 条件二:必须是可售状态
                 'key' => '_stock_status',
                 'value' => 'instock'
             )
            // 你可能还需要加入其他的 meta_query 或 tax_query,比如按分类筛选
            // ...
        ),
        // 根据筛选条件设置排序
        'meta_key' => '_sale_price', // 指定按哪个元数据排序
    );

    // 根据 filter_value 设置排序规则
    if ($filter_value === 'highest') {
        $args['orderby'] = 'meta_value_num'; // 按数字类型排序
        $args['order'] = 'DESC'; // 降序,售价高(折扣力度可能理解为原价-售价,或者直接看售价)
                                  // 注意:“最高折扣”的定义可能需细化,这里按“售价最高”处理
    } elseif ($filter_value === 'lowest') {
        $args['orderby'] = 'meta_value_num'; // 按数字类型排序
        $args['order'] = 'ASC'; // 升序,售价低
    } else {
        // 如果没有有效的 filter 或 filter 为空,可以按默认排序或不加排序参数
        unset($args['meta_key']); // 移除按售价排序
        // $args['orderby'] = 'date'; // 比如按发布日期
        // $args['order'] = 'DESC';
    }

    // 执行查询
    $products_query = new WP_Query($args);

    // 开始生成商品列表的 HTML
    ob_start();

    if ($products_query->have_posts()) {
        woocommerce_product_loop_start(); // 输出 WooCommerce 产品循环开始的 HTML
        while ($products_query->have_posts()) {
            $products_query->the_post();
            wc_get_template_part('content', 'product'); // 使用标准的 WooCommerce 产品单元模板
        }
        woocommerce_product_loop_end(); // 输出 WooCommerce 产品循环结束的 HTML
    } else {
        // 如果没有找到商品,显示提示信息
        woocommerce_no_products_found();
    }

    // 重置查询,以免影响页面其他部分
    wp_reset_postdata();

    // 获取缓冲中的 HTML 内容
    $html_output = ob_get_clean();

    // 返回 JSON 数据,包含 HTML
    wp_send_json_success(array('html' => $html_output));

    // 切记:WordPress AJAX 处理函数最后必须 die() 或 wp_die()
    wp_die();
}


/**
 *  给我们的 JavaScript 文件传递必要的 PHP 变量
 *  例如 AJAX 请求的 URL 和 Nonce
 */
function enqueue_discount_filter_scripts() {
    // 假设你的主题或插件有一个 main.js 文件,并且依赖 jQuery
    // 确保只在需要筛选器的页面加载 JS
    if ( is_shop() || is_product_category() || is_product_tag() /* 或其他你的商品列表页条件 */ ) {
        wp_enqueue_script('discount-filter-js', get_template_directory_uri() . '/js/discount-filter.js', array('jquery'), '1.0', true); // 路径根据你的实际情况修改

        // 使用 wp_localize_script 把数据传到前端 JS
        wp_localize_script('discount-filter-js', 'discountFilterData', array(
            'ajax_url' => admin_url('admin-ajax.php'), // WordPress AJAX 处理中心
            'nonce'    => wp_create_nonce('discount_filter_nonce') // 创建 Nonce 用于安全验证
        ));
    }
}
add_action('wp_enqueue_scripts', 'enqueue_discount_filter_scripts');

注意点:

  • 安全Noncecheck_ajax_refererwp_create_nonce 是配套使用的,防止跨站请求伪造 (CSRF)。
  • 查询逻辑 :用了 WP_Query 来执行新的商品查询。关键在于设置 meta_query 来筛选出有 _sale_price 且大于0的商品,并同时通过 meta_key, orderby, order 参数来控制排序。这里将“最高/最低折扣”简单理解为按“售价”高低排,你可以根据业务需求调整 meta_key(比如计算折扣百分比存为另一个 meta field 再排序)。
  • 返回 HTML :直接在后端生成商品列表的 HTML (wc_get_template_part('content', 'product')),然后通过 wp_send_json_success 发送给前端。这样做最省事,前端不用拼 HTML。
  • wp_localize_script :这是把后端数据(AJAX URL 和 Nonce)安全传递给前端 JavaScript 的标准方法。

2. 前端:改造短代码和编写 JavaScript

首先,修改之前的短代码,去掉 onchange<form> 标签(如果它只包裹 select 的话),并给你的商品列表区域一个唯一的 ID,方便 JS 定位和替换内容。

// 短代码改造 (discount_filter_shortcode 函数)

function discount_filter_shortcode() {
    ob_start();
    ?>
    <div class="discount-filter-wrap">
        <label for="discount_filter_select">按折扣排序:</label>
        <select id="discount_filter_select" name="discount_filter">
            <option value="">默认排序</option>
            <option value="highest" <?php selected( isset($_GET['discount_filter']) ? $_GET['discount_filter'] : '', 'highest' ); ?>>折扣最高</option>
            <option value="lowest" <?php selected( isset($_GET['discount_filter']) ? $_GET['discount_filter'] : '', 'lowest' ); ?>>折扣最低</option>
        </select>
        <!-- 加一个加载指示器,提升体验 -->
        <span class="loading-indicator" style="display: none;">加载中...</span>
    </div>
    <?php
    return ob_get_clean();
}
// add_shortcode 不变

然后,在你主题或插件的 JS 文件里 (比如前面提到的 discount-filter.js) 加入 AJAX 逻辑:

// discount-filter.js

jQuery(document).ready(function($) {

    // 监听我们改造后的下拉框的 change 事件
    $('#discount_filter_select').on('change', function() {
        var filterValue = $(this).val(); // 获取选中的值 (highest, lowest, or '')
        var productContainer = $('.products'); // 找到 WooCommerce 商品列表的容器,它的 class 通常是 .products
                                               // !! 确认你页面上商品列表容器的确切选择器 !!
        var loadingIndicator = $('.loading-indicator'); // 加载指示器

        // 如果没找到商品容器,直接退出,避免出错
        if (!productContainer.length) {
            console.error('Product container not found!');
            return;
        }

        // 显示加载提示
        loadingIndicator.show();
        // 可以给容器加个“加载中”的样式,比如降低透明度
        productContainer.css('opacity', 0.5);

        // 发起 AJAX 请求
        $.ajax({
            url: discountFilterData.ajax_url, // 后端传过来的 AJAX 地址
            type: 'POST', // 使用 POST 方法
            data: {
                action: 'filter_discounted_products_ajax', // 后端定义的 AJAX action hook 名
                security: discountFilterData.nonce, // 后端传过来的 Nonce 值
                filter: filterValue // 把选中的筛选值传过去
            },
            success: function(response) {
                // 请求成功后
                if(response.success && response.data.html) {
                    // 用后端返回的 HTML 替换掉原来的商品列表内容
                    productContainer.html(response.data.html);

                    // WooCommerce 可能需要重新初始化某些 JS 效果(如添加到购物车按钮的 AJAX)
                    // 这个根据你的主题和插件可能需要额外处理,常见的有触发 'init_variation_form' 等事件
                    $(document.body).trigger('wc_fragment_refresh'); // 尝试触发购物车片段刷新(有时需要)
                    $(document.body).trigger('init_add_to_cart_variation'); // 如果有可变商品

                     // 这里可以添加对分页的处理逻辑,比如重新渲染分页链接
                     // 如果 AJAX 返回了分页信息,更新分页区域

                } else {
                    // 如果后端返回失败或数据格式不对
                    console.error('AJAX Error:', response);
                    // 可以给用户一个友好的提示
                    productContainer.html('<p>加载商品失败,请稍后再试。</p>');
                }
            },
            error: function(jqXHR, textStatus, errorThrown) {
                // 网络错误或其他 AJAX 错误
                console.error('AJAX Request Failed:', textStatus, errorThrown);
                 productContainer.html('<p>加载商品时遇到网络问题,请检查连接后重试。</p>');
            },
            complete: function() {
                // 不论成功失败,请求完成后执行
                // 隐藏加载提示
                loadingIndicator.hide();
                // 恢复容器透明度
                productContainer.css('opacity', 1);

                 // 平滑滚动到列表顶部,可选
                 // $('html, body').animate({ scrollTop: productContainer.offset().top - 100 }, 500);
            }
        });
    });

    // 进阶:考虑使用 History API (pushState) 更新浏览器 URL
    // 这样用户刷新页面或分享链接时,能保持当前的筛选状态
    // 这会复杂一些,需要处理 popstate 事件等,此处暂不详述
});

JavaScript 注意点:

  • 选择器$('.products') 是 WooCommerce 默认的产品列表容器类名,请根据你的实际页面 HTML 结构调整。
  • Loading 状态 :添加加载指示器和容器透明度变化,能改善用户体验。
  • 错误处理successerror 回调都要考虑,给用户反馈。
  • WooCommerce JS :AJAX 加载新内容后,某些依赖 JS 的 WooCommerce 功能(如可变商品选项、添加到购物车按钮的 AJAX 行为)可能需要重新初始化。上面代码里加了 trigger 尝试处理,具体看情况可能需要更复杂的处理。
  • 分页 :上面代码没处理分页。完整的 AJAX 方案还需要处理分页链接,让分页也通过 AJAX 加载。

3. 总结 AJAX 方案

这种方式是现代 Web 开发的主流,用户体验好。虽然代码量比原来多了不少,但结构清晰,前后端职责分明。

方案二:优化 pre_get_posts (如果坚持用页面刷新)

如果你确实因为某些原因(比如不想引入复杂JS)还能接受页面刷新,那至少应该优化一下后端的 pre_get_posts 逻辑,让它更健壮、功能更完整。

// functions.php 或你的插件文件

// 移除旧的 filter_products_by_discount 和相关的 posts_orderby 过滤器及函数

// 新的 pre_get_posts 处理函数
function enhanced_filter_products_by_discount( $query ) {
    // 只在主查询、前台、且是商品归档页 (shop, category, tag) 时执行
    if ( ! is_admin() && $query->is_main_query() && $query->is_post_type_archive('product') || $query->is_tax(get_object_taxonomies('product')) ) {

        // 检查 URL 中是否有 discount_filter 参数
        if ( isset( $_GET['discount_filter'] ) && ! empty( $_GET['discount_filter'] ) ) {
            $discount_filter = sanitize_text_field( $_GET['discount_filter'] );

            // 核心改进:添加 meta_query 筛选出打折商品
            $meta_query = $query->get('meta_query') ?: array(); // 获取已有的 meta_query,或初始化为空数组
            $meta_query['relation'] = 'AND'; // 确保多个条件是 "AND" 关系
            $meta_query[] = array(
                'key'     => '_sale_price',
                'value'   => 0,
                'compare' => '>',
                'type'    => 'NUMERIC'
            );
            // 你可能还需要确保商品有货等,添加更多 meta_query 条件
            $meta_query[] = array(
                'key' => '_stock_status',
                'value' => 'instock'
             );
            $query->set( 'meta_query', $meta_query );

            // 核心改进:使用 set() 方法设置排序参数,更推荐
            $query->set( 'meta_key', '_sale_price' );
            $query->set( 'orderby', 'meta_value_num' ); // 按数字排序

            if ( $discount_filter === 'highest' ) {
                $query->set( 'order', 'DESC' ); // 售价高(你可以根据需要调整逻辑)
            } elseif ( $discount_filter === 'lowest' ) {
                $query->set( 'order', 'ASC' ); // 售价低
            }
             // 如果 filter 值无效,则不按 sale_price 排序,移除 meta_key
             else {
                  $query->set( 'meta_key', null); // 或 $query->set('meta_key', '');
                  $query->set('orderby', 'date'); // 回退到默认排序,比如日期
                  $query->set('order', 'DESC');
             }
        }
    }
}
add_action( 'pre_get_posts', 'enhanced_filter_products_by_discount' );

// 短代码保持原样(带 onchange="this.form.submit();" 的那个版本)
// 因为这个方案就是依赖页面刷新的

这个方案的改进点:

  • 筛选打折品 :明确加入了 meta_query,确保只有 _sale_price > 0 的商品才会被查询出来。
  • 健壮的排序 :用 $query->set() 直接修改查询变量,这是 pre_get_posts 的标准用法,比依赖 posts_orderby 过滤器更可靠。
  • 目标页面判断 :增强了对目标页面的判断 (is_post_type_archive('product'), is_tax(...)),避免在不相关的页面执行逻辑。

但是!请注意: 这个方案解决了数据准确性问题,但没有 解决页面刷新的问题。选择这个方案,意味着你接受筛选时整个页面重新加载。

选择哪个方案,就看你对用户体验的要求了。追求丝滑动态效果,请务必选择 AJAX 方案。如果项目简单或者有特殊限制,优化后的 pre_get_posts 也能用,只是体验上差一截。