JavaScript: 2 интересных примера практического использования Intersection Observer API

Kate

Administrator
Команда форума
Привет, друзья!


На днях мне посчастливилось заниматься решением 2 несложных, но довольно интересных задач на чистом JavaScript (из-за React чуть не забыл, как это делается). В процессе решения этих задач никто не пострадал, напротив, все остались довольны. Поэтому я решил поделиться результатами с сообществом.


Обратите внимание: данная статья рассчитана, преимущественно, на начинающих разработчиков и вряд ли покажется интересной опытным, хотя, смею надеяться, что и последние не пожалеют потраченного времени, если прочитают ее до конца.


Введение​


Итак, задачи были следующими:


  • Реализовать навигацию по разделам сайта с визуальным переключением индикатора текущего местонахождения пользователя и возможностью переходить к определенному разделу по клику. Сама навигация согласно макету должна была выглядеть так:

y3if5mtvau91r3hooek10sjpcl8.png




  • Реализовать "ленивую" (отложенную, lazy) загрузку медиаресурсов (изображений, аудио и видео), поскольку те же изображения даже после сжатия с помощью gulp-imagemin весили (и весят, потому что они никуда не делись) неприличные 50 Мб и загружались при запуске приложения (больше они себя так не ведут).

Как я уже сказал, обе задачи надо было решить на ванильном JS. Почему? Потому что проект был, что называется, legacy — Python в лице Wagtail и JS в лице JQuery.


На первый взгляд, может показаться, что названные задачи являются совершенно разными и между ними не может быть ничего общего. “Что может их объединять?” — спросите вы.


Ответ: технология, которую можно использовать для их решения. Вы не ослышались, обе задачи можно решить с помощью одного инструмента и при этом его даже не придется устанавливать. Для решения задач нам не потребуются ни сторонние библиотеки, ни вспомогательные утилиты — интерфейс, о котором идет речь, предоставляется самим браузером.


Как вы уже могли догадаться по названию статьи, я говорю об Intersection Observer API (далее — IOA). Данный интерфейс поддерживается всеми современными браузерами.


Если кратко, то IOA позволяет асинхронно регистрировать момент пересечения целевого элемента с его предком, если таковой определен в настройках, или с областью видимости документа (viewport).


Экземпляр IOA создается так:


const observer = new IntersectionObserver(callback, options)

Конструктор IntersectionObserver принимает 2 параметра: функцию обратного вызова, запускаемую в момент пересечения — callback, и объект с настройками — options.


Объект с настройками может содержать 3 поля:


  • root — родительский элемент, за пересечением с которым (целевым элементом) наблюдает наблюдатель (извините за тавтологию). Если root не определен или имеет значение null, предком целевого элемента считается viewport
  • rootMargin — отступы вокруг root. Как и для CSS-свойства margin, значения для rooMargin могут задаваться в пикселях, процентах, em, rem и т.д., например, 20px 10px. По умолчанию данная настройка имеет значение 0
  • threshold — процент видимости целевого элемента от 0 до 1. Значение данной настройки может задаваться в виде числа или массива чисел. По умолчанию также имеет значение 0

Наблюдение за целевым элементом устанавливается посредством вызова метода observe:


observer.observe(target)

Существует также несколько других методов, но мы будет использовать только observe.


При достижении целевым элементом порогового значения (threshold) вызывается callback, который получает список объектов entries и самого наблюдателя. Каждый объект entry имеет несколько свойств (target, time, intersectionRatio и др.). Единственным свойством, которое нас интересует, является isIntersection — логическое значение, выступающее индикатором нахождения целевого элемента в зоне пересечения.


Кстати, процент пересечения также можно определять с помощью свойства intersectionRatio.


Пожалуй, это все, что нам нужно знать об IOA для того, чтобы успешно решить наши задачи. Приступаем.


Начнем с навигации.


Реализация навигации по разделам сайта​


Давайте рассуждать.


Элементы меню (ссылки) соответствуют разделам страницы (заголовкам разделов, если быть точнее). Каждому заголовку соответствует определенная ссылка. Мы это знаем, но этого не знает JS. Поэтому нам нужно как-то сообщить ему об этих отношениях один к одному. Существует несколько способов это сделать, но самым простым является использование атрибута data-*. Назовем его data-section. Пускай для простоты значениями этих атрибутов будут номера разделов (1, 2, 3 и т.д.).


Также нам нужен какой-то способ визуального отображения текущего раздела, т.е. раздела, просматриваемого пользователем. Самым простым способом это сделать является добавление к такому разделу специального CSS-класса. Назовем этот класс active.


Вот как будет выглядеть один элемент навигации и соответствующий ему раздел:


<!-- элемент навигации -->
<div class="step active" data-section="1">
<span class="text">Section 1</span>
<div class="dot"></div>
</div>

<!-- раздел страницы, соответствующий этому элементу -->
<section data-section="1">
<h2>Section 1</h2>
<p>Много lorem</p>
</section>

