Стандартизация Swiper и макетов в Битриксе: уходим от хаоса в inline-скриптах
Последние записи
Хостинг для сайта
Если выбираете хостинг под проект — советую Timeweb. Современная инфраструктура, широкий выбор тарифов от стартовых до VPS под нагрузку, отзывчивая техподдержка, которая отвечает по существу, а не отписками. Я сам держу на нём часть своих проектов и рекомендую клиентам.
Перейти Партнёрская ссылка. Для вас цена не меняется.Проблема: inline-скрипты и иллюзия контроля
В большинстве проектов на 1С-Битрикс инициализация Swiper выглядит одинаково. В каждом template.php компонента — свой inline-скрипт:
new Swiper('.card', {
slidesPerView: 3,
spaceBetween: 20,
pagination: { el: '.swiper-pagination' },
});
Это работает ровно до первого AJAX-запроса. Пагинация, фильтр, обновление корзины — и новые слайды появляются в DOM, но Swiper о них не знает. Типичные «лечения»:
- обернуть инициализацию в
setTimeout(500)— угадывать момент появления DOM; - вызвать
swiper.update()— не помогает, если слайдер вообще не создан; - продублировать inline-скрипт в каждый компонент, где есть AJAX — копипаст растёт вместе с проектом.
Корневая причина — не в Swiper, а в архитектуре. Инициализация привязана к моменту вывода HTML, а не к моменту появления элемента в DOM. Пока эти два момента совпадают — всё работает. Как только Битрикс начинает подгружать блоки асинхронно, совпадение ломается.
Второй источник проблем — конфигурация внутри скрипта. Настройки слайдера размазаны по десяткам файлов: где-то loop: true, где-то speed: 0, где-то передан неверный селектор пагинации. Понять, какой слайдер на странице как настроен, можно только поиском по коду.
Архитектура: три уровня конфигурации
Вместо разрозненных inline-скриптов предлагается единая схема из трёх уровней. Каждый следующий переопределяет предыдущий:
| Уровень | Где задаётся | Что определяет | Пример |
|---|---|---|---|
| Глобальный | Объект swiperConfig в основном скрипте | Базовые настройки для всех слайдеров | speed: 300, slidesPerView: 1 |
| Компонентный | Атрибут data-options в HTML | Переопределения для конкретного слайдера | loop: false, effect: 'slide' |
| Императивный | JS-код в component_epilog.php | Настройки для сложных случаев | Синхронизация thumbs, кастомные события |
Такая схема — стандарт для production-решений. Разметка описывает структуру, атрибуты — конфигурацию, JS вмешивается только там, где атрибутов не хватает.
Почему data-options, а не набор data-*
Два подхода к атрибутной конфигурации:
- Плоские атрибуты:
data-slides-per-view="3",data-space-between="20",data-loop="true". - Один JSON:
<div data-options='{"slidesPerView": 3}'>.
Для Битрикса выигрывает JSON. Причина — в природе template.php: настройки рождаются как PHP-массив и передаются в json_encode() одной строкой. Плоские атрибуты потребовали бы ручного маппинга каждого ключа. Плюс вложенные структуры — breakpoints, pagination.el — через data-* выразить невозможно.
Breakpoints в data-options
Адаптивность слайдера — как раз тот случай, где плоские атрибуты не справляются. В Swiper за это отвечает объект breakpoints, где ключ — минимальная ширина вьюпорта, значение — набор переопределений. Через data-* это выражается десятком избыточных атрибутов, через data-options — одним блоком.
<div class="swiper card" data-options='<?=swiperDataOptions([
'slidesPerView' => 1,
'spaceBetween' => 10,
'breakpoints' => [
768 => [
'slidesPerView' => 2,
'spaceBetween' => 15,
],
1024 => [
'slidesPerView' => 3,
'spaceBetween' => 20,
],
],
])?>'>
<div class="swiper-wrapper">
<div class="swiper-slide">…</div>
</div>
</div>
Менеджер просто передаёт этот объект в Swiper, никакой дополнительной обработки не требуется.
Класс SwiperManager
Инициализацию, переинициализацию и парсинг опций логично собрать в один класс. Это даёт пространство имён, единую точку входа и удобство для внешних вызовов из component_epilog.php.
class SwiperManager {
constructor(config = {}) {
this.config = Object.assign({
slidesPerView: 1,
spaceBetween: 0,
speed: 300,
watchOverflow: true,
observer: true,
observeParents: true,
}, config);
this.instances = new WeakMap();
this.observer = null;
this.destroyed = false;
this._onReady = this._onReady.bind(this);
this._onAjaxSuccess = this._onAjaxSuccess.bind(this);
this._onMutation = this._onMutation.bind(this);
this._bindEvents();
}
/**
* Навешивает обработчики: DOMContentLoaded, Bitrix AJAX, MutationObserver.
* Наблюдение идёт за documentElement, а не за document.body — если скрипт
* подключён в head, body на этот момент ещё не существует.
*
* @returns {void}
*/
_bindEvents() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', this._onReady, { once: true });
} else {
this._onReady();
}
if (typeof BX !== 'undefined') {
BX.ready(() => {
BX.addCustomEvent('onAjaxSuccess', this._onAjaxSuccess);
});
}
this.observer = new MutationObserver(this._onMutation);
this.observer.observe(document.documentElement, { childList: true, subtree: true });
}
/**
* Инициализирует все ещё не инициализированные слайдеры в контейнере.
* Селектор :not(.swiper-initialized) опирается на класс, который Swiper
* добавляет корневому элементу после успешного создания. Именно это
* делает повторные вызовы init() безопасными.
*
* @param {Element|Document} container — область поиска.
* @param {Object} [override={}] — параметры поверх data-options.
* @returns {void}
*/
init(container = document, override = {}) {
if (this.destroyed) return;
const nodes = container.querySelectorAll('.swiper:not(.swiper-initialized):not(.single)');
if (!nodes.length) return;
nodes.forEach((el) => {
const params = this._buildParams(el, override);
try {
const instance = new Swiper(el, params);
this.instances.set(el, instance);
} catch (e) {
console.error('[SwiperManager] Не удалось создать Swiper:', el, e);
}
});
}
/**
* Уничтожает существующий инстанс на элементе и создаёт новый.
*
* @param {Element} el — корневой элемент слайдера.
* @param {Object} [override={}] — параметры поверх data-options.
* @returns {Swiper|null}
*/
reinit(el, override = {}) {
if (!el || this.destroyed) return null;
const instance = this.instances.get(el);
if (instance) {
try {
instance.destroy(true, true);
} catch (e) {
console.warn('[SwiperManager] Ошибка destroy:', e);
}
this.instances.delete(el);
}
el.classList.remove('swiper-initialized');
const params = this._buildParams(el, override);
try {
const fresh = new Swiper(el, params);
this.instances.set(el, fresh);
return fresh;
} catch (e) {
console.error('[SwiperManager] Не удалось пересоздать Swiper:', el, e);
return null;
}
}
/**
* Возвращает инстанс Swiper по элементу.
*
* @param {Element} el
* @returns {Swiper|undefined}
*/
get(el) {
return this.instances.get(el);
}
/**
* Отключает наблюдатель и помечает менеджер уничтоженным.
* Нужен, если менеджер создаётся и уничтожается динамически.
*
* @returns {void}
*/
destroy() {
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
this.destroyed = true;
}
/**
* Мержит глобальный конфиг, data-options и override.
*
* @private
*/
_buildParams(el, override) {
return Object.assign({}, this.config, this._parseOptions(el), override);
}
/**
* Безопасно парсит data-options.
*
* @private
*/
_parseOptions(el) {
const raw = el.dataset.options;
if (!raw) return {};
try {
return JSON.parse(raw);
} catch (e) {
console.warn('[SwiperManager] Невалидный data-options:', el, e);
return {};
}
}
_onReady() {
this.init();
}
_onAjaxSuccess() {
setTimeout(() => this.init(), 100);
}
/**
* Реагирует на добавление узлов в DOM. Скан ограничен mutation.target,
* а не всем документом — на активном DOM это критично.
*
* @private
*/
_onMutation(mutations) {
if (this.destroyed) return;
const targets = new Set();
for (const mutation of mutations) {
if (!mutation.addedNodes.length) continue;
const node = mutation.target instanceof Element
? mutation.target
: mutation.target.parentElement;
if (node) {
targets.add(node);
}
}
targets.forEach((target) => {
this.init(target);
});
}
}
Ключевые моменты:
WeakMapхранит соответствие «элемент → инстанс». Это позволяет вызыватьdestroy()на конкретном слайдере, не задевая соседей._buildParamsгарантирует приоритет: глобальный конфиг →data-options→ override. Предсказуемая цепочка вместо разрозненныхObject.assignпо компонентам.watchOverflow: trueв базовом конфиге скрывает слайдер, если слайдов меньше, чемslidesPerView. Без этой опции пустой слайдер выглядит сломанным.MutationObserverловит случаи, которые пропускаетonAjaxSuccess: вставки черезinnerHTML, компоненты сторонних библиотек, динамические модалки.- Наблюдение идёт за
document.documentElement, а не заdocument.body. Если скрипт подключён в<head>,document.bodyв этот момент ещёnull, иobserver.observe()выбросит исключение. - Класс
swiper-initialized— служебный, его добавляет самSwiperпосле успешного создания инстанса. Селектор:not(.swiper-initialized)опирается именно на него, и это делает повторные вызовыinit()идемпотентными. - Никаких принудительных
el.style.display = 'block'. Слайдер может быть скрыт по бизнес-логике — например, до раскрытия аккордеона. Менеджер не имеет права менять это состояние. - Вокруг
new Swiper()стоитtry/catch: невалидный селектор, отсутствующий модуль, конфликт с другим плагином — всё это логируется, но не валит остальные слайдеры на странице. - Метод
destroy()отключает наблюдателя. Если менеджер создаётся динамически (например, в SPA-переходе), без него останется висетьMutationObserver.
Запуск
window.swiperManager = new SwiperManager({
speed: 400,
slidesPerView: 1,
});
Дальше — единая точка доступа ко всем слайдерам страницы. Никаких window.reloadCatalog, никаких «не забудь вызвать в новом компоненте».
Обработка AJAX и динамического контента
Классическая схема «onAjaxSuccess + setTimeout» покрывает только запросы, инициированные Битриксом. Её хватает, пока проект не выйдет за пределы штатного bitrix:news.list. Дальше начинаются случаи, которые она не видит:
- модальное окно, содержимое которого вставлено через
innerHTML; - компонент сторонней библиотеки (
Vue,React,Alpine), отрендерившийся после AJAX; - слайдер внутри блока, добавленного пользовательским скриптом.
MutationObserver в классе закрывает эти случаи. Повторные вызовы init() безопасны благодаря селектору :not(.swiper-initialized): уже созданные слайдеры пропускаются. Это устраняет главный риск наблюдателя — многократную инициализацию одного элемента.
Событие onAjaxSuccess при этом остаётся как fallback. Задержка setTimeout(100) нужна, потому что Битрикс генерирует событие до того, как вставит HTML в DOM. Убрать её полностью нельзя — придётся ждать MutationObserver, что добавит лишний цикл отрисовки.
Производительность
MutationObserver на documentElement — это глобальный наблюдатель, и наивная реализация превращает его в узкое место: каждое изменение DOM вызывает полный скан документа. Правил три.
Скан ограничен mutation.target, а не document. Когда Битрикс подгружает блок через AJAX, mutation.target — это контейнер, в который вставили HTML. Искать слайдеры нужно только там, а не по всей странице. На каталоге в 500 карточек это разница между querySelectorAll по 500 элементам и по одному блоку.
Один наблюдатель на странице, не на компонент. SwiperManager создаётся один раз, в основном скрипте шаблона сайта. Если создавать его в каждом component_epilog.php, на странице будет столько же наблюдателей, сколько компонентов со слайдерами, и каждый будет получать свою копию всех мутаций DOM.
Batch-обработка через Set. Если за один тик пришло десять мутаций в разных узлах, _onMutation собирает их в Set уникальных целей и вызывает init() один раз на каждую уникальную цель, а не десять раз подряд по одному и тому же контейнеру.
Отдельно стоит помнить про сам Swiper: каждый инстанс создаёт свои слушатели событий и держит ссылки на DOM. Если у вас на странице пятьдесят слайдеров — это пятьдесят объектов с requestAnimationFrame внутри. watchOverflow: true в базовом конфиге скрывает пустые слайдеры, но не мешает им создаваться. Если в списке заведомо много элементов без слайдов, лучше не навешивать класс .swiper в шаблоне вовсе.
Работа со «сложными» слайдерами
Не все слайдеры укладываются в единый конфиг. Синхронизация thumbs, кастомные события, привязка к другим компонентам — такие случаи исключаются из автоматической инициализации классом .single. Ответственность за их создание переносится в код компонента.
Важно: если у элемента есть класс .swiper и нет .single, менеджер инициализирует его сам на DOMContentLoaded. Если разработчик потом вызовет reinit(), инстанс будет уничтожен и создан заново — это лишняя работа. Поэтому в примерах ниже у слайдеров, которыми управляет компонент, обязательно стоит .single.
Для подключения скрипта из component_epilog.php используется Asset::addJs():
use Bitrix\Main\Page\Asset;
Asset::addJs('/local/templates/.default/js/product-gallery.js');
Содержимое product-gallery.js:
document.addEventListener('DOMContentLoaded', () => {
const gallery = document.querySelector('#productGallery');
const thumbs = document.querySelector('#productThumbs');
if (!gallery || !thumbs) return;
const thumbsInstance = new Swiper(thumbs, {
slidesPerView: 4,
spaceBetween: 10,
watchSlidesProgress: true,
});
window.swiperManager.reinit(gallery, {
thumbs: { swiper: thumbsInstance },
});
});
В разметке:
<div id="productGallery" class="swiper single">…</div>
<div id="productThumbs" class="swiper single">…</div>
Контракт простой: если разработчик ставит класс .single, он обязан вызвать reinit() сам. Взамен — полная свобода в конфигурации без риска, что менеджер вмешается и перезапишет настройки.
Вложенные слайдеры и конфликты селекторов
Отдельный случай — слайдер внутри слайдера. Например, галерея товара открывается в модальном окне, а внутри галереи есть свой мини-слайдер с логотипами брендов. Если оба элемента помечены классом .swiper без .single, менеджер инициализирует оба, но при пересоздании внешнего он уничтожит свой инстанс и создаст заново, не тронув внутренний. Это нормально, пока их настройки не конфликтуют.
Проблема начинается, когда селекторы в опциях написаны без контекста. Пример плохого data-options:
{
"pagination": { "el": ".swiper-pagination" },
"navigation": { "nextEl": ".swiper-button-next" }
}
Внутри внешнего слайдера есть и свой .swiper-pagination, и у вложенного тоже. Swiper берёт первый найденный в документе элемент и подключает пагинацию внешнего слайдера к узлу внутреннего. Визуально это выглядит как «пагинация не работает» или «слайды дублируются».
Правильно — привязывать элементы к конкретному слайдеру через data-*-атрибуты внутри контейнера:
<div class="swiper card" data-options='<?=swiperDataOptions([
"pagination" => ["el" => "[data-swiper-pagination]"],
"navigation" => [
"nextEl" => "[data-swiper-next]",
"prevEl" => "[data-swiper-prev]",
],
])?>'>
<div class="swiper-wrapper">…</div>
<div class="swiper-pagination" data-swiper-pagination></div>
<button type="button" class="swiper-button-next" data-swiper-next aria-label="Следующий слайд"></button>
<button type="button" class="swiper-button-prev" data-swiper-prev aria-label="Предыдущий слайд"></button>
</div>
Селектор [data-swiper-pagination] остаётся уникальным внутри компонента, даже если на странице тридцать слайдеров с такой же разметкой. Для вложенных случаев, где нужно явное исключение, дополнительно вешают .single на внутренний и управляют им из кода.
Жизненный цикл: destroy вместо reInit
Метод reInit() был в старых версиях Swiper и убран в актуальных. Причина — непредсказуемое поведение при смене набора слайдов: остаются клоны в loop-режиме, кэшированные размеры, устаревшие ссылки на события. Правильный путь один — destroy() + новый инстанс.
const instance = window.swiperManager.get(el);
if (instance) {
instance.destroy(true, true);
}
Два аргумента destroy важны:
deleteInstance = true— удаляетel.swiperи связанные данные;cleanStyles = true— удаляет inline-стили, добавленныеSwiperпри инициализации.
Без cleanStyles = true при повторной инициализации стили наслаиваются: слайды прыгают, ширина контейнера не пересчитывается, loop дублирует уже имеющиеся клоны. Именно из-за этого «reInit() не работает» — на самом деле он просто не убирает то, что нужно убрать.
Marquee с drag по удержанию
Бесконечный marquee с drag по удержанию — задача, где стандартные настройки Swiper конфликтуют друг с другом. Разберём по пунктам.
Осторожно с freeMode
Первая мысль — включить freeMode: true и autoplay с delay: 0. Для многих конфигураций это действительно работает: freeMode снимает фиксацию на позициях слайдов, а нулевая задержка даёт непрерывное движение. Но поведение зависит от версии Swiper и от того, как реализован режим остановки автоплея при взаимодействии. В некоторых сборках freeMode начинает перехватывать события drag и останавливать автоплей даже при disableOnInteraction: false, в других — нет.
Универсально безопасная конфигурация marquee — без freeMode, но с loop, длинным speed и delay: 0:
const marqueeConfig = {
slidesPerView: 'auto',
spaceBetween: 20,
loop: true,
speed: 6000,
allowTouchMove: true,
simulateTouch: true,
grabCursor: false,
autoplay: {
delay: 0,
disableOnInteraction: false,
pauseOnMouseEnter: false,
},
};
Если проект уже опирается на freeMode: true и работает стабильно — не трогайте. Если поведение плавает — уберите freeMode, как в примере выше.
delay: 0 и большое speed дают непрерывное движение. Но одного этого мало: по умолчанию Swiper использует функцию перехода с ускорением, из-за чего marquee «пульсирует» — на каждом слайде движение замедляется и снова ускоряется. Лечится одной строкой в CSS, подключаемой через Asset::addCss() или styles.css шаблона:
.swiper-marquee .swiper-wrapper {
transition-timing-function: linear !important;
}
Если проект должен работать в очень старых браузерах, где анимации переходов отключены пользователем (prefers-reduced-motion: reduce), стоит подстраховаться:
@media (prefers-reduced-motion: reduce) {
.swiper-marquee .swiper-wrapper {
transition-duration: 0.01ms !important;
}
}
Это не отключает сам marquee, но убирает длинную анимацию перехода, оставляя мгновенную смену кадров — приемлемая деградация для пользователей с вестибулярными расстройствами.
Порог движения и интерактивные элементы
Swiper стартует drag при малейшем сдвиге курсора. Пользователь кликает ссылку в слайде, рука дрогнула на 2–3 пикселя — сработал drag, ссылка не открылась. Решается комбинацией порога и временного отключения allowTouchMove над интерактивными элементами.
Ключевой момент: mouseover/mouseout не покрывают touch-устройства — там эти события либо не приходят, либо приходят с задержкой. Правильно использовать pointerdown, который срабатывает и для мыши, и для пальца, и для стилуса. Мы временно отключаем allowTouchMove в момент нажатия и возвращаем обратно на pointerup или pointercancel.
const marqueeEl = document.querySelector('.swiper-marquee');
const marqueeSwiper = window.swiperManager.reinit(marqueeEl, {
...marqueeConfig,
threshold: 10,
});
marqueeEl.addEventListener('pointerdown', (e) => {
const interactive = e.target.closest('a, button, input, select, textarea, [role="button"]');
marqueeSwiper.allowTouchMove = !interactive;
});
marqueeEl.addEventListener('pointerup', () => {
marqueeSwiper.allowTouchMove = true;
});
marqueeEl.addEventListener('pointercancel', () => {
marqueeSwiper.allowTouchMove = true;
});
threshold: 10 означает: drag начнётся только после смещения курсора на 10 пикселей. Для кликов этого достаточно — короткий тап не превратится в drag. Для touch-устройств порог работает как системный порог распознавания жеста, а pointerdown с проверкой на интерактивный элемент отключает drag ещё до того, как он начнётся.
Отдельно стоит помнить, что allowTouchMove — обычное свойство инстанса Swiper. Если по клику оно переключается быстро и часто, могут оставаться «залипшие» состояния: пользователь нажал на кнопку, браузер не прислал pointerup (например, из-за жеста масштабирования). В таких случаях полезно дополнительно сбрасывать состояние на pointerleave.
Доступность
Слайдер, который выглядит хорошо, но недоступен с клавиатуры, — это половина слайдера. У Swiper есть встроенный модуль a11y, который включается опцией и расставляет большинство нужных атрибутов автоматически.
{
"a11y": {
"enabled": true,
"prevSlideMessage": "Предыдущий слайд",
"nextSlideMessage": "Следующий слайд",
"firstSlideMessage": "Это первый слайд",
"lastSlideMessage": "Это последний слайд",
"paginationBulletMessage": "Перейти к слайду {{index}}"
},
"keyboard": {
"enabled": true,
"onlyInViewport": true
}
}
Что это даёт:
- корневой контейнер получает
role="region"иaria-roledescription="carousel"; - каждый слайд получает
role="group",aria-roledescription="slide"иaria-labelс номером; - кнопки навигации и точки пагинации получают
aria-labelи корректныйtabindex; - клавиши
←и→переключают слайды, когда фокус внутри слайдера; - скринридер объявляет, на каком слайде пользователь находится.
Отдельно нужно позаботиться о кнопках навигации, если они кастомные:
<button type="button" class="swiper-button-prev" data-swiper-prev aria-label="Предыдущий слайд">
<svg aria-hidden="true"><use href="/local/templates/.default/img/sprite.svg#arrow-left"></use></svg>
</button>
У самой иконки стоит aria-hidden="true", чтобы скринридер не дублировал название кнопки именем спрайта. Для декоративных элементов внутри слайда — alt="" у картинок, которые не несут смысла. Для информативных — alt с описанием товара.
Конфигурация в шаблоне компонента
На стороне PHP изменения минимальны. Единственное улучшение — вынести json_encode в хелпер, чтобы не дублировать параметры экранирования в каждом template.php. Обязательное условие — обернуть объявление в function_exists, иначе на странице с двумя шаблонами получится fatal error из-за повторного объявления.
if (!function_exists('swiperDataOptions')) {
/**
* Готовит JSON-строку для атрибута data-options.
*
* @param array $options Параметры Swiper
* @return string Экранированный JSON
*/
function swiperDataOptions(array $options): string
{
return htmlspecialchars(
json_encode($options, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
ENT_QUOTES,
'UTF-8'
);
}
}
Хелпер кладётся в functions.php шаблона сайта, чтобы был доступен из любого компонента без дополнительных require.
Пример использования в шаблоне компонента:
<div class="swiper card" data-options='<?=swiperDataOptions([
'pagination' => ['el' => '[data-swiper-pagination]'],
'loop' => false,
'effect' => 'slide',
'speed' => 0,
])?>'>
<div class="swiper-wrapper">
<?foreach ($morePhoto as $IMAGE):?>
<div class="swiper-slide">
<img src="<?=htmlspecialcharsbx($IMAGE)?>" alt="<?=htmlspecialcharsbx($productTitle)?>">
</div>
<?endforeach;?>
</div>
<div class="swiper-pagination" data-swiper-pagination></div>
</div>
Про флаги json_encode:
ENT_QUOTES— защищает атрибут от поломки, если в JSON попадут одинарные или двойные кавычки, а также<и>. Без него первый же заголовок с апострофом порвёт разметку.JSON_UNESCAPED_UNICODE— сохраняет русские строки читаемыми при отладке в DevTools.JSON_UNESCAPED_SLASHES— полезен для URL. Без него строкаhttps://domain.ru/превратится вhttps:\/\/domain.ru\/— визуально шумно и хуже читается. К селекторам вроде.swiper-paginationэтот флаг отношения не имеет.
Обработка ошибок и логирование
Ошибки внутри SwiperManager не должны валить страницу целиком. Правила простые.
Создание и пересоздание инстанса — в try/catch. Невалидный селектор, отсутствующий модуль, конфликт с другим плагином, который уже навесил свои обработчики на тот же элемент — всё это логируется через console.error и позволяет остальным слайдерам на странице инициализироваться нормально.
Парсинг data-options — в try/catch с предупреждением. Если разработчик забыл запятую в JSON — пользователь увидит слайдер без настроек, а не пустую страницу. В консоль уходит console.warn с указанием конкретного элемента, что сразу локализует проблему.
Ошибки внутри destroy() — не критичны, но логируются. Если инстанс уже был уничтожен или Swiper в момент вызова destroy ещё не до конца инициализирован, исключение не должно прерывать выполнение reinit().
Для продакшена имеет смысл заменить console.* на собственный логгер с уровнями и отправкой критичных ошибок в систему мониторинга. Но это уже отдельная задача, за пределами статьи.
Тестирование
Три сценария, которые обязательно проверяются руками перед выкладкой.
Каталог с AJAX-пагинацией. Открыть каталог, пролистать три-четыре страницы. Каждый раз после подгрузки слайдеры в карточках должны быть инициализированы, кнопки навигации работать, а в консоли — ни одной ошибки. Параллельно проверить во вкладке Performance, что MutationObserver не растёт в количестве и что каждая пагинация добавляет один вызов init, а не десять.
Модальное окно со слайдером. Открыть модалку, закрыть, открыть заново пять раз. Внутри модалки должен создаваться новый инстанс, а старый — уничтожаться. Проверить через window.swiperManager.get(el): после закрытия модалки метод должен вернуть либо новый инстанс (при повторном открытии), либо undefined (если DOM удалён). Если возвращается старый инстанс с мёртвыми ссылками — где-то пропущен destroy().
Marquee с drag. Открыть страницу с marquee на десктопе и на мобильном. На десктопе — проверить, что клик по ссылке внутри слайда открывает ссылку, а не запускает drag. На мобильном — проверить, что короткий тап по ссылке не превращается в drag и что долгое удержание и перетаскивание работает. В DevTools есть режим эмуляции тач-событий: More tools → Sensors → Touch.
Дополнительно стоит проверить сценарий с медленной сетью: включить троттлинг до Slow 3G, обновить страницу и убедиться, что слайдеры инициализируются после полной загрузки, а не с пустыми контейнерами. Если страница успевает отрисоваться до того, как Swiper подгрузился, MutationObserver должен поймать изменения и инициализировать слайдеры повторно.
Итог
Стандартный подход — inline-скрипт в каждом компоненте, конфиг вразнобой, реакция на AJAX через setTimeout — работает, пока проект маленький. Как только появляется динамика, он превращается в набор костылей. Класс SwiperManager меняет точку зрения: слайдер — это не часть шаблона, а объект с жизненным циклом. Его создаёт, пересоздаёт и уничтожает один менеджер, а разметка лишь описывает конфигурацию через data-options.
Результат — три уровня конфигурации вместо разрозненных скриптов, MutationObserver с ограничением по mutation.target вместо скана всего документа, публичный API для сложных случаев вместо копипаста, явный жизненный цикл через destroy(true, true) и доступность через встроенный модуль a11y. Тот же Swiper, та же разметка — но управляемая.
Комментарии