Главная/Статьи/Битрикс: фильтрация и сортировка элементов по свойству

Битрикс: фильтрация и сортировка элементов по свойству

Реализация AJAX-фильтра по свойству инфоблока. Highload-блоки справочников, передача фильтра в компонент, счётчики элементов.

ДМ
Дмитрий Мещеряков
📅 16 декабря 2020 г.📖 8 мин чтения

Задача выглядит на полчаса: вывести список рубрик, посчитать количество статей в каждой и переключать выборку по клику. Ровно так её обычно и делают — и получают страницу, которая на пятидесяти рубриках открывается три секунды.

Разберём рабочую реализацию и два места, где она незаметно превращается в проблему: подсчёт количества и кэширование фильтруемого списка.

Задача

Создать фильтр для списка статей (или товаров) по свойству «Рубрика», где рубрики хранятся в Highload-блоке. Показывать количество элементов в каждой рубрике.

Структура данных

КомпонентОписание
Инфоблок «Статьи»ID: 5, свойство SECTION (привязка к HL-блоку)
Highload-блок «Рубрики»ID: 1, поля: UF_NAME, UF_XML_ID, UF_SORT

Шаг 1: Подготовка данных (result_modifier.php)

В шаблоне компонента news.list создаём файл result_modifier.php:

php
<?php
if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true) die();

use Bitrix\Main\Loader;
use Bitrix\Highloadblock\HighloadBlockTable;

if (!Loader::includeModule('highloadblock')) {
    return;
}

// Загружаем рубрики из HL-блока
$hlblockId = 1;
$hlblock = HighloadBlockTable::getById($hlblockId)->fetch();

if (!$hlblock) {
    return;
}

$entity = HighloadBlockTable::compileEntity($hlblock);
$entityClass = $entity->getDataClass();

$sections = $entityClass::getList([
    'select' => ['ID', 'UF_NAME', 'UF_XML_ID', 'UF_SORT'],
    'order' => ['UF_SORT' => 'ASC', 'UF_NAME' => 'ASC'],
    'filter' => ['UF_ACTIVE' => 1], // Если есть поле активности
])->fetchAll();

// Подсчитываем количество элементов в каждой рубрике
foreach ($sections as &$section) {
    $section['COUNT'] = getElementsCountBySection(
        $arParams['IBLOCK_ID'],
        'SECTION',
        $section['UF_XML_ID']
    );
}
unset($section);

$arResult['SECTIONS'] = $sections;
// Валидируем параметр по списку известных рубрик: в фильтр должно
// попадать только то, что мы сами вывели на страницу
$requestedSection = (string) ($_GET['section'] ?? '');
$knownSections = array_column($sections, 'UF_XML_ID');

$arResult['ACTIVE_SECTION'] = in_array($requestedSection, $knownSections, true)
    ? $requestedSection
    : null;

/**
 * Подсчёт элементов по значению свойства
 *
 * ВНИМАНИЕ: один запрос на рубрику. Ниже показан вариант с одним
 * запросом на все рубрики сразу — используйте его, если рубрик больше
 * десятка
 */
function getElementsCountBySection(int $iblockId, string $propertyCode, string $value): int
{
    $result = \CIBlockElement::GetList(
        [],
        [
            'IBLOCK_ID' => $iblockId,
            'ACTIVE' => 'Y',
            '=PROPERTY_' . $propertyCode => $value,
        ],
        [], // Только подсчёт
        false,
        []
    );
    
    return (int) $result;
}
⚠️ Важно

Объявление функции внутри result_modifier.php — ошибка, ждущая своего часа. Файл выполняется при каждом промахе кэша компонента. Если тот же шаблон подключён на странице дважды (например, список статей в основной колонке и в сайдбаре), PHP упадёт с фатальной ошибкой «Cannot redeclare function». Выносите такие функции в класс с автозагрузкой — или, если очень нужно оставить здесь, оборачивайте в if (!function_exists(...)).

