Виджеты интерфейса на ванильном JavaScript
Vanilla JS - виджеты без фреймворка
Фреймворки (React, Vue) прячут повторяющуюся работу с интерфейсом. Но под капотом те же идеи — состояние, разметка, события, очистка при уничтожении. На "голом" JavaScript это видно явно.
Здесь разбираем один законченный виджет — промо-карусель (баннер со сменой слайдов). Разметка и стили — в типовых элементах интерфейса (CSS); базовые @keyframes и transition с разбором — CSS-анимации — готовые эффекты; классы ES6 — в работе с объектами и прототипами; DOM — в работе с HTML; события — в событиях браузера.
Сначала простыми словами
Карусель — это тот же "мини-фреймворк":
- в переменной хранится текущий слайд;
- DOM отражает это состояние;
- пользовательские события меняют состояние;
- очистка снимает подписки и таймеры.
Этот подход напрямую переносится на табы, модалки, меню и аккордеоны.
Play ITЗагрузка интерактивного демо…
Из чего состоит виджет
| Часть | Где живёт | Что делает |
|---|---|---|
| Разметка | HTML | Слайды, кнопки, контейнер для точек |
| Оформление | CSS | Ширина, наложение слайдов, transition, @keyframes |
| Поведение | JS-класс | Смена слайда, таймер, обработчики |
JavaScript не рисует баннер с нуля — он переключает атрибуты и классы, а браузер плавно меняет opacity благодаря CSS. Это важный принцип: состояние в JS, отображение в CSS.
Паттерн — constructor → init → destroy
В JavaScript нет встроенного метода init(), как в некоторых фреймворках. Это соглашение:
constructor— сохранить ссылки на DOM и начальные числа (индекс, задержка).init()— подписаться на события, создать индикаторы, запустить автопрокрутку.destroy()— снять таймер и обработчики, чтобы не было утечек памяти.
Зачем не всё в конструкторе:
- Конструктор не может быть
async— если понадобится загрузка данных,initудобнее. - Иногда объект создают, а
init()вызывают позже (например, когда блок вставили в страницу). - Тестам проще подменить
init, не трогая создание экземпляра.
Класс PromoCarousel — обзор
Ниже — учебная реализация (в репозитории энциклопедии она же лежит в src/components/shared/bannerCarouselEngine.js и питает интерактив на странице CSS).
Код ITЗагрузка примера кода…
Разбор:
DEFAULT_AUTOPLAY_MSзадаёт базовую задержку автопрокрутки и используется как дефолт при создании экземпляра.- Конструктор принимает объект параметров с деструктуризацией, поэтому порядок аргументов не критичен.
- Проверка
if (!root) { throw new Error(...) }сразу останавливает создание виджета в некорректном состоянии. querySelectorAllсобирает слайды поdata-*-атрибутам, что снижает связность с CSS-классами.- Поля
currentIndex,slideCount,autoplayIntervalформируют внутреннее состояние компонента. - Ранний
returnприslideCount === 0предотвращает лишние подписки и ошибки в дальнейшей логике. - Вызов
this.init()запускает жизненный цикл только после успешной инициализации всех зависимостей.
Конструктор — что сохраняем в this
Каждый вызов document.querySelector — обход дерева DOM. Для виджета с тремя слайдами это дёшево, но привычка кэшировать узлы в конструкторе переносится и на большие интерфейсы.
this.slides— массив изquerySelectorAll(spread[...]делает обычный массив).this.currentIndex— номер видимого слайда (0 … n−1).this.autoplayInterval— id таймера; без него нельзя остановить автопрокрутку.
throw new Error, если нет root, — раннее сообщение об ошибке вместо тихого undefined дальше по коду.
init() — порядок имеет значение
init() {
this.createIndicators();
this.showSlide(this.currentIndex);
this.startAutoplay();
this.bindControls();
this.bindHoverPause();
}
Разбор:
initвыполняет последовательную сборку виджета: сначала DOM-элементы управления, затем визуальная синхронизация состояния.showSlide(this.currentIndex)гарантирует корректный стартовый кадр до включения таймера.startAutoplay()запускается после первичной отрисовки, чтобы пользователь видел валидное начальное состояние.bindControls()иbindHoverPause()подключают интерактив после подготовки интерфейса.- Такой порядок уменьшает вероятность гонок между первыми пользовательскими кликами и автопрокруткой.
- Индикаторы — сначала создаём точки (если контейнер пустой в HTML).
- showSlide — синхронизируем видимый слайд и активную точку.
- startAutoplay — только после корректного первого кадра.
- Обработчики — в конце, когда DOM в согласованном состоянии.
createIndicators() — не дублировать HTML
Если слайдов может быть 3 или 10, вручную писать десять кнопок в разметке неудобно. Скрипт создаёт кнопки в цикле:
Код ITЗагрузка примера кода…
Почему стрелочная функция в click: () => { this.goToSlide(i) } сохраняет правильный this (экземпляр класса). Обычная function потребовала бы .bind(this).
Почему resetAutoplay после клика: пользователь осознанно выбрал слайд; через 200 мс не должно срабатывать автоматическое "догоняющее" переключение от старого таймера.
showSlide(index) — связка с CSS
Код ITЗагрузка примера кода…
Разбор:
slides.forEachпроходит по всем слайдам и вычисляет флагactiveдля текущего индекса.slide.dataset.activeсвязывает состояние JS с CSS-селекторами поdata-active.aria-hiddenделает неактивные слайды менее заметными для ассистивных технологий.- Для индикаторов
aria-current="true"сообщает, какой элемент соответствует текущему состоянию карусели. onSlideChange?.(index)использует optional chaining: колбэк вызовется только если он передан.- Метод синхронизирует и визуальное состояние, и accessibility-атрибуты в одной точке.
В CSS завязка на атрибут:
.slide[data-active='true'] {
opacity: 1;
pointer-events: auto;
}
Один источник правды — currentIndex в JS; DOM только отражает его.
Опциональный onSlideChange — колбэк для демо или аналитики ("показали слайд 2").
next(), prev() и "зацикливание"
next() {
this.currentIndex = (this.currentIndex + 1) % this.slideCount;
this.showSlide(this.currentIndex);
}
prev() {
this.currentIndex = (this.currentIndex - 1 + this.slideCount) % this.slideCount;
this.showSlide(this.currentIndex);
}
Оператор % (остаток от деления) даёт индекс в диапазоне 0 … slideCount - 1. После последнего слайда next возвращает 0; перед первым prev — последний.
Формула (index - 1 + n) % n нужна, потому что в JavaScript % с отрицательными числами ведёт себя иначе, чем ожидают с "математическим" модулем.
Автопрокрутка — setInterval и сброс
Код ITЗагрузка примера кода…
Разбор:
if (this.slideCount <= 1) return;отключает автопрокрутку, когда листать нечего.- Проверка
prefers-reduced-motionуважает системные настройки доступности пользователя. - Перед новым
setIntervalвызываетсяstopAutoplay(), чтобы не создавать дублирующиеся таймеры. window.setInterval(() => this.next(), this.autoplayDelay)периодически переключает слайд через метод экземпляра.clearIntervalвstopAutoplayосвобождает ресурсы и предотвращает "фантомные" переключения после destroy.resetAutoplayудобно вызывать после ручного действия пользователя, чтобы начать новый цикл отсчёта.
| Метод | Назначение |
|---|---|
startAutoplay | Запускает новый интервал |
stopAutoplay | Очищает старый id (обязательно перед новым setInterval) |
resetAutoplay | Обёртка "перезапустить с нуля" после клика |
prefers-reduced-motion: у части пользователей в системе включено уменьшение анимации. Автосмена слайдов — тоже движение; её отключают из уважения к настройке.
Пауза при наведении:
bindHoverPause() {
this.root.addEventListener('mouseenter', () => this.stopAutoplay());
this.root.addEventListener('mouseleave', () => this.startAutoplay());
}
Пока курсор над баннером, пользователь читает; ушёл — карусель снова крутится.
Точка входа на странице
document.addEventListener('DOMContentLoaded', () => {
const root = document.querySelector('[data-promo-carousel]');
if (!root) return;
new PromoCarousel({ root });
});
DOMContentLoaded гарантирует, что querySelector найдёт узлы из HTML. Альтернатива — скрипт в конце <body> или атрибут defer у <script src="..."> (см. работа с HTML).
destroy() — зачем очищать
Если виджет убирают из страницы (SPA, динамическая вкладка), таймер и слушатели остаются, если их не снять — утечки памяти и "фантомные" переключения.
destroy() {
this.stopAutoplay();
if (this.indicatorsHost) {
this.indicatorsHost.innerHTML = '';
}
this.indicators = [];
// В продакшене: сохранить ссылки на обработчики и вызвать removeEventListener
}
В учебном коде для кнопок "назад/вперёд" иногда клонируют узел (replaceWith(cloneNode(true))), чтобы сбросить все слушатели разом — грубо, но наглядно.
Связь с классами ES6
Синтаксис class PromoCarousel { } — сахар над прототипами (подробнее в §22). Для UI-компонента это удобно: методы лежат в одном месте, this указывает на экземпляр.
Сравнение с C#/Java:
| Идея | C# | JavaScript (браузер) |
|---|---|---|
| Поля экземпляра | private int _index | this.currentIndex |
| Инициализация | конструктор | constructor + init() |
| Обработчик кнопки | Click += ... | addEventListener('click', ...) |
| Статический helper | static method | отдельная функция или static |
Главная ловушка JS — this зависит от способа вызова, не от места объявления. В обработчиках используйте стрелочные функции или bind в конструкторе.
Что улучшить дальше (без фреймворка)
- Клавиатура —
keydownна корне: стрелки влево/вправо, Escape для паузы. - Свайп —
pointerdown/pointerupи сравнение смещения (см. события). - Intersection Observer — не крутить автопрокрутку, пока баннер не виден во viewport.
- Ленивая загрузка —
loading="lazy"на<img>внутри слайдов. - Готовые библиотеки — Swiper, Glide — имеет смысл, когда нужны миниатюры, виртуальные слайды, глубокая доступность; для одного hero-блока часто хватает своего класса на ~100 строк.
Практика
- Сверстайте баннер по гайду CSS без JS — только первый слайд виден.
- Подключите
PromoCarousel(или свой класс с тем же API). - Добавьте
max-width: 720pxи проверьте на ширине окна 1400px. - В DevTools включите "Reduce motion" — убедитесь, что автопрокрутка не стартует.
- В консоли вызовите
carousel.next()— если сохраните экземпляр вwindow.demoCarousel = new PromoCarousel(...).
Итог
Промо-карусель — хороший тренажёр — HTML (смысл и a11y), CSS (слои и анимация), JS (состояние, таймеры, события). Разделение "CSS рисует переход — JS выбирает слайд" переносится на табы, аккордеоны и модальные окна — те же кирпичи, другая разметка.