Главная/Статьи/Битрикс: хлебные крошки внутри кэшируемого компонента

Битрикс: хлебные крошки внутри кэшируемого компонента

Как вывести bitrix:breadcrumb внутри шаблона компонента с включённым кэшированием. Deferred-вызовы и плейсхолдеры.

ДМ
Дмитрий Мещеряков
📅 1 октября 2018 г.📖 6 мин чтения

Задача звучит безобидно: вывести хлебные крошки не в header.php, а внутри шаблона catalog.element — потому что по макету они лежат внутри карточки товара, а не над ней. Ставим вызов компонента в шаблон, обновляем страницу — всё работает. Открываем второй товар — крошки от первого.

Это лобовое столкновение с тем, как устроено кэширование компонентов в Битриксе, и «просто выключить кэш у карточки товара» — не решение: карточка товара как раз то место, где кэш нужен больше всего.

Проблема

При включённом кэшировании компонента (news.detail, catalog.element) вывод хлебных крошек через bitrix:breadcrumb внутри шаблона приводит к ошибкам:

  • Крошки кэшируются для первой страницы
  • На других страницах показывается неправильная цепочка
  • «Разваливается» вёрстка

Почему так происходит

template.php выполняется только при промахе кэша. На попадании Битрикс не запускает шаблон вообще — он отдаёт сохранённый HTML целиком. Соответственно, вызов bitrix:breadcrumb внутри шаблона отработает ровно один раз, а его результат станет частью кэшированной страницы товара. Никакой ошибки при этом нет: компонент честно сохранил в кэш то, что ему сказали сохранить.

Значит, нужен способ вынуть динамическую часть за границу кэша.

Решение: отложенные вызовы

component_epilog.php — единственный файл шаблона, который выполняется на каждом хите, независимо от состояния кэша. Отсюда схема: в шаблоне на месте крошек оставляем текстовую метку, метку вместе с HTML кладём в кэш, а в эпилоге подменяем её на свежесгенерированную цепочку.

Тот же приём Битрикс использует внутри себя — например, для вывода SECTION_URL и заголовка страницы из кэшируемых компонентов.

Класс ComponentHelper

php
<?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
<?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
<?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
<?php if (!defined('B_PROLOG_INCLUDED') || B_PROLOG_INCLUDED !== true) die();

use Local\Component\DeferredOutput;

// Обрабатываем плейсхолдеры и выводим результат
DeferredOutput::process($this);
?>

Обратите внимание на SetResultCacheKeys() в конструкторе. Битрикс сохраняет в кэш не весь arResult, а только ключи, объявленные компонентом, — свои дополнительные ключи нужно регистрировать явно. Забытый вызов даёт самый неприятный вид ошибки: на первом хите (кэш пустой, шаблон отработал) всё правильно, на втором — пустая страница, потому что DEFERRED_OUTPUT из кэша не вернулся.

Универсальный хелпер для простых случаев

php
<?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);
        }
    }
}

Упрощённое использование

php
<!-- 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();
?>
php
<!-- component_epilog.php -->
<?php
use Local\Component\SimpleDeferredBreadcrumb;
SimpleDeferredBreadcrumb::process($this);
?>

Чем платим

Приём рабочий, но у него есть цена, и её стоит осознавать до внедрения.

Эпилог выполняется на каждом хите. Всё, что вы туда вынесли, больше не кэшируется — по определению. Вынесли крошки — платите одним вызовом компонента на запрос. Вынесли туда половину шаблона — кэш перестаёт иметь смысл.

Шаблон становится сложнее для того, кто придёт после вас. Вёрстка уезжает в буфер, вывод происходит в другом файле, а echo в шаблоне больше не означает «показать на экране». Комментарий в начале template.php с объяснением схемы здесь не роскошь.

Ошибки диагностируются тяжело. Пустая страница вместо карточки товара может означать и незарегистрированный ключ кэша, и не сработавший эпилог, и потерянный буфер. При отладке первым делом чистите кэш компонента — половина «необъяснимых» симптомов уходит вместе с ним.

Когда этого можно избежать

Прежде чем строить схему с плейсхолдерами, проверьте две более простые альтернативы:

  • Вынести крошки из компонента в шаблон сайта. Часто выясняется, что «по макету внутри карточки» решается вёрсткой, а не расположением вызова в коде.
  • Использовать $APPLICATION->AddChainItem() в component_epilog.php, а сам вывод крошек оставить в header.php. Компонент при этом только дополняет цепочку названием товара, а рисуется она в некэшируемом месте. Для типовой задачи «в крошках должно быть название товара» этого достаточно, и никакой буферизации не требуется.

Схема с DeferredOutput оправдана, когда динамических вставок внутри кэшируемого шаблона действительно несколько и они разбросаны по вёрстке. Ради одних крошек её обычно не разворачивают.

🚀

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

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

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

Комментарии

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