Считаем одним запросом, а не пятьюдесятью

Код выше делает по отдельному COUNT на каждую рубрику: двадцать рубрик — двадцать запросов, и все на каждом открытии страницы. Это классический N+1, и на списках он проявляется быстро.

Тот же результат берётся одним запросом с группировкой:

php
use Bitrix\Main\Application;

$connection = Application::getConnection();
$helper = $connection->getSqlHelper();

$sql = "
    SELECT ep.VALUE AS SECTION_XML_ID, COUNT(DISTINCT e.ID) AS CNT
    FROM b_iblock_element e
    INNER JOIN b_iblock_element_property ep ON ep.IBLOCK_ELEMENT_ID = e.ID
    INNER JOIN b_iblock_property p ON p.ID = ep.IBLOCK_PROPERTY_ID
    WHERE e.IBLOCK_ID = " . (int) $iblockId . "
      AND e.ACTIVE = 'Y'
      AND p.CODE = '" . $helper->forSql($propertyCode) . "'
    GROUP BY ep.VALUE
";

$counts = [];
$rows = $connection->query($sql);

while ($row = $rows->fetch()) {
    $counts[$row['SECTION_XML_ID']] = (int) $row['CNT'];
}

foreach ($sections as &$section) {
    $section['COUNT'] = $counts[$section['UF_XML_ID']] ?? 0;
}
unset($section);
💡 Совет

Запрос выше рассчитан на первый вариант хранения свойств. Если у инфоблока VERSION = 2, значения лежат в b_iblock_element_prop_s{IBLOCK_ID} — колонка PROPERTY_{ID} вместо JOIN. Проверьте SELECT VERSION FROM b_iblock WHERE ID = ... перед тем, как копировать. И обратите внимание на forSql() для строкового параметра: код свойства приходит из настроек, но привычка экранировать всё, что попадает в SQL, дешевле разбора последствий.

Шаг 2: HTML-разметка фильтра (template.php)

php
<?php if (!empty($arResult['SECTIONS'])): ?>
<div class="articles-filter">
    <div class="articles-filter__title">Рубрики</div>
    <div class="articles-filter__list">
        
        <!-- Кнопка "Все" -->
        <a href="<?= $APPLICATION->GetCurPageParam('', ['section']) ?>" 
           class="articles-filter__item <?= empty($arResult['ACTIVE_SECTION']) ? 'active' : '' ?>">
            <span class="articles-filter__name">Все статьи</span>
            <span class="articles-filter__count"><?= count($arResult['ITEMS']) ?></span>
        </a>
        
        <!-- Рубрики -->
        <?php foreach ($arResult['SECTIONS'] as $section): ?>
            <?php 
            $isActive = $arResult['ACTIVE_SECTION'] === $section['UF_XML_ID'];
            $url = $APPLICATION->GetCurPageParam(
                'section=' . urlencode($section['UF_XML_ID']), 
                ['section']
            );
            ?>
            <a href="<?= $url ?>" 
               class="articles-filter__item <?= $isActive ? 'active' : '' ?>"
               data-section="<?= htmlspecialchars($section['UF_XML_ID']) ?>">
                <span class="articles-filter__name"><?= htmlspecialchars($section['UF_NAME']) ?></span>
                <span class="articles-filter__count"><?= $section['COUNT'] ?></span>
            </a>
        <?php endforeach; ?>
        
    </div>
</div>
<?php endif; ?>

<!-- Список статей -->
<div class="articles-list">
    <?php foreach ($arResult['ITEMS'] as $item): ?>
        <!-- Вывод статьи -->
    <?php endforeach; ?>
</div>

Шаг 3: Передача фильтра в компонент

В файле вызова компонента (например, index.php раздела):

php
<?php
// Формируем глобальный фильтр
if (!empty($_GET['section'])) {
    $GLOBALS['articlesFilter'] = [
        '=PROPERTY_SECTION' => htmlspecialchars($_GET['section']),
    ];
}
?>

<?php $APPLICATION->IncludeComponent(
    'bitrix:news.list',
    'articles',
    [
        'IBLOCK_TYPE' => 'content',
        'IBLOCK_ID' => 5,
        'NEWS_COUNT' => 12,
        'SORT_BY1' => 'ACTIVE_FROM',
        'SORT_ORDER1' => 'DESC',
        'FILTER_NAME' => 'articlesFilter', // Имя глобальной переменной
        'CACHE_TYPE' => 'A',
        'CACHE_TIME' => 3600,
        // ... остальные параметры
    ]
); ?>

Расширенная версия с AJAX

JavaScript (filter.js)

javascript
class ArticlesFilter {
    constructor(options) {
        this.filterSelector = options.filterSelector || '.articles-filter';
        this.listSelector = options.listSelector || '.articles-list';
        this.itemSelector = options.itemSelector || '.articles-filter__item';
        this.ajaxUrl = options.ajaxUrl || '/local/ajax/articles.php';
        
        this.init();
    }

    init() {
        const filter = document.querySelector(this.filterSelector);
        if (!filter) return;

        filter.addEventListener('click', (e) => {
            const item = e.target.closest(this.itemSelector);
            if (!item) return;

            e.preventDefault();
            this.loadArticles(item.dataset.section || '');
            this.setActive(item);
            this.updateUrl(item.dataset.section);
        });
    }

    async loadArticles(section) {
        const list = document.querySelector(this.listSelector);
        list.classList.add('loading');

        try {
            const params = new URLSearchParams({ section });
            const response = await fetch(`${this.ajaxUrl}?${params}`);
            const html = await response.text();
            
            list.innerHTML = html;
        } catch (error) {
            console.error('Filter error:', error);
        } finally {
            list.classList.remove('loading');
        }
    }

    setActive(item) {
        document.querySelectorAll(this.itemSelector).forEach(el => {
            el.classList.remove('active');
        });
        item.classList.add('active');
    }

    updateUrl(section) {
        const url = new URL(window.location);
        if (section) {
            url.searchParams.set('section', section);
        } else {
            url.searchParams.delete('section');
        }
        history.pushState({}, '', url);
    }
}

// Инициализация
document.addEventListener('DOMContentLoaded', () => {
    new ArticlesFilter({
        filterSelector: '.articles-filter',
        listSelector: '.articles-list',
        ajaxUrl: '/local/ajax/articles.php',
    });
});

PHP-обработчик AJAX

php
<?php
// /local/ajax/articles.php

define('NO_KEEP_STATISTIC', true);
define('NO_AGENT_CHECK', true);

require $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_before.php';

use Bitrix\Main\Loader;

Loader::includeModule('iblock');

$section = htmlspecialchars($_GET['section'] ?? '');
$iblockId = 5;

$filter = [
    'IBLOCK_ID' => $iblockId,
    'ACTIVE' => 'Y',
];

if ($section) {
    $filter['=PROPERTY_SECTION'] = $section;
}

$elements = \CIBlockElement::GetList(
    ['ACTIVE_FROM' => 'DESC'],
    $filter,
    false,
    ['nTopCount' => 12],
    ['ID', 'NAME', 'CODE', 'PREVIEW_TEXT', 'PREVIEW_PICTURE', 'ACTIVE_FROM']
);

$items = [];
while ($element = $elements->GetNext()) {
    if ($element['PREVIEW_PICTURE']) {
        $element['PREVIEW_PICTURE'] = \CFile::GetPath($element['PREVIEW_PICTURE']);
    }
    $items[] = $element;
}

// Рендерим HTML
?>
<?php if (empty($items)): ?>
    <div class="articles-empty">Статьи не найдены</div>
