Перейти к основному содержимому

Виджеты интерфейса на ванильном 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() — снять таймер и обработчики, чтобы не было утечек памяти.

Зачем не всё в конструкторе:

  1. Конструктор не может быть async — если понадобится загрузка данных, init удобнее.
  2. Иногда объект создают, а init() вызывают позже (например, когда блок вставили в страницу).
  3. Тестам проще подменить 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() подключают интерактив после подготовки интерфейса.
  • Такой порядок уменьшает вероятность гонок между первыми пользовательскими кликами и автопрокруткой.
  1. Индикаторы — сначала создаём точки (если контейнер пустой в HTML).
  2. showSlide — синхронизируем видимый слайд и активную точку.
  3. startAutoplay — только после корректного первого кадра.
  4. Обработчики — в конце, когда 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 _indexthis.currentIndex
Инициализацияконструкторconstructor + init()
Обработчик кнопкиClick += ...addEventListener('click', ...)
Статический helperstatic methodотдельная функция или static

Главная ловушка JS — this зависит от способа вызова, не от места объявления. В обработчиках используйте стрелочные функции или bind в конструкторе.


Что улучшить дальше (без фреймворка)

  1. Клавиатураkeydown на корне: стрелки влево/вправо, Escape для паузы.
  2. Свайпpointerdown / pointerup и сравнение смещения (см. события).
  3. Intersection Observer — не крутить автопрокрутку, пока баннер не виден во viewport.
  4. Ленивая загрузкаloading="lazy" на <img> внутри слайдов.
  5. Готовые библиотеки — Swiper, Glide — имеет смысл, когда нужны миниатюры, виртуальные слайды, глубокая доступность; для одного hero-блока часто хватает своего класса на ~100 строк.

Практика

  1. Сверстайте баннер по гайду CSS без JS — только первый слайд виден.
  2. Подключите PromoCarousel (или свой класс с тем же API).
  3. Добавьте max-width: 720px и проверьте на ширине окна 1400px.
  4. В DevTools включите "Reduce motion" — убедитесь, что автопрокрутка не стартует.
  5. В консоли вызовите carousel.next() — если сохраните экземпляр в window.demoCarousel = new PromoCarousel(...).

Итог

Промо-карусель — хороший тренажёр — HTML (смысл и a11y), CSS (слои и анимация), JS (состояние, таймеры, события). Разделение "CSS рисует переход — JS выбирает слайд" переносится на табы, аккордеоны и модальные окна — те же кирпичи, другая разметка.