Задача звучит безобидно: вывести хлебные крошки не в header.php, а внутри шаблона catalog.element — потому что по макету они лежат внутри карточки товара, а не над ней. Ставим вызов компонента в шаблон, обновляем страницу — всё работает. Открываем второй товар — крошки от первого.
Это лобовое столкновение с тем, как устроено кэширование компонентов в Битриксе, и «просто выключить кэш у карточки товара» — не решение: карточка товара как раз то место, где кэш нужен больше всего.
Проблема
При включённом кэшировании компонента (news.detail, catalog.element) вывод хлебных крошек через bitrix:breadcrumb внутри шаблона приводит к ошибкам:
- Крошки кэшируются для первой страницы
- На других страницах показывается неправильная цепочка
- «Разваливается» вёрстка
Почему так происходит
template.php выполняется только при промахе кэша. На попадании Битрикс не запускает шаблон вообще — он отдаёт сохранённый HTML целиком. Соответственно, вызов bitrix:breadcrumb внутри шаблона отработает ровно один раз, а его результат станет частью кэшированной страницы товара. Никакой ошибки при этом нет: компонент честно сохранил в кэш то, что ему сказали сохранить.
Значит, нужен способ вынуть динамическую часть за границу кэша.
Решение: отложенные вызовы
component_epilog.php — единственный файл шаблона, который выполняется на каждом хите, независимо от состояния кэша. Отсюда схема: в шаблоне на месте крошек оставляем текстовую метку, метку вместе с HTML кладём в кэш, а в эпилоге подменяем её на свежесгенерированную цепочку.
Тот же приём Битрикс использует внутри себя — например, для вывода SECTION_URL и заголовка страницы из кэшируемых компонентов.
Класс ComponentHelper
<?php
// /local/php_interface/classes/Component/DeferredOutput.php
namespace Local\Component;
class DeferredOutput
{
private \CBitrixComponent $component;
private int $placeholderIndex = 0;
private array $callbacks = [];
private bool $started = false;
/**
* Инициализация отложенного вывода
*/
public function __construct(\CBitrixComponent $component)
{
$this->component = $component;
$this->component->SetResultCacheKeys([
'DEFERRED_OUTPUT',
'DEFERRED_CALLBACKS',
]);
}
/**
* Начать буферизацию (вызывать в начале template.php)
*/
public function start(): self
{
if ($this->started) {
return $this;
}
$this->started = true;
ob_start();
return $this;
}
/**
* Добавить отложенный вызов функции
* Возвращает плейсхолдер, который будет заменён на результат функции
*/
public function defer(callable $callback, array $args = []): string
{
$placeholder = '##DEFERRED_' . (++$this->placeholderIndex) . '_' . uniqid() . '##';
$this->callbacks[$placeholder] = [
'callback' => $callback,
'args' => $args,
];
return $placeholder;
}
/**
* Сохранить буфер в кэш (вызывать в конце template.php)
*/
public function save(): void
{
if (!$this->started) {
return;
}
$this->component->arResult['DEFERRED_OUTPUT'] = ob_get_contents();
$this->component->arResult['DEFERRED_CALLBACKS'] = $this->callbacks;
ob_end_clean();
}
/**
* Обработка плейсхолдеров (вызывать в component_epilog.php)
*/
public static function process(\CBitrixComponent $component): void
{
$output = $component->arResult['DEFERRED_OUTPUT'] ?? '';
$callbacks = $component->arResult['DEFERRED_CALLBACKS'] ?? [];
if (empty($output)) {
return;
}
foreach ($callbacks as $placeholder => $data) {
// Выполняем callback и получаем результат
ob_start();
call_user_func_array($data['callback'], $data['args']);
$result = ob_get_clean();
// Заменяем плейсхолдер на результат
$output = str_replace($placeholder, $result, $output);
}
echo $output;
}
}Ограничение, о котором нужно знать заранее: в arResult нельзя класть замыкания. Массив $callbacks уезжает в кэш, а кэш — это сериализация. Closure не сериализуется, PHP выбросит фатальную ошибку прямо при сохранении кэша. Поэтому defer() здесь принимает только то, что переживает serialize(): имя функции строкой или пару ['ClassName', 'method']. Именно поэтому во вспомогательных функциях ниже логика вынесена в обычные функции, а не написана инлайном.
Вспомогательные функции
<?php
// /local/php_interface/init.php
use Bitrix\Main\Loader;
Loader::registerAutoLoadClasses(null, [
'Local\\Component\\DeferredOutput' => '/local/php_interface/classes/Component/DeferredOutput.php',
]);
/**
* Функция для отложенного вывода хлебных крошек
*/
function showBreadcrumb(string $template = '.default'): void
{
global $APPLICATION;
$APPLICATION->IncludeComponent(
'bitrix:breadcrumb',
$template,
[
'START_FROM' => '0',
'PATH' => '',
'SITE_ID' => SITE_ID,
]
);
}
/**
* Функция для отложенного вывода заголовка страницы
*/
function showPageTitle(): void
{
global $APPLICATION;
echo $APPLICATION->GetTitle();
}
/**
* Функция для отложенного вывода любого компонента
*/
function showComponent(string $name, string $template, array $params): void
{
global $APPLICATION;
$APPLICATION->IncludeComponent($name, $template, $params);
}Использование в шаблоне
template.php
<?php if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true) die();
use Local\Component\DeferredOutput;
// Создаём хелпер и начинаем буферизацию
$deferred = new DeferredOutput($component);
$deferred->start();
?>
<article class="article">
<header class="article__header">
<!-- Хлебные крошки — будут подставлены после кэша -->
<?= $deferred->defer('showBreadcrumb', ['.default']) ?>
<h1 class="article__title">
<?= htmlspecialchars($arResult['NAME']) ?>
</h1>
<div class="article__meta">
<time datetime="<?= $arResult['DATE_ACTIVE_FROM'] ?>">
<?= $arResult['DATE_ACTIVE_FROM'] ?>
</time>
</div>
</header>
<div class="article__content">
<?= $arResult['DETAIL_TEXT'] ?>
</div>
<footer class="article__footer">
<!-- Динамический заголовок страницы -->
<p>Текущая страница: <?= $deferred->defer('showPageTitle') ?></p>
</footer>
</article>
<?php
// Сохраняем буфер в кэш
$deferred->save();
?>component_epilog.php
<?php if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true) die();
use Local\Component\DeferredOutput;
// Обрабатываем плейсхолдеры и выводим результат
DeferredOutput::process($this);
?>Обратите внимание на SetResultCacheKeys() в конструкторе. Битрикс сохраняет в кэш не весь arResult, а только ключи, объявленные компонентом, — свои дополнительные ключи нужно регистрировать явно. Забытый вызов даёт самый неприятный вид ошибки: на первом хите (кэш пустой, шаблон отработал) всё правильно, на втором — пустая страница, потому что DEFERRED_OUTPUT из кэша не вернулся.
Универсальный хелпер для простых случаев
<?php
namespace Local\Component;
class SimpleDeferredBreadcrumb
{
/**
* Быстрое подключение — для тех, кто не хочет менять весь шаблон
*/
public static function include(\CBitrixComponent $component, string $template = '.default'): void
{
// Сохраняем плейсхолдер в кэш
$component->SetResultCacheKeys(['BREADCRUMB_PLACEHOLDER']);
$placeholder = '##BREADCRUMB_' . uniqid() . '##';
$component->arResult['BREADCRUMB_PLACEHOLDER'] = $placeholder;
$component->arResult['BREADCRUMB_TEMPLATE'] = $template;
echo $placeholder;
}
/**
* Обработка в epilog
*/
public static function process(\CBitrixComponent $component): void
{
global $APPLICATION;
$placeholder = $component->arResult['BREADCRUMB_PLACEHOLDER'] ?? '';
$template = $component->arResult['BREADCRUMB_TEMPLATE'] ?? '.default';
if (empty($placeholder)) {
return;
}
// Получаем HTML хлебных крошек
ob_start();
$APPLICATION->IncludeComponent('bitrix:breadcrumb', $template, [
'START_FROM' => '0',
'PATH' => '',
'SITE_ID' => SITE_ID,
]);
$breadcrumbHtml = ob_get_clean();
// Выводим буфер с заменой плейсхолдера
$output = $component->arResult['DEFERRED_OUTPUT'] ?? '';
if (!empty($output)) {
echo str_replace($placeholder, $breadcrumbHtml, $output);
}
}
}Упрощённое использование
<!-- template.php -->
<?php
use Local\Component\SimpleDeferredBreadcrumb;
$this->SetResultCacheKeys(['DEFERRED_OUTPUT']);
ob_start();
?>
<div class="article">
<nav class="breadcrumb">
<?php SimpleDeferredBreadcrumb::include($component, 'custom'); ?>
</nav>
<h1><?= $arResult['NAME'] ?></h1>
<!-- ... -->
</div>
<?php
$this->arResult['DEFERRED_OUTPUT'] = ob_get_clean();
?><!-- component_epilog.php -->
<?php
use Local\Component\SimpleDeferredBreadcrumb;
SimpleDeferredBreadcrumb::process($this);
?>Чем платим
Приём рабочий, но у него есть цена, и её стоит осознавать до внедрения.
Эпилог выполняется на каждом хите. Всё, что вы туда вынесли, больше не кэшируется — по определению. Вынесли крошки — платите одним вызовом компонента на запрос. Вынесли туда половину шаблона — кэш перестаёт иметь смысл.
Шаблон становится сложнее для того, кто придёт после вас. Вёрстка уезжает в буфер, вывод происходит в другом файле, а echo в шаблоне больше не означает «показать на экране». Комментарий в начале template.php с объяснением схемы здесь не роскошь.
Ошибки диагностируются тяжело. Пустая страница вместо карточки товара может означать и незарегистрированный ключ кэша, и не сработавший эпилог, и потерянный буфер. При отладке первым делом чистите кэш компонента — половина «необъяснимых» симптомов уходит вместе с ним.
Когда этого можно избежать
Прежде чем строить схему с плейсхолдерами, проверьте две более простые альтернативы:
- Вынести крошки из компонента в шаблон сайта. Часто выясняется, что «по макету внутри карточки» решается вёрсткой, а не расположением вызова в коде.
- Использовать
$APPLICATION->AddChainItem()вcomponent_epilog.php, а сам вывод крошек оставить вheader.php. Компонент при этом только дополняет цепочку названием товара, а рисуется она в некэшируемом месте. Для типовой задачи «в крошках должно быть название товара» этого достаточно, и никакой буферизации не требуется.
Схема с DeferredOutput оправдана, когда динамических вставок внутри кэшируемого шаблона действительно несколько и они разбросаны по вёрстке. Ради одних крошек её обычно не разворачивают.
Частые вопросы
Почему хлебные крошки внутри компонента показывают неправильную цепочку?
Потому что вызов bitrix:breadcrumb попал в кэш компонента. Файл template.php выполняется только при промахе кэша — при попадании Битрикс не запускает шаблон вообще, а отдаёт сохранённый HTML целиком. Крошки первого открытого товара становятся частью кэшированной страницы и показываются на всех остальных.
Можно ли просто отключить кэш у catalog.element?
Технически да, но карточка товара — как раз то место, где кэш нужен больше всего. Отключение кэша ради одной динамической вставки означает полную генерацию страницы на каждый запрос, поэтому правильнее вынести за границу кэша только саму вставку, а не весь компонент.
Какой файл шаблона компонента выполняется при попадании в кэш?
component_epilog.php — единственный файл шаблона, который выполняется на каждом хите независимо от состояния кэша. Именно поэтому в него выносят всё динамическое: вывод крошек, заголовок страницы, персональные блоки. Тот же приём Битрикс использует внутри себя для вывода SECTION_URL и заголовка страницы из кэшируемых компонентов.
Почему нельзя положить замыкание в arResult?
Потому что arResult уезжает в кэш, а кэш — это сериализация. Closure не сериализуется, и PHP выбросит фатальную ошибку прямо при сохранении кэша. В отложенные вызовы можно передавать только то, что переживает serialize: имя функции строкой или пару из имени класса и метода.
Почему после внедрения отложенного вывода страница стала пустой?
Скорее всего, не вызван SetResultCacheKeys для своих ключей. Битрикс сохраняет в кэш не весь arResult, а только объявленные компонентом ключи. Симптом характерный: на первом хите, когда кэш пуст и шаблон отработал, всё правильно, а на втором — пустая страница, потому что DEFERRED_OUTPUT из кэша не вернулся. При отладке первым делом чистите кэш компонента.
Чем платят за схему с отложенным выводом?
Эпилог выполняется на каждом хите, поэтому всё вынесенное в него больше не кэшируется по определению — за крошки платят одним вызовом компонента на запрос, а если вынести туда половину шаблона, кэш перестаёт иметь смысл. Плюс шаблон становится сложнее для того, кто придёт после вас: вёрстка уезжает в буфер, вывод происходит в другом файле, а echo в шаблоне больше не означает «показать на экране».