Таких элементов и разделов у нас будет 5 штук.


Вся разметка:


Стили вполне себе обычные, так что на них я останавливаться не буду.


Все стили:


Как вы думаете, сколько строк JS-кода занимает решение данной задачи? Не забывайте о том, что кроме выделения элемента навигации, соответствующего текущему разделу, с помощью стилей, нам также необходимо реализовать переключение между разделами по клику (при этом визуализация должна отрабатывать корректно). Так сколько же? 100? 50?


Ответ: 30 строк! И это без экономии на пробелах и отступах.


Завернем весь наш код в функцию. Назовем ее createSectionNav. Пусть функция принимает корневой элемент — root:


// определяем функцию
const createSectionNav = (root) => {
// ...
}
// вызываем ее с корневым элементом
createSectionNav(document.querySelector('#page'))

Получаем ссылки на nav, main и текущий (активный) элемент навигации (далее — шаг):


const nav = root.querySelector('nav')
const main = root.querySelector('main')
let currentActiveStep = nav.querySelector('.active')

Сначала реализуем переключение между разделами по клику. Для прокрутки к определенному элементу мы воспользуемся глобальным методом scrollIntoView. А находить нужный элемент будем с помощью атрибута data-section:


nav.addEventListener('click', ({ target }) => {
// извлекаем значение атрибута `data-section`
const { section } = target.closest('.step').dataset

if (!section) return

// находим соответствующий элемент и выполняем к нему прокрутку
main.querySelector(`[data-section='${section}']`).scrollIntoView({
// плавно
behavior: 'smooth'
})
})

В принципе, если на странице много разделов, для того, чтобы каждый раз не искать нужный раздел с помощью querySelector, можно предварительно составить карту разделов:


const sectionMap = [...main.querySelectorAll('[data-section]')].reduce(
(obj, el) => {
obj[el.dataset.section] = el
return obj
},
{}
)

И обращаться к соответствующему разделу по ключу:


sectionMap[section].scrollIntoView({
behavior: 'smooth'
})

Это все, что требуется для переключения между разделами. “А как же визуальная составляющая?” — спросите вы. О, это самое интересное. Визуальная составляющая будет полностью инкапсулирована в наблюдателе.


Перебираем разделы:


main.querySelectorAll("section").forEach((s) => {
// ...
}

Из настроек нам требуется только процент пересечения. Установим его в значение 0.3 (30%):


const options = { threshold: 0.3 }

Функция обратного вызова делает следующее:


  • принимает список объектов entries
  • извлекает первый entry
  • проверяет, находится ли он зоне пересечения
  • если находится
    • извлекает номер раздела из его data-section
    • удаляет класс active у текущего активного элемента
    • определяет новый активный элемент
    • добавляет к нему класс active
    • и присваивает новое значение переменной currentActiveStep

const callback = (entries) => {
if (entries[0].isIntersecting) {
const { section } = s.dataset
if (!section) return
currentActiveStep.classList.remove("active")
const newActiveStep = nav.querySelector(
`[data-section='${section}']`
)
newActiveStep.classList.add("active")
currentActiveStep = newActiveStep
}
}

Создаем наблюдателя:


const observer = new IntersectionObserver(callback, options)

И начинаем следить за разделом:


observer.observe(s)

Если все это объединить, то получится следующее:


main.querySelectorAll("section").forEach((s) => {
new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
const { section } = s.dataset
if (!section) return
currentActiveStep.classList.remove("active")
const newActiveStep = nav.querySelector(
`[data-section='${section}']`
)
newActiveStep.classList.add("active")
currentActiveStep = newActiveStep
}
},
{
threshold: 0.3
}
).observe(s)
})

Таким образом, визуальная составляющая полностью инкапсулирована в наблюдателе: колбэк запускается как при "ручной" прокрутке страницы, так и при клике по шагу — в этом случае выполняется автоматическая прокрутка, также приводящая к регистрации пересечения.


Поиграть с этим кодом можно здесь:




Теперь поговорим о ленивой загрузке.


Реализация ленивой загрузки медиаресурсов​


“В чем проблема обычной загрузки медиаресурсов?” — спросите вы. Когда таких ресурсов немного, никакой проблемы нет. Но когда их много и когда компоненты приложения загружаются разом (в приложении не реализован code splitting — разделение кода на уровне компонентов), все ресурсы, используемые этими компонентами, загружаются вместе с ними. Большое количество ресурсов означает "много байт" полезной нагрузки, а много байт полезной нагрузки означает снижение производительности. Следовательно, чем больше весят загружаемые ресурсы, тем хуже производительность приложения.


Как только браузер при разборе разметки встречает атрибут src (например, <img src="https://example.com/some_img.png" alt="" role="presentation" />), он тут же загружает ресурс из указанного в атрибуте источника. Все ресурсы, загружаемые браузером при запуске приложения, можно найти в разделе Network (Сеть) инструментов разработчика (ресурсы можно фильтровать с помощью вкладок JS, CSS, Img и др.). Вот, например, Chrome загрузил аватар для моего Google-аккаунта:


