Задача звучит безобидно: вывести хлебные крошки не в 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 оправдана, когда динамических вставок внутри кэшируемого шаблона действительно несколько и они разбросаны по вёрстке. Ради одних крошек её обычно не разворачивают.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.