<?php else: ?>
    <?php foreach ($items as $item): ?>
        <article class="article-card">
            <?php if ($item['PREVIEW_PICTURE']): ?>
                <img src="<?= $item['PREVIEW_PICTURE'] ?>" alt="<?= $item['NAME'] ?>">
            <?php endif; ?>
            <h3><a href="/articles/<?= $item['CODE'] ?>/"><?= $item['NAME'] ?></a></h3>
            <p><?= $item['PREVIEW_TEXT'] ?></p>
            <time><?= FormatDate('d F Y', MakeTimeStamp($item['ACTIVE_FROM'])) ?></time>
        </article>
    <?php endforeach; ?>
<?php endif; ?>

CSS-стили

css
.articles-filter {
    margin-bottom: 2rem;
}

.articles-filter__title {
    font-weight: 600;
    margin-bottom: 1rem;
    color: var(--fg);
}

.articles-filter__list {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
}

.articles-filter__item {
    display: inline-flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.5rem 1rem;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 2rem;
    font-size: 0.875rem;
    color: var(--fg);
    text-decoration: none;
    transition: all 0.2s ease;
}

.articles-filter__item:hover {
    border-color: var(--accent);
    color: var(--accent);
}

.articles-filter__item.active {
    background: var(--accent);
    border-color: var(--accent);
    color: white;
}

.articles-filter__count {
    padding: 0.125rem 0.5rem;
    background: rgba(0, 0, 0, 0.1);
    border-radius: 1rem;
    font-size: 0.75rem;
}

.articles-filter__item.active .articles-filter__count {
    background: rgba(255, 255, 255, 0.2);
}

.articles-list {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
    gap: 1.5rem;
}

.articles-list.loading {
    opacity: 0.5;
    pointer-events: none;
}
💡 Совет

Совет: Для сложных фильтров с несколькими параметрами используйте компонент bitrix:catalog.smart.filter — он уже реализует всю логику фильтрации, включая SEO-friendly URL.

⚠️ Важно

Про кэш и фильтр — важное уточнение. Само по себе присвоение массива в $GLOBALS['arrFilter'] в ключ кэша компонента не попадает: компонент сохранит результат первой отфильтрованной выборки и начнёт отдавать его для всех остальных значений фильтра. Симптом — «выбираю рубрику, а список не меняется».

Отвечает за это параметр CACHE_FILTER: со значением "Y" компонент включает содержимое массива фильтра в идентификатор кэша, и каждая комбинация кэшируется отдельно.

CACHE_GROUPS тут ни при чём — он разделяет кэш по группам пользователя, а не по фильтру, и включать его «на всякий случай» только размножает кэш.

И помните обратную сторону: CACHE_FILTER = "Y" означает отдельный файл кэша на каждое значение фильтра. Для десятка рубрик это ровно то, что нужно; для фильтра с произвольными значениями из адресной строки — прямой путь к неограниченному росту кэша.

Итоги

Реализация фильтра по свойству упирается не в саму фильтрацию — она делается одним параметром компонента, — а в три соседних вопроса.

Подсчёт количества. Наивный вариант даёт N+1 запрос. Один GROUP BY вместо цикла — и страница перестаёт зависеть от числа рубрик.

Кэширование. Без CACHE_FILTER фильтр молча не работает при включённом кэше; с ним нужно следить, чтобы множество значений фильтра оставалось конечным.

Валидация входа. В фильтр должно попадать только то, что вы сами предложили пользователю. Не столько ради безопасности (Битрикс параметры экранирует), сколько чтобы произвольные значения из адресной строки не плодили кэш и не отдавали 200 на несуществующих рубриках.

💡 Совет

И общий совет: для фильтров с несколькими параметрами не пишите своё — берите bitrix:catalog.smart.filter. Он закрывает SEO-friendly URL, счётчики, диапазоны и связанность фильтров, а всё перечисленное написать самому — это отдельный проект, а не вечер работы. Своя реализация оправдана ровно в случае из этой статьи: один простой фильтр по одному свойству.

🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.