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

Виджеты интерфейса на ванильном JavaScript

Разработчику Архитектору

Vanilla JS - виджеты без фреймворка

Фреймворки (React, Vue) прячут повторяющуюся работу с интерфейсом. Но под капотом те же идеи: состояние, разметка, события, очистка при уничтожении. На «голом» JavaScript это видно явно.

Здесь разбираем один законченный виджет — промо-карусель (баннер со сменой слайдов). Разметка и стили — в типовых элементах интерфейса (CSS); классы ES6 — в работе с объектами и прототипами; DOM — в работе с HTML; события — в событиях браузера.

Загрузка карусели…

Из чего состоит виджет

ЧастьГде живётЧто делает
Разметка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).

const DEFAULT_AUTOPLAY_MS = 5000;

class PromoCarousel {
constructor({ root, autoplayDelay = DEFAULT_AUTOPLAY_MS, onSlideChange } = {}) {
if (!root) {
throw new Error('PromoCarousel: нужен корневой элемент');
}

this.root = root;
this.slides = [...root.querySelectorAll('[data-carousel-slide]')];
this.prevBtn = root.querySelector('[data-carousel-prev]');
this.nextBtn = root.querySelector('[data-carousel-next]');
this.indicatorsHost = root.querySelector('[data-carousel-indicators]');

this.currentIndex = 0;
this.slideCount = this.slides.length;
this.autoplayDelay = autoplayDelay;
this.autoplayInterval = null;
this.onSlideChange = onSlideChange;
this.indicators = [];

if (this.slideCount === 0) {
return;
}

this.init();
}

init() {
this.createIndicators();
this.showSlide(this.currentIndex);
this.startAutoplay();
this.bindControls();
this.bindHoverPause();
}

// ... методы ниже разобраны по блокам
}

Конструктор — что сохраняем в 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();
}
  1. Индикаторы — сначала создаём точки (если контейнер пустой в HTML).
  2. showSlide — синхронизируем видимый слайд и активную точку.
  3. startAutoplay — только после корректного первого кадра.
  4. Обработчики — в конце, когда DOM в согласованном состоянии.

createIndicators() — не дублировать HTML

Если слайдов может быть 3 или 10, вручную писать десять кнопок в разметке неудобно. Скрипт создаёт кнопки в цикле:

createIndicators() {
if (!this.indicatorsHost) return;

this.indicatorsHost.innerHTML = '';
this.indicators = [];

for (let i = 0; i < this.slideCount; i++) {
const dot = document.createElement('button');
dot.type = 'button';
dot.setAttribute('data-carousel-dot', '');
dot.setAttribute('aria-label', `Слайд ${i + 1}`);

dot.addEventListener('click', () => {
this.goToSlide(i);
this.resetAutoplay();
});

this.indicatorsHost.appendChild(dot);
this.indicators.push(dot);
}
}

Почему стрелочная функция в click: () => { this.goToSlide(i) } сохраняет правильный this (экземпляр класса). Обычная function потребовала бы .bind(this).

Почему resetAutoplay после клика: пользователь осознанно выбрал слайд; через 200 мс не должно срабатывать автоматическое «догоняющее» переключение от старого таймера.


showSlide(index) — связка с CSS

showSlide(index) {
this.slides.forEach((slide, i) => {
const active = i === index;
slide.dataset.active = active ? 'true' : 'false';
slide.setAttribute('aria-hidden', active ? 'false' : 'true');
});

this.indicators.forEach((dot, i) => {
const active = i === index;
dot.dataset.active = active ? 'true' : 'false';
if (active) {
dot.setAttribute('aria-current', 'true');
} else {
dot.removeAttribute('aria-current');
}
});

this.onSlideChange?.(index);
}

В 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 и сброс

startAutoplay() {
if (this.slideCount <= 1) return;
if (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) return;

this.stopAutoplay();
this.autoplayInterval = window.setInterval(() => this.next(), this.autoplayDelay);
}

stopAutoplay() {
if (this.autoplayInterval) {
window.clearInterval(this.autoplayInterval);
this.autoplayInterval = null;
}
}

resetAutoplay() {
this.stopAutoplay();
this.startAutoplay();
}
МетодНазначение
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 выбирает слайд» переносится на табы, аккордеоны и модальные окна — те же кирпичи, другая разметка.

См. также

Другие статьи этого же раздела в боковом меню (как на странице «О разделе»).