Задача выглядит на полчаса: вывести список рубрик, посчитать количество статей в каждой и переключать выборку по клику. Ровно так её обычно и делают — и получают страницу, которая на пятидесяти рубриках открывается три секунды.
Разберём рабочую реализацию и два места, где она незаметно превращается в проблему: подсчёт количества и кэширование фильтруемого списка.
Задача
Создать фильтр для списка статей (или товаров) по свойству «Рубрика», где рубрики хранятся в 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
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, и на списках он проявляется быстро.
Тот же результат берётся одним запросом с группировкой:
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 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
// Формируем глобальный фильтр
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)
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
// /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-стили
.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.