Клиент зашёл в ваш интернет-магазин, хочет найти синие кроссовки размера 43 стоимостью до 5000 рублей. Он тыкает в фильтр — и страница перезагружается. Ждёт. Секунда, вторая, пятая… Закрывает вкладку. Уходит к конкуренту, где фильтрация работает мгновенно, без перезагрузки.
Знакомая ситуация? Именно поэтому AJAX-фильтр для каталога WooCommerce — это не роскошь, а необходимость. По данным Baymard Institute, 42% крупных интернет-магазинов реализуют фильтрацию товаров через AJAX. И знаете что — у них конверсия выше.
Сегодня разберём, как сделать такой фильтр с нуля. Без тяжёлых плагинов. Чистый PHP + JavaScript. Поехали.
Зачем делать AJAX-фильтр для WooCommerce вручную
«Стойте, — скажете вы, — есть же готовые плагины. Filter Everything, WOOF, JetSmartFilters…» Да, есть. И я сам их использовал на десятках проектов. Но у каждого свои нюансы:
- WOOF — мощный, но тяжёлый. На крупных каталогах (5000+ товаров) может серьёзно тормозить
- Filter Everything — неплохой, но бесплатная версия ограничена
- JetSmartFilters — работает только с Elementor и JetEngine
А иногда у клиента специфические требования: нестандартная логика фильтрации, кастомные поля, сложные зависимости между параметрами. И вот тут готовый плагин превращается из решения в проблему. Приходится городить хаки, переписывать шаблоны, страдать при обновлениях.
Свой AJAX-фильтр — это полный контроль. Никаких лишних запросов к базе, никакого мусорного кода. Только то, что нужно вашему проекту.
Архитектура: из чего состоит AJAX-фильтр
Прежде чем писать код, давайте поймём структуру. Любой AJAX-фильтр для каталога WooCommerce состоит из четырёх частей:
- Фронтенд (HTML) — форма фильтра с чекбоксами, радиокнопками, слайдерами
- JavaScript — перехватывает изменения в форме и отправляет запрос на сервер
- PHP-обработчик — принимает параметры, формирует WP_Query и возвращает результат
- Шаблон вывода — HTML-разметка карточек товаров для обновления на странице
Всё. Никакой магии. Давайте реализовывать каждый элемент.
Шаг 1: HTML-разметка формы фильтра
Создадим шорткод, чтобы вывести фильтр в любом месте сайта. Открываем functions.php (или лучше — файл в плагине-модуле, но об этом позже):
function wenesis_ajax_filter_shortcode() {
// Получаем все значения атрибута "Цвет"
$colors = wc_get_attribute_terms(1, 'pa_color'); // ID атрибута = 1
// Получаем категории товаров
$categories = get_terms(array(
'taxonomy' => 'product_cat',
'hide_empty' => true,
'parent' => 0 // только родительские
));
ob_start();
?>
<form id="ajax-product-filter" class="wc-filter-form">
<div class="filter-section">
<h4>Категория</h4>
<ul class="filter-list">
<?php foreach ($categories as $cat) : ?>
<li>
<label>
<input type="checkbox"
name="category[]"
value="<?php echo esc_attr($cat->slug); ?>">
<?php echo esc_html($cat->name); ?>
<span class="count">(<?php echo $cat->count; ?>)</span>
</label>
</li>
<?php endforeach; ?>
</ul>
</div>
<div class="filter-section">
<h4>Цвет</h4>
<ul class="filter-list">
<?php foreach ($colors as $color) : ?>
<li>
<label>
<input type="checkbox"
name="color[]"
value="<?php echo esc_attr($color->slug); ?>">
<?php echo esc_html($color->name); ?>
</label>
</li>
<?php endforeach; ?>
</ul>
</div>
<div class="filter-section">
<h4>Цена</h4>
<div class="price-range">
<input type="number" name="min_price" placeholder="От" min="0">
<span>—</span>
<input type="number" name="max_price" placeholder="До" min="0">
</div>
</div>
<div class="filter-section">
<h4>Сортировка</h4>
<select name="orderby">
<option value="date">Новинки</option>
<option value="price">Цена: по возрастанию</option>
<option value="price-desc">Цена: по убыванию</option>
<option value="popularity">Популярность</option>
<option value="rating">Рейтинг</option>
</select>
</div>
<button type="submit" class="filter-submit">Применить</button>
<button type="reset" class="filter-reset">Сбросить</button>
</form>
<?php
return ob_get_clean();
}
add_shortcode('ajax_product_filter', 'wenesis_ajax_filter_shortcode');
[/php]
<p>Обратите внимание: я использую <code>ob_start()</code> и <code>ob_get_clean()</code> — это стандартный паттерн для шорткодов в WordPress. Не <code>echo</code>, а именно <code>return</code>.</p>
<h2>Шаг 2: JavaScript — отправка AJAX-запроса</h2>
<p>Теперь создадим скрипт, который будет перехватывать отправку формы и обновлять товары без перезагрузки. Подключаем его правильно — через <code>wp_enqueue_script</code>:</p>
[php]
function wenesis_enqueue_filter_assets() {
if (!is_shop() && !is_product_category()) return;
wp_enqueue_script(
'wenesis-filter',
get_stylesheet_directory_uri() . '/js/ajax-filter.js',
array('jquery'),
'1.0',
true
);
wp_localize_script('wenesis-filter', 'wenesisFilter', array(
'ajaxurl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('wenesis_filter_nonce')
));
}
add_action('wp_enqueue_scripts', 'wenesis_enqueue_filter_assets');
А вот и сам JavaScript. Ключевой момент — использование делегирования событий через .on():
jQuery(document).ready(function($) {
let filterForm = $('#ajax-product-filter');
let productList = $('.products'); // контейнер товаров WooCommerce
let isLoading = false;
// Обработка отправки формы
filterForm.on('submit', function(e) {
e.preventDefault();
applyFilter();
});
// Сброс фильтра
filterForm.on('reset', function() {
setTimeout(function() {
applyFilter();
}, 10);
});
function applyFilter() {
if (isLoading) return;
isLoading = true;
// Собираем данные формы
let formData = filterForm.serialize();
formData += '&action=filter_products&nonce=' + wenesisFilter.nonce;
// Показываем индикатор загрузки
productList.addClass('loading');
$.ajax({
url: wenesisFilter.ajaxurl,
type: 'POST',
data: formData,
success: function(response) {
if (response.success) {
productList.html(response.data.html);
// Обновляем счётчик товаров
$('.woocommerce-result-count').html(response.data.count_text);
} else {
console.error('Ошибка фильтрации:', response.data);
}
},
error: function(xhr, status, error) {
console.error('AJAX error:', error);
},
complete: function() {
isLoading = false;
productList.removeClass('loading');
}
});
}
});
Обратите внимание на проверку isLoading — она предотвращает множественные запросы, если пользователь быстро тыкает в чекбоксы. Мелочь, а на больших каталогах спасает от утечек памяти.
Шаг 3: PHP-обработчик на стороне сервера
Сердце всего механизма. Здесь формируется запрос к базе данных:
function wenesis_filter_products() {
// Проверяем nonce
if (!wp_verify_nonce($_POST['nonce'] ?? '', 'wenesis_filter_nonce')) {
wp_send_json_error('Security check failed');
}
// Получаем параметры из запроса
$categories = isset($_POST['category']) ? array_map('sanitize_text_field', $_POST['category']) : array();
$colors = isset($_POST['color']) ? array_map('sanitize_text_field', $_POST['color']) : array();
$min_price = isset($_POST['min_price']) ? floatval($_POST['min_price']) : '';
$max_price = isset($_POST['max_price']) ? floatval($_POST['max_price']) : '';
$orderby = isset($_POST['orderby']) ? sanitize_text_field($_POST['orderby']) : 'date';
$paged = isset($_POST['page']) ? intval($_POST['page']) : 1;
// Параметры сортировки
$order_args = array();
switch ($orderby) {
case 'price':
$order_args = array('meta_key' => '_price', 'orderby' => 'meta_value_num', 'order' => 'ASC');
break;
case 'price-desc':
$order_args = array('meta_key' => '_price', 'orderby' => 'meta_value_num', 'order' => 'DESC');
break;
case 'popularity':
$order_args = array('meta_key' => 'total_sales', 'orderby' => 'meta_value_num', 'order' => 'DESC');
break;
case 'rating':
$order_args = array('meta_key' => '_wc_average_rating', 'orderby' => 'meta_value_num', 'order' => 'DESC');
break;
default:
$order_args = array('orderby' => 'date', 'order' => 'DESC');
}
// Формируем meta_query для фильтрации по цене
$meta_query = array();
if ($min_price !== '' || $max_price !== '') {
$price_query = array('key' => '_price', 'compare' => 'BETWEEN', 'type' => 'DECIMAL');
if ($min_price !== '') $price_query['value'][0] = $min_price;
if ($max_price !== '') $price_query['value'][1] = $max_price;
$meta_query[] = $price_query;
}
// Формируем tax_query для категорий и атрибутов
$tax_query = array('relation' => 'AND');
if (!empty($categories)) {
$tax_query[] = array(
'taxonomy' => 'product_cat',
'field' => 'slug',
'terms' => $categories,
'operator' => 'IN'
);
}
if (!empty($colors)) {
$tax_query[] = array(
'taxonomy' => 'pa_color',
'field' => 'slug',
'terms' => $colors,
'operator' => 'IN'
);
}
// Основной запрос
$args = array(
'post_type' => 'product',
'posts_per_page' => 12,
'paged' => $paged,
'post_status' => 'publish',
'meta_query' => $meta_query,
'tax_query' => $tax_query,
);
// Объединяем с аргументами сортировки
$args = wp_parse_args($order_args, $args);
$query = new WP_Query($args);
ob_start();
if ($query->have_posts()) {
woocommerce_product_loop_start();
while ($query->have_posts()) {
$query->the_post();
wc_get_template_part('content', 'product');
}
woocommerce_product_loop_end();
// Пагинация
woocommerce_pagination();
} else {
echo '<p class="no-products">Товаров по вашему запросу не найдено.</p>';
}
$html = ob_get_clean();
wp_reset_postdata();
// Формируем текст счётчика
$count_text = sprintf(
'Показано %d из %d товаров',
min($query->post_count, 12),
$query->found_posts
);
wp_send_json_success(array(
'html' => $html,
'count_text' => $count_text,
'total' => $query->found_posts,
'pages' => $query->max_num_pages
));
}
// Два хука — для авторизованных и неавторизованных
add_action('wp_ajax_filter_products', 'wenesis_filter_products');
add_action('wp_ajax_nopriv_filter_products', 'wenesis_filter_products');
Важные детали, которые новички часто упускают:
- nonce-проверка — без неё ваш фильтр = открытая дверь для ботов
- sanitize_text_field и floatval — очистка входящих данных. Всегда
- wp_reset_postdata() — без него глобальный
$post«сломается», и следующий контент на странице выведет данные неправильного товара - Два хука —
wp_ajax_для залогиненных,wp_ajax_nopriv_для гостей. Забыли nopriv — гости увидят 0
Шаг 4: Стили и анимация загрузки
Пока AJAX-фильтр для WooCommerce отправляет запрос, пользователь должен видеть, что «что-то происходит». Добавим простой лоадер:
/ Состояние загрузки /
.products.loading {
position: relative;
opacity: 0.5;
pointer-events: none;
transition: opacity 0.3s ease;
}
.products.loading::after {
content: »;
position: absolute;
top: 50%;
left: 50%;
width: 40px;
height: 40px;
margin: -20px 0 0 -20px;
border: 3px solid #f3f3f3;
border-top: 3px solid #333;
border-radius: 50%;
animation: spin 0.8s linear infinite;
z-index:
Добавить комментарий