jm6xgtvhf436s5r8dyhodgmdvvw.png


Вы можете спросить: "Неужели браузеры не предоставляют какой-либо нативной технологии для реализации ленивой загрузки медиа?" В действительности, кое-что они для этого предоставляют, но… Конечно, есть но, иначе не было бы этой статьи.


Существует атрибут loading. Если добавить его к изображению или фрейму (iframe) со значением lazy, то они будут загружаться лениво. И все бы ничего, вот только данный атрибут не поддерживается Safari, а там, где поддерживается, срабатывает не всегда корректно, поэтому для продакшна он не подходит.


Загрузкой аудио и видео можно управлять с помощью атрибута preload. Если установить его в значение none, то медиа будет загружаться только после нажатия пользователем кнопки Play. Данный атрибут поддерживается всеми современными браузерами. Но его возможности являются довольно ограниченными.


Все-таки хотелось бы иметь какой-то общий подход к реализации ленивой загрузки медиа. Кроме того, основной вес большинства страниц составляют изображения.


Для того, чтобы избежать загрузки ресурсов при запуске приложения, необходимо избавиться от всех атрибутов src, но при этом иметь возможность загружать ресурсы… при пересечении соответствующих элементов с viewport! Здесь нам снова пригодится атрибут data-*. Заменим src на data-src:


<img data-src="https://images.unsplash.com/photo-1629290211578-213dc1f621e1" alt="" role="presentation" />

Разметка этого приложения будет похожа на разметку предыдущего, за исключением следующего:


  • нам не нужна панель навигации
  • нам хватит 3 разделов
  • между разделами будут находиться медиаресурсы: одно изображение, одно аудио и одно видео.

Вся разметка:



И...


Все стили:


Думаю, вы уже успели составить себе примерное представление о том, как будет выглядеть код нашего скрипта. Для демонстрации дополнительных возможностей IOA в сравнении с preload, предлагаю запускать автоматическое воспроизведение аудио и видео при пересечении ими порогового значения (которое мы также установим в 30%):


// создаем функцию
const initLazyLoading = (root) => {
// находим все "ленивые" элементы
const lazyEls = [...root.querySelectorAll("[data-src]")]

// для каждого из них
lazyEls.forEach((el) => {
// создаем наблюдателя
new IntersectionObserver(
(entries) => {
// аудио или видео?
const audioOrVideo =
el.localName === "audio" || el.localName === "video"
// если целевой элемент находится в зоне пересечения
if (entries[0].isIntersecting) {
// извлекаем `src` из `data-src`
const { src } = el.dataset
if (!src) return
// если у элемента нет атрибута `src`
if (!el.hasAttribute("src")) {
// добавляем ему данный атрибут со значением `data-src`
el.setAttribute("src", src)
// удаляем `data-src`
el.removeAttribute("data-src")
}
if (audioOrVideo) {
// запускаем воспроизведение
el.play()
}
} else {
if (audioOrVideo) {
// останавливаем воспроизведение
el.pause()
}
}
},
{
// пороговое значение
threshold: 0.3
}
// начинаем наблюдать за элементом
).observe(el)
})
}
// вызываем функцию
initLazyLoading(document.querySelector("main"))

Мы не снимаем наблюдение с элементов посредством вызова метода unobserve в колбэке, поскольку хотим запускать и останавливать воспроизведение аудио и видео при выполнении прокрутки страницы в обоих направлениях. Однако благодаря проверке if (!el.hasAttribute("src")) атрибут data-src меняется на атрибут src только один раз.


Поиграть с этим кодом можно здесь:




До тех пор, пока медиа находятся за пределами области просмотра (проигнорируем пороговое значение подобно константе в большом "O"), они загружаться не будут. В этом мы можем убедиться, изучив вкладку Network.


Изображение находится за пределами viewport:


alt0da00o_itrmlpxq8f8h29jig.png


А вот мы до него "докрутили":


1hs8_-cfuaazcgdjfuk9ebby8us.png


При попадании аудио и видео в область просмотра, начинается их автоматическое воспроизведение, которое автоматически останавливается при выходе соответствующих элементов из viewport.


Рекомендую взглянуть на библиотеку vanilla-lazyload. В дополнение к реализованному нами функционалу, она позволяет лениво загружать фоновые изображения, разные медиаресурсы в зависимости от ширины области просмотра (source и srcset) и т.д.


Вывод​


В данной статье мы с вами рассмотрели всего лишь 2 примера практического использования IOA. На самом деле, возможности, предоставляемые данным интерфейсом, намного шире. Парочку идей можно найти в этой статье. А здесь можно почитать о других "наблюдателях".


Надеюсь, вы не зря потратили время и нашли для себя что-то интересное.


Благодарю за внимание и хорошего дня!

 
Сверху