DOU 2.0: Новий красивий слайдер замість старого некрасивого
Реактивність без бібліотек, анімація скролу без transform, компонент без фреймворку? Вам не здалося. Я зробив слайдер, який працює без жодної зовнішньої залежності.
Все — чистий JavaScript, Custom Elements, Shadow DOM і трішки магії з нативним CSS-скролом. Зараз я працюю над одним дуже таємним пет-проєктом, в рамках якого я якось заглянув «під капот» слайдера на головній сторінці DOU. Передати свої відчуття від побаченого мені буде важко — різко перестало вистачати повітря, відмовляли ноги, слабли руки, паморочилась голова. І так, на мою особисту думку там усе настільки жахливо.
Однак я є палким прихильником однієї поговірки про пусті балачки та транспортацію мішків. І це якраз той випадок, коли я цілком можу взятися за мішки. Тому прийняв рішення буквально узяти ситуацію до своїх рук та розробити мінімалістичне рішення, від якого я не буду відчувати усі вищенаведені симптоми. Хотілося створити компонент, який можна просто вставити на сторінку і він працюватиме (майже скрізь, але про це пізніше).
Ідея проста: мінімум коду, максимум нативних можливостей, цікавих рішень і, звісно, ніяких фреймворків. Щоб усе це не перетворилося на набір хаотичних експериментів, я вирішив виписати для себе мінімальні вимоги, аби не заплутатися в тому, чого хочу досягти в результаті.
Вимоги
Перед тим, як братися до роботи, варто зрозуміти, що саме ми намагаємось зробити. Я розібрав існуючий слайдер і окреслив мінімальну поведінку, яку треба відтворити.
Функціональні вимоги
- Відображення слайдів: Слайдер має відображати набір слайдів. Слайдом може бути будь-який елемент, вкладений
у компонент слайдера. - Автоматичне перемикання слайдів: Слайдер має автоматично перемикати слайди через заданий часовий інтервал;
- Ручне керування: Користувач повинен мати можливість перемикати слайди вручну (наприклад, за допомогою кнопок «вперед» та «назад», індикаторів або жестів);
- Синхронізація з прокруткою: Під час самостійного переміщення між слайдами за допомогою прокрутки активний слайд має оновлюватися відповідно до позиції прокрутки;
- Призупинення автоматичного перемикання під час взаємодії: Автоматичне перемикання має зупинятися, коли користувач взаємодіє зі слайдером (наприклад, при фокусуванні чи наведенні курсора), та відновлюватися після завершення взаємодії;
Технічні вимоги
- Жоднісіньких залежностей: Бо такий шлях. Ніяких бібліотек чи фреймворків, тільки чистий JavaScript;
- Custom Element: По-перше, давно пора. По-друге, це дає можливість створити компонент з повною ізоляцією логіки та стилів;
- Лише функціональна стилізація: Компонент не повинен мати власних презентаційних стилів. Усе, що він робить — забезпечує функціональність;
- Якомога менше коду: Особистий принцип. Чим менше коду, тим менше помилок і складності. І звісно, ніяких езотеричних (для мене) рішень — усе повинно бути зрозумілим і логічним;
- A11y без додаткових зусиль: Доступність вирішується за замовчуванням через стандартні HTML-елементи та нативні API. Усе, що стосується доступності, перевіряється через автоматичні підказки ШІ та базові інструменти перевірки, без додаткових витрат часу на ручне налаштування або експерименти.
- Підтримка хоча б Chrome, Edge та Firefox: Підтримка Safari суперечить наступному принципу «мінімально необхідних зусиль», тож звиняйте;
Критерії здорового ґлузду
А ще, аби не втратити розум під час написання компоненту, я вирішив дотримуватись кількох простих принципів:
- Мінімально необхідні зусилля: Досягнути поставлених вимог з найменшими витратами часу й коду.
- Жодних костилів: Якщо щось вимагає костильного підходу — я цього не роблю.
- Реалістичні дедлайни: Якщо якась ідея займає більше одного вільного вечора без помітного прогресу — я її відкидаю.
Загалом, я вирішив, що цей компонент повинен бути якомога простішим.
Приклад використання
На сторінці цей слайдер виглядає і працює наступним чином:А ось так просто його додавати до розмітки:
<dou-slider interval="3000"> <div class="slide">Слайд 1</div> <div class="slide">Слайд 2</div> <div class="slide">Слайд 3</div> <span slot="prev" class="icon"></span> <span slot="next" class="icon"></span> </dou-slider>
Поклали слайди, за бажанням замінили іконки, вказали інтервал перемикання. А як це все функціонує, ми з вами розбиратимемо далі, і почати я пропоную зі знайомства з кодом самого компонента DouSlider
.
Custom Element
Якщо ви ще раптом не знайомі з Custom Elements — це таке API, що дозволяє вам створювати власні HTML-елементи. Саме елементи, не теги. Теги ви і так можете писати, які лишень в голову прийде, бравзер просто сприйматиме їх як найпростіші елементи, а от Custom Elements дозволяє ще й визначати їхню поведінку. І, буду відвертим, там такий бездонний колодязь можливостей, що я дізнавався дійсно нові для себе речі буквально нещодавно, під час роботи над слайдером.
class DouSlider extends HTMLElement { constructor() { super(); this.attachShadow({ mode: "open" }); } } customElements.define("dou-slider", DouSlider);
Найперше ми оголошуємо клас DouSlider
, наслідуючись від HTMLElement
та глобально реєструємо клас компонента в державному реєстрі кастомних елементів (сподіваюсь, інформацію звідти скоро можна буде побачити в Резерв+). До речі, скасувати реєстрацію уже неможливо. Ще зверніть увагу — імʼя класу взагалі не обовʼязково бути схожим на імʼя тегу, під яким реєструється компонент. Ми просто кажемо бравзеру, що як він зустріне ось такий тег, то це не діти бавились, а треба використати кастомний клас.
В конструкторі нічого надзвичайного не відбувається — викликаємо super()
для забезпечення наслідування від батьківського класу, а ще приєднуємо ShadowDOM
, який потрібен для приховання нутрощів нашого компоненту від зовнішнього світу.
Ініціалізація компонента
Наступний важливий крок — створити усю необхідну нам начинку елемента, а також оживити цю начинку за допомогою інтерактивности. І для цього є чудовий lifecycle method connectedCallback
, який викликається після того, як наш елемент потрапляє до DOM. Насправді, якщо вашому елементу не треба нічого знати про навколишній світ, то цю ініціалізацію можна провести і в конструкторі, який викликається до вставки в DOM. Але я обрав свій шлях самурая і вирішив використати connectedCallback
для більшої семантичності.
connectedCallback() { this.#render(); this.#setupTimer(); this.#currentIndex = new ReactiveCurrentIndex(this.shadowRoot.getElementById("scrollMarkerGroup")); this.#registerEventHandlers(); }
Першим ділом я викликаю метод #render()
, аби вставити усю базову розмітку в shadowRoot
:
#render() { this.shadowRoot.innerHTML = carouselTemplate; }
Чому приватний метод? Як на мене, це доволі логічно — якщо я вже ховаю всі нутрощі компонента, то й логіку варто тримати за замком. Приватні методи саме для цього й придумали.
Але є цікавинка — якщо бавитися з DevTools, то усі приватні поля можна мало того, що бачити, їх ще можна й перезаписувати. В звичайному ж контексті ви будете ловити SyntaxError
при спробі навіть прочитати приватне поле.
Що відображає компонент
Розмітка компонента
Я знаю, що тут усі неймовірні JavaScript-гуру, тому HTML та CSS нікого не цікавлять, але для мене це не аргументи. Тому хочете ви того, чи ні, але я поясню зараз, шо відбувається в шаблоні компонента:
<header id=controls part=controls> <button part=button data-action="${ButtonActions.Prev}" aria-label="Previous Slide" aria-controls="scrollview"> <slot name=prev></slot> </button> <form id="scrollMarkerGroup" part="scroll-marker-group" aria-label="Carousel Indicators"></form> <button part=button data-action="${ButtonActions.Next}" aria-label="Next Slide" aria-controls="scrollview"> <slot name=next></slot> </button> </header> <div id=scrollview role="region" aria-label="Image Slider" tabindex="0"> <slot id=scrollviewContent></slot> </div> <template id=scrollMarkerTemplate> <input type=radio id=scrollMarker name=targetCurrentIndex part="scroll-marker"/> </template>
Є header
, що міститиме елементи керування слайдером: кнопки вперед/назад, а також маркери, які а) відображають поточний активний слайд і б) перемикають активний слайд.
І є <div id=scrollview>
— область, в якій будуть знаходитися слайди, і яка відповідатиме за плавне і красиве перемикання слайдів. Ні, не за допомогою transform
. Так, можна навіть без CSS.
Ну а <template id=scrollMarkerTemplate>
— це шаблончик для тих самих маркерів, які згодом генеруються динамічно, в залежності від кількості вставлених слайдів.
Зараз я хотів би звернути вашу увагу на декілька цікавих моментів, які допоможуть спростити код в майбутньому. По-перше, я використовую data-action
на кнопках. Якщо ви памʼятаєте мою статтю про делегування подій, то одразу впізнали цей підхід, якщо ж ні — то обовʼязково прочитайте. А якщо коротко — я можу обробляти різноманітні actions
з будь-яких елементів за допомогою одного обробника подій.
По-друге, я використовую як іменовані, так і неіменовані слоти. В неіменований слот буде поміщено увесь контент, який ви помістите всередину свого елемента:
<dou-slider> <div class="slide">1</div> <div class="slide">2</div> <div class="slide">3</div> </dou-slider>
і який при цьому не матиме встановленого значення атрибуту slot
. Тобто це місце вставки вашого контенту за замовчуванням. А от для того, аби вставити щось у визначене місце, треба користуватись іменованими слотами:
<slot name="foo"></slot> … <x-component> <div slot="foo"></div> </x-component>
І от у моєму випадку, якщо я покладу в елемент будь-який контент, він поміститься одразу в слот без імені. А для того, аби підкласти іконки для кнопок, мені достатньо буде вказати їм відповідні слоти:
<dou-slider> <div class="slide"></div> <div class="slide"></div> <div class="icon" slot="prev"></div> <div class="icon" slot="next"></div> </dou-slider>
А далі зверніть увагу на атрибути part
на деяких елементах. Вони дають можливість стилізувати певні елементи всередині ShadowDOM, який, як відомо, дуже не любить зовнішні стилі. Окрім custom properties, вони ж CSS-змінні, які він з радістю пропускає. Стилізацію ззовні ми розберемо аж в кінці статті, тому або майте терпіння, або біс із ним, тим читанням, ось вам посилання на цю частину.
template
дає певний імперативний API для швидкого клонування елементів та вставки їх в DOM. Звідти братиму шаблон для маркерів, які, як бачите, є радіоінпутами.
Функціональні стилі
:where(*) { margin: 0; padding: 0; box-sizing: border-box; } :host { display: flex; flex-direction: column; } button { background: none; } button ::slotted(*) { pointer-events: none; } #scrollview { display: flex; flex: 1; overflow-x: scroll; scrollbar-width: none; scroll-snap-type: x mandatory; scroll-behavior: smooth; overscroll-behavior-x: contain; } #scrollview ::slotted(*) { scroll-snap-align: center; flex: 0 0 100%; }
Тепер швиденько розберемося, що відбувається в стилях. І це, насправді, дуже важливо, бо по факту функціонал саме слайдера у мене базується на нативному скролі, а не на тому ж transform
. Зупинюся на #scrollview
, бо скидання стилів на початку і так зрозумілі. Тако от, нас цікавлять наступні стилі, які безпосередньо впливають на те, як працює скрол:
overflow-x: scroll;
Очевидно, задаємо слайдеру стандартну поведінку зі скролом по горизонталі.
scrollbar-width: none;
О, а це вже цікаво. В такий спосіб я прибираю скролбар повністю, бо в нашому випадку ми обійдемося саморобними маркерами. І так, це працює в Firefox.
scroll-snap-type: x mandatory;
Ось так ми описуємо поведінку, яка забезпечить «паркування» слайдів по завершенні скролу. Тобто, якщо ми проскролимо так, що буде видно два слайди, які умовно припарковані ні там, ні сям, цей контейнер автоматично «доскролить» таким чином, аби слайд, який стирчить у вʼюпорті найбільше, зайняв основну позицію. В нашому випадку — аби було видно тільки його.
scroll-behavior: smooth;
Ця властивість забезпечує ту саму плавність «доскролу», та й скролу взагалі. Саме вона дозволила мені відмовитися від звичного підходу з transform
. Єдиний недолік — неможливо встановити ані швидкість, ані часову функцію
анімації. Але у цьому випадку цим можна абсолютно знехтувати.
overscroll-behavior-x: contain;
І на завершення — маленький лайвхак, який блокуватиме (не в Safari) скрол на батьківському контейнері, якщо в цьому контейнері ви доскролились до ручки краю. Найбільша проблема, яку мені вдалося цим вирішити, це блокування жесту на трекпаді, який переходить назад по історії бравзера, який неодмінно буде тригеритись, якщо скролити слайдер, знаходячись на першому слайді. Далі йде два важливих правила для слайдів:
#scrollview ::slotted(*) { scroll-snap-align: center; flex: 0 0 100%; }
Тут scroll-snap-align: center
задає правило паркування слайду, тобто він має паркуватися посередині свого батька зі скролом, а flex: 0 0 100%
змушує кожен слайд займати всю ширину контейнера, лишаючись при цьому вирівняними по горизонталі в рядочок. Ще не знудились? Далі буде жвавіше, бо ми переходимо аж до другого рядочка connectedCallback
, на якому ініціалізується...
Таймер
Цей таймер використовується для того, аби запустити перехід до наступного слайду, щойно поточний слайд завершить «паркуватися». Чому не інтервал? Інтервалом в цьому випадку набагато важче оперувати, особливо коли потрібно мати тонкий контроль над перезапуском. Я детальніше далі розберу конкретні кейси.
Один з них — можливість зупиняти і перезапускати таймер при наведенні курсора на слайд, аби призупинити цикл прокрутки і дати можливість користувачу спокійно прийняти рішення, тиснути йому в посилання, чи ні. Для керування ж таймером в контексті слайдера я виділив кілька окремих методів:
#suspendTimer() { this.#timer.disable(); } #resumeTimer() { this.#timer.enable(); }
Якщо подивитесь до методу Timer::disable
, то побачите, що він не ставить сам таймер на павзу, а зупиняє його повністю, одночасно забороняючи його перезапуск. А Timer::enable
цю заборону знімає, при цьому запускаючи таймер заново. Тобто технічно це suspend
і resume
, просто без збереження поточного прогресу відліку часу. Сподіваюся, мета існування саме такої реалізації таймера вам зрозуміла і прийнятна, бо саме час перейти до третього рядочка connectedCallback
, де починається справжня магія.
ReactiveCurrentIndex
this.#currentIndex = new ReactiveCurrentIndex( this.shadowRoot.getElementById("scrollMarkerGroup") );
Тут пропоную трохи зупинитися і розібратися, що відбувається в класі ReactiveCurrentIndex.
А відбувається тут нахабне використання особливостей поведінки HTML Form Elements для забезпечення тієї самої «реактивности». Якщо конкретніше: комбінація можливості перемикати активний input[type=radio]
та можливість звертатися до елементів форми всередині form
за іменем. Дивіться, в чому фокус. Маючи посилання на елемент форми і набір радіо-інпутів під одним іменем, ми маємо доступ до цікавого обʼєкту типу RadioNodeList
:
<input type=radio name=targetCurrentIndex value=0 …> <input type=radio name=targetCurrentIndex value=1 …>
form.elements.targetCurrentIndex // <-- Ось.
який виглядає як звичайний NodeList
, але збоку у нього теліпається value
, в якому, очевидно, лежатиме значення атрибута value
активного радіо-інпута. Тобто, звернувшись в будь-який момент до цього обʼєкта, я матиму актуальне значення.
А тепер питання — коли зазвичай мені потрібно дізнатися поточне значення? Правильно, коли воно щойно змінилося (рєактівщина, ммм...) А як дізнатися, коли якесь значення всередині форми
змінилося? Знову правильно — повісити listener на подію change
. Ну або внаглу втулитиonchange
. В цьому випадку це абсолютно припустимо, бо ця форма більш ні для чого мені не потрібна.
А тепер до справді чорної магії: якщо встановити вручну значення value
у RadioNodeList
, то відповідний радіо-інпут автоматично оновить свій стан! Угумсь, він стане checked
. Візуально теж. Але є одне але — при цьому подію change
не буде створено. А нашо вона нам? А для того, щоб завершити повний цикл реактивності.
Приходьте послухати Сергія на DOU Day👇
Дивіться самі. У мене є сутність ReactiveCurrentIndex
, за допомогою якої я можу реагувати на зміну
її value
, викликаючи якийсь колбек.
Ця сутність одночасно вміє слідкувати за активним станом форми, який синхронізується з UI. Тобто я очікую, що натиснувши на радіо-інпут, мій код зможе зреагувати на зміну поточного індексу, а якщо я присвою значення value
ззовні, то активний індикатор автоматично оновиться. От тому в сетері value
окрім того, що я оновлюю значення активного інпуту, я ще й «стріляю» подію change
, бо саме на ній висить зовнішня підписка.
Інтерактивність
this.#registerEventHandlers();
Отут буде дуже багато чого цікавого, і це те місце, де все сходиться докупи.
Обробка кнопок
… #registerEventHandlers() { this.shadowRoot.getElementById("controls").onclick = (event) => { if (!event.target.dataset?.action) { return; } this.#handleButtonClick(event.target.dataset.action); }; … #handleButtonClick(action) { switch (action) { case ButtonActions.Next: this.showNextSlide(); break case ButtonActions.Prev: this.showPrevSlide(); break; default: return; } }
Ось воно, священне делегування подій! Я вішаю лише один обробник кліків, і обробляю в ньому лише події від елементів з атрибутом data-action
. А далі, абсолютно абстрагуючись від того, звідки цей action
прилетів, обробляю його.
Зупинка та перезапуск таймера при взаємодії
this.shadowRoot.onfocusin = () => { this.suspendTimer(); }; this.shadowRoot.onfocusout = () => { this.resumeTimer(); }; … this.#scrollviewRef.onmouseenter = () => { this.suspendTimer(); }; this.#scrollviewRef.onmouseleave = () => { this.resumeTimer(); };
В цих обробниках подій я втілюю якраз вимогу зупиняти таймер коли користувач взаємодіє зі слайдом — в даному випадку при наведенні курсора, або ж коли якийсь елемент всередині слайду отримує фокус. І, так, я був дуже приємно здивований тим, що підхід з використанням нативного скролу для слайдер виявився напрочуд доступним. Наприклад, слайдер сам прокручується на наступний слайд, якщо «табити» по активним елементам всередині слайдів. В моєму випадку це посилання на статтю.
І реагувати на фокус всередині слайд стало можливим завдяки подіям focusin
та focusout
, які спливають, на відміну від focus
. Що важливо — focusin
це не подія «фокус всередині», а «отримання фокусу елементом». А focusout
це коли фокус «переходить» з іншого елемента. І виявляється, що ця подія ще й містить посилання на елемент, який втрачає фокус.
Підписка на зміну стану поточного індексу активного слайда
this.#currentIndex.onvaluechange = () => { this.#scrollToCurrentSlide(); };
Ось тут я «підписуюсь» на зміну стану в моєму штучному обʼєкті CurrentIndexState, в якомуonvaluechange
викликається явно:
// class CurrentIndexState constructor(formRef) { this.#formRef = formRef; this.#formRef.onchange = () => { this.onvaluechange?.(this.value); }; }
Оновлення маркерів
this.#scrollviewRef.onslotchange = () => { this.#slideCountCache = null; this.#renderMarkers(); }; … #renderMarkers() { const markerGroup = this.shadowRoot.getElementById("scrollMarkerGroup"); const markerTemplate = this.shadowRoot.getElementById("scrollMarkerTemplate"); const fragment = document.createDocumentFragment(); for (let index = 0; index < this.#slideCount; index++) { const input = markerTemplate.cloneNode(true).content.firstElementChild; input.value = index; input.checked = index === 0; fragment.append(input); } markerGroup.replaceChildren(fragment); }
Що відбувається? По-перше, я підписуюсь на подію slotchange
, яку викидає будь-який слот при зміні свого вмісту. Я не можу згенерувати маркери в іншому місці, бо ні під час виконання конструктора, ні в самому connectedCallback
у мене ще немає доступу до вмісту слота. Тому треба підписатися, щоб слот сам сказав — «я маю новий вміст».
Ну а тоді я отримую кількість слайдів, переданих в компонент, і від того створюю відповідну кількість маркерів. this.#slideCount
є геттером, і ми розглянемо, чому я обрав такий підхід, дещо пізніше. Самі ж маркери створюю доволі просто: за допомогою template
створюю новий інстанс радіо-інпуту, вставляю його в documentFragment
, а потім одним махом заміняю усі дочірні елементи в markerGroup
(це, якраз, ота форма, з якою я і роблю реактивну магію). Ніяких innerHTML = ''
, ніякого while
і видалення нащадків по одному. Раз — і готово!
Автоматичне перемикання слайдів
this.#scrollviewRef.onscrollend = ({ target: { scrollLeft } }) => { this.#currentIndex.value = Math.round(scrollLeft / this.#scrollviewWidthCache); this.#timer.restart(); };
Що Chrome (і його похідні), що Firefox нині підтримують подію scrollend
(але не Safari), про яку, гадаю, просто мріяло безліч поколінь веброзробників. Ця подія транслюється, як не дивно, саме по завершенні скролу, і це якраз те місце, в якому я хотів би перезапустити таймер. Що я і роблю. Чому я одночасно оновлюю #currentIndex.value
? Бо scrollend
може відбутися і без участі таймеру — коли ми запускаємо наступний слайд кнопкою, або ж явно прокруткою, наприклад тачпадом.
В такому випадку необхідно оновити поточний індекс.Тобто індекс ми оновлюємо на випадок, якщо скрол запустили ми самі, а таймер перезапускаємо на випадок, якщо перемикання відбулося автоматично. І в обох випадках в логіці присутні перевірки, щоб не запускати зайвого коду. Наприклад, якщо ми самі скролимо слайди, таймер не запуститься, бо буде в цей час заблокований. А оновлення індексу не запустить обробник події у випадку, якщо це проста синхронізація, і внутрішнє значення, та значення, яке ми присвоюємо, співпадають.
Оновлення кешу ширини контейнера
#scrollviewResizeObserver = new ResizeObserver(([entry]) => { this.#scrollviewWidthCache = entry.contentRect.width; }); … this.#scrollviewResizeObserver.observe(this.#scrollviewRef);
Аби слайдеру не ставало хижо при зміні його розмірів, я додав ResizeObserver
, який відслідковує зміни розміру елементу scrollvew
. В двох словах — це як onresize, але для окремого елемента. Як ви могли бачити вище, ця ширина використовується для обчислення поточного індексу слайду наприкінці скролу. І аби не смикати зайвий раз DOM-елемент, я це значення кешую.
Деініціалізація
Я довго вагався, чи додавати це взагалі в код, бо, по факту, особливих протікань памʼяті тут не передбачається, адже очікується, що слайдер існуватиме увесь час існування сторінки, і піде в небуття разом із нею. Але для наглядности я вирішив усе таки дещо підчистити:
disconnectedCallback() { this.#scrollviewResizeObserver.disconnect(); this.#scrollviewResizeObserver = null; this.#timer.disable(); }
В disconnectedCallback
я вибірково очищаю ResizeObserver
та зупиняю таймер. Ще, можливо, пасує очистити мій реактивний стейт. Сама суть цього колбеку полягає в тому, що він викликається перед видаленням нашого кастомного елемента з DOM, і якщо передбачається, що сторінка існуватиме і далі, то це якраз чудове місце, в якому можна влаштувати генеральне прибирання памʼяті.
Як працює скрол
Для реалізації найголовнішої фічі слайдера, а саме безпосреденьо скролу, або ж прокрутки, я обрав нативний скрол замість transform
. В інших розділах я побіжно згадуватиму ті чи інші переваги, які вдалось отримати, а зараз окреслю в загальному. Так от, нативний скрол дає мені вбудовані можливості як для запуску, так і для відслідковування прокрутки. Той самий метод scrollTo
чи подія scrollend
.
А ще, працюючи над скролом, часто із легким сумом згадував метод scrollIntoView
, бо його аналог на рівні контейнера ще більше спростив би логіку прокрутки. Для запуску скролу я маю ось такий метод, в якому явно задаю позицію, до якої треба проскролити, знаючи поточний індекс та ширину контейнера:
#scrollToCurrentSlide() { this.#scrollviewRef.scrollTo({ left: this.#currentIndex.value * this.#scrollviewWidthCache }); }
Викликаю я його, як ви вже могли бачити, в одному-єдиному місці:
this.#currentIndex.onvaluechange = () => { this.#scrollToCurrentSlide(); };
А от змінюю значення #currentIndex.value
я в кількох місцях, покладаючись на саморобну реактивність:
this.#scrollviewRef.onscrollend = ({ target: { scrollLeft } }) => { this.#currentIndex.value = Math.round(scrollLeft / this.#scrollviewWidthCache); this.#timer.restart(); }; … showNextSlide() { this.#currentIndex.value = (this.#currentIndex.value + 1) % this.#slideCount; } showPrevSlide() { this.#currentIndex.value = (this.#currentIndex.value + this.#slideCount - 1) % this.#slideCount; }
До речі, оці чудернацькі розрахунки це ніщо інше, як «зациклювання» перемикання слайдів. Тобто, дійшовши до останнього, ми переключимось на перший. А якщо будемо відмотувати назад, то з першого перескочимо на останній слайд.
Гетер, сетер, два кеша
Кешування DOM-елементів
Аби мінімізувати кількість запитів до DOM-елементів, я вирішив скористатися такою прекрасною функціональністю JavaScript-обʼєктів, а в моєму випадку класів, як гетери та сетери. Наприклад, ось кешування посилання на scrollview
:
#scrollviewRefCache = null; get #scrollviewRef() { return (this.#scrollviewRefCache ??= this.shadowRoot.getElementById("scrollview")); }
Здавалось би, яка різниця, напряму чи ось так? Але різниця є. Не в цьому прикладі, звичайно, тут втрати будуть фактично не помітні, але на складніших випадках, якщо часто запитувати елементи через DOM API, падіння швидкодії може бути відчутним. Ви помітили оцей прикол з ??=
? Розберемось з ним трошкти згодом, тримайте в голові.
Кешування значень властивостей DOM-елементів
Аби мати доступ до актуальної кількості слайдів всередині слота, я використав доволі хитромудру конструкцію, але знову ж таки, вийшло доволі «реактивно»:
#slideCountCache = null; get #slideCount() { return (this.#slideCountCache ??= this.shadowRoot.getElementById("scrollviewContent").assignedElements().length); }
Спробую пояснити. Отже, #slideCount
— це суто гетер, який, по факту, виконується щоразу, як до нього звертається якийсь код. Тому їх бажано якось або мемоїзувати, або кешувати в інший спосіб. У мене в ролі кеша виступає #slideCountCache
. В ньому зберігається довжина колекції з assignedElements
слота, в якому знаходяться слайди. Мені потрібно знати точну кількість слайдів в будь-який момент часу, тому і з кешем тут трошки хитро. Давайте поки розберемось з ??=
. Якщо коротко, цей оператор заміняє ось таку конструкцію:
get #slideCount() { if (this.#slideCountCache === null || this.#slideCountCache === undefined) { this.#slideCountCache = this.shadowRoot.getElementById("scrollviewContent").assignedElements().length } return this.#slideCountCache; }
Тож оновлення кеша відбудеться лише в тому випадку, коли #slideCountCache
буде null
.
І я це роблю вручну ось в цьому місці:
this.#scrollviewRef.onslotchange = () => { this.#slideCountCache = null; // <-- в цьому місці this.#renderMarkers(); };
Тобто я очікую, що при slotchange
кількість слайдів може змінитися, тому інвалідую кеш. А при наступному звертання до #slideCount
цей кеш оновиться, і гетер вертатиме кешоване значення. Ось так.
Кешування інстансів
В методі #setupTimer
??=
використовується для лінивої ініціалізації обʼєкта таймера, що дозволяє мені створити його лише раз, а при усіх послідовних викликах користуватися уже готовим посиланням на інстанс.
const timer = (this.#timer ??= new Timer(this.interval, () => this.showNextSlide()));
Публічне API компонента
Звичайно ж, наглухо зашитий слайдер нікому не цікавий, тож я вирішив деякий його функціонал винести в публічне API, а саме налаштування тривалості перемикання слайдів і методи для цього перемикання.
Керування перемиканням
Дуже просто. Назовні стирчать два публічні методи: showNextSlide
та showPrevSlide
, які виконують рівно те, як називаються.
document.querySelector('dou-slider').showNextSlide(); document.querySelector('dou-slider').showPrevSlide();
Оновлення внутрішнього стану за допомогою атрибутів
У Custom Elements є чудова вбудована можливість слідкувати за зміною атрибутів DOM-елементу, і реагувати на неї всередині нашого коду. Для початку треба явно вказати, за якими атрибутами ми хочемо слідкувати:
const Attributes = { Interval: "interval", }; … static get observedAttributes() { return [Attributes.Interval]; }
І далі використати ще один lifecycle callback attributeChangedCallback
:
attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) { return; } if (name === Attributes.Interval) { this.#intervalCache = null; this.#setupTimer(); } }
Перевірку на спробу присвоїти старе значення треба робити руками, це вам не фреймворки, де за вас усе подумали. Конкретно в цьому прикладі я скидаю кеш для interval
, значення якого оновиться уже всередині метода #setupTimer
при спробі його прочитати. А #setupTimer
в свою чергу просто оновить значення duration
та перезапустить таймер.
Тобто змінювати періодичність можна в будь-який момент, просто змінивши значення атрибуту interval
елемента dou-slider
! Але і це ще не все. Ви помітили, що interval
, на відміну від інших, публічний? Так, я дозволяю змінювати його значення і через JavaScript! Але не просто так, а з цікавим фокусом:
get interval() { return (this.#intervalCache ??= DouSlider.getPositiveInterval(parseInt(this.getAttribute(Attributes.Interval)))); } set interval(interval) { this.#intervalCache = null; this.setAttribute(Attributes.Interval, DouSlider.getPositiveInterval(interval)); }
Гетер має виглядати уже знайомо для вас — кеш через окреме поле і nullish coalescing assignment, все просто. А от в сетері я скидаю цей кеш, і оновлюю атрибут. Таким чином і поле і атрибут синхронізовані. Подібна поведінка в стандартних елементах є, до прикладу, у атрибуту id
. О, а ще зробив невеличкий статичний метод для валідації значення інтервалу.
По-перше, атрибути завжди мають рядковий тип (string
), це вам не реакт, нагадую, а по-друге, порушити просторово-часовий континуум мені не дуже хочеться, тож треба слідкувати, аби не можна було присвоїти відʼємні значення інтервалу:
static getPositiveInterval(interval) { const num = parseInt(interval); return Number.isInteger(num) ? Math.max(num, 0) : 0; }
Ну і, звичайно, #intervalCache
має значення 0 за замовчуванням, тобто слайдер не перемикатиме слайди, якщо ми не вкажемо явний інтервал через атрибут або властивість елемента. І тепер можна оновлювати інтервал не лише через атрибут, а й прямим присвоєнням:
document.querySelector('dou-slider').interval = 1000;
Що можна покращити
Ну, як мінімум можна додати якийсь кастомний івент на кшталт slidechange
, в якому передаватиметься поточний індекс слайда. В такий спосіб, разом з публічними методами showNextSlide
, showPrevSlide
, можливістю задавати значення індекса через публічний сетер interval
та можливістю реагувати на внутрішню зміну цього індекса можна дозволити створювати власні елементи керування слайдером поза компонентом. Звичайно, тоді ще доведеться вводити якийсь атрибут для вимкнення «нативних» кнопок, шось типу controls="none"
. Очевидно, я цього не робив, бо такої задачі не стояло.
Шо по доступності
Відверто, я в цій темі не те, шоб дуб дерево хвойне, але експертности відчутно бракує. Тому я покладався на вбудовані можливості для tab-навігації, і разочок попросив ШІ додати необхідні aria-атрибути. І цього, на мою думку, для цього компонента цілком достатньо. Тим паче, що завдяки використанню суто нативних елементів я отримав кілька дуже корисних побічних ефектів. Отже, завдяки використанню форми і радіо-інпутів є можливість перемикати слайди стрілками з клавіатури, якщо один з маркерів є в фокусі. Вверх та вліво перемикатимуть назад, вниз і вправо, відповідно — вперед.
І, помітьте, в коді взагалі немає обробників подій з клавіатури. Також і сам скрол-контейнер підтримує вбудовану навігацію з кнопок. Ну як навігацію. Скролити кнопками можна було від початку часів, а тут просто за рахунок scroll-snap
відбувається автоматична парковка. Єдиний нюанс — якщо натиснути стрілку ще раз після того, як запустилась прокрутка, то вона буквально встане на павзу, без паркування. Але наступне натискання відновить анімацію.
А наступне — зупинить. Також працює tab-навігація по кнопках, і по активним елементам всередині слайдера. Я вже раніше згадував, що при отриманні фокусу всередині слайду, контейнер сам підскролить , щоб елемент з фокусом було видно.
Зовнішні стилі
Як і обіцяв, аж наприкінці статті розповім про стилізацію вебкомпонентів за допомогою ::part
. Взагалі, ззовні ваш Custom Element можна обмазати стилями в два способи: за допомогою CSS-змінних та тих самих part
. Різниця полягає в тому, що CSS-змінні дають можливість лише точкової зміни певних стилів, що аж надто добре накладається на ідею ізоляції стилів. А ::part()
працюють як ’мілкі’ селектори, якими можна зловити уже цілий елемент, але не його нащадків.
<!-- Shadow DOM --> <div part="styled"> <div class="child"> custom-element::part(styled) { /* Стилі спрацюють */ } custom-element::part(styled) .child { /* Нє */ }
Nested CSS синтаксис ::part()
теж не підтримує, хоча дозволяє метчити ті самі псевдоелементи:
custom-element::part(styled)::after { /* Стилі спрацюють */ } custom-element::part(styled) { &::after { /* Нє */ } }
Повні кастомні стилі для кнопок і маркерів виглядають ось так:
Тут особливо цікавинок немає, про змінні розповім в окремому матеріалі, єдине, що варте окремої згадки, це те, що appearance: none
скрізь працює чудово, але...
«Не в Safari» ©
Я цей браузер не люблю давно й палко. В ньому або шось не робе геть, або робе з такими багами, шо на голову не налізає. Так і тут. До прикладу, тікет щодо імплементації івента scrollend
відкрито ще 2019 року.
appearance: none
на радіо-інпутах працює дуже дивно, і оновлює відображення маркерів тільки при наведенні на них курсора. Той же overscroll-behavior
працює досить дивно — якщо скролити на першому слайді повільно й ніжно, ніби пестячи спляче кошеня, то оверскрол блокується, як і очікується.
Якщо ж рука ваша смикнеться і ви скрольнете дещо швидше за помираючого слимака, то Safari чхатиме на наші бажання, і оверскрол спрацює тільки так.
Тому, згідно правила «якщо щось потребує костиля — я того не роблю», підтримку Safari було виключено повністю. В ньому можна й старий слайдер крутити, мені не жалко.
Післямова
Отже, що ми маємо в результаті? Повністю автономний, реактивний і доступний слайдер, який не потребує жодних сторонніх залежностей, працює на чистому JavaScript, використовує максимум нативних браузерних API та забезпечує високу продуктивність без зайвих компромісів. Так, це не ідеальне рішення. Воно навряд чи виграє нагороди за дизайн чи гнучкість. Але під час роботи над ним я відкрив для себе кілька цікавих підходів, які давно хотів спробувати, отримав кілька осяянь — наприклад, щодо використання нативного скролу замість transform чи оті реактивні трюки з формами й радіоінпутами.
Я не просто побачив нові можливості в добре відомих API, а й примусив їх працювати разом так, як цього не очікував. Писав, видаляв і переписував код — і, по ходу, видалив я того коду крепко більше, ніж лишилося в результаті.
Вихідний код також доступний у публічному репозиторії на GitHub. Форкайте, бавтесь, додавайте поліфіли для Safari, якщо не цінуєте свій час, бггг.
Якщо цей матеріал змусив когось із вас переглянути свої підходи або побачити бравзери з нового боку — значить, усе було недарма. Бо моя мета була не просто створити компонент, а показати, що нативні можливості можуть бути не менш цікавими й потужними, ніж готові бібліотеки чи фреймворки. А тепер — до коментів! Я знаю, що ви вже готові розказати, де я не правий. Чекаю на ваші виважені зауваження.
І, звісно, командо DOU — зі святом вас! 20 років тримати спільноту — це неймовірно потужно. Від мене — цей маленький, але щирий подарунок. Буду надзвичайно втішений, якщо вам пасуватиме таки використати цей слайдер на сайті. Тоді вже можна буде і сумісність із Safari доробити. Але це буде вже тема для зовсім іншої статті. Цьом вам у лобіка!
Подякувати за цю титанічну працю ви можете, закинувши дві гривні на РЕБ, або ж долучившись до ініціативи «Незамовлений Гепі Міл»
P.S. Подаруйте хтось сайту нормальний редактор для дописів, будь-ласочка, а то у мене склалось враження, що я на форматування тексту витратив ледь не стільки ж, скільки на саме написання, майте бога в серці.
68 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів