Доброго времени суток, друзья!
Обзор
Intersection Observer API (IOA) позволяет приложению асинхронно наблюдать за пересечением элемента (target) с его родителем (root) или областью просмотра (viewport). Другими словами, этот API обеспечивает вызов определенной функции каждый раз при пересечении целевого элемента с root или viewport.
Примеры использования:
- «ленивая» или отложенная загрузка изображений
- бесконечная прокрутка страницы
- получение информации о видимости рекламы для целей расчета стоимости показов
- запуск процесса или анимации, находящихся в поле зрения пользователя
Для начала работы с IOA необходимо с помощью конструктора создать объект-наблюдатель с двумя параметрами — функцией обратного вызова и настройками:
// настройки
let options = {
root: document.querySelector('.scroll-list'),
rootMargin: '5px',
threshold: 0.5
}
// функция обратного вызова
let callback = function(entries, observer){
...
}
// наблюдатель
let observer = new IntersectionObserver(callback, options)
Настройки:
- root — элемент, который выступает в роли области просмотра для target (предок целевого элемента или null для viewport)
- rootMargin — отступы вокруг root (margin в CSS, по умолчанию все отступы равны 0)
- threshold — число или массив чисел, указывающий допустимый процент пересечения target и root
Далее создается целевой элемент, за которым наблюдает observer:
let target = document.querySelector('.list-item')
observer.observe(target)
Вызов callback возвращает объект, содержащий записи об изменениях, произошедших с целевым элементом:
let callback = (entries, observer) => {
entries.forEach(entry => {
// entry (запись) - изменение
// entry.boundingClientRect
// entry.intersectionRatio
// entry.intersectionRect
// entry.isIntersecting
// entry.rootBounds
// entry.target
// entry.time
})
}
В сети полно информации по теории, но довольно мало материалов по практике использования IOA. Я решил немного восполнить этот пробел.
Примеры
«Ленивая» (отложенная) загрузка изображений
Задача: загружать (показывать) изображения по мере прокрутки страницы пользователем.
Код:
// ждем полной загрузки страницы
window.onload = () => {
// устанавливаем настройки
const options = {
// родитель целевого элемента - область просмотра
root: null,
// без отступов
rootMargin: '0px',
// процент пересечения - половина изображения
threshold: 0.5
}
// создаем наблюдатель
const observer = new IntersectionObserver((entries, observer) => {
// для каждой записи-целевого элемента
entries.forEach(entry => {
// если элемент является наблюдаемым
if (entry.isIntersecting) {
const lazyImg = entry.target
// выводим информацию в консоль - проверка работоспособности наблюдателя
console.log(lazyImg)
// меняем фон контейнера
lazyImg.style.background = 'deepskyblue'
// прекращаем наблюдение
observer.unobserve(lazyImg)
}
})
}, options)
// с помощью цикла следим за всеми img на странице
const arr = document.querySelectorAll('img')
arr.forEach(i => {
observer.observe(i)
})
}
Результат:
Фон контейнера, выходящего за пределы области просмотра, белый.
При пересечении с областью просмотра наполовину, фон меняется на небесно-голубой.
> Codepen
> Github
Замена изображения
Задача: менять изображение-заполнитель на оригинальное при прокрутке страницы пользователем.
Код:
window.onload = () => {
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log(entry)
// ссылка на оригинальное изображение хранится в атрибуте "data-src"
entry.target.src = entry.target.dataset.src
observer.unobserve(entry.target)
}
})
}, { threshold: 0.5 })
document.querySelectorAll('img').forEach(img => observer.observe(img))
}
Результат:
Первое изображение загружено, поскольку находится в области просмотра. Второе — заполнитель.
При дальнейшей прокрутке заполнитель заменяется исходным изображением.
> Codepen
> Github
Изменение фона контейнера
Задача: менять фон контейнера при прокрутке страницы пользователем туда и обратно.
Код:
window.addEventListener('load', event => {
let box = document.querySelector('div')
// ratio - процент видимости элемента
let prevRatio = 0.0
let observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
let curRatio = entry.intersectionRatio
// при прокрутке цвет меняется от светло-синего до светло-красного
// мы хотим наблюдать эффект при прокрутке страницы в обе стороны (вниз и вверх)
// поэтому наблюдение за элементом не прекращается
curRatio > prevRatio ? entry.target.style.background = `rgba(40,40,190,${curRatio})` : entry.target.style.background = `rgba(190,40,40,${curRatio})`
prevRatio = curRatio
})
}, {
threshold: buildThresholdList()
})
observer.observe(box)
// функция построения шкалы пересечения
// шкала представляет собой массив из 20 элементов, определяющих цвет контейнера
function buildThresholdList() {
let thresholds = []
let steps = 20
for (let i = 1.0; i <= steps; i++) {
let ratio = i / steps
thresholds.push(ratio)
}
return thresholds
}
})
Результат:
Фон контейнера меняется от светло-синего…
через синий…
до светло-красного.
> Codepen
> Github
Работа с видео
Задача: ставить запущенное видео на паузу и запускать его снова в зависимости от попадания видео в область просмотра.
Код:
window.onload = () => {
let video = document.querySelector('video')
let observer = new IntersectionObserver(() => {
// если видео запущено
if (!video.paused) {
// приостанавливаем проигрывание
video.pause()
// если видео было запущено ранее (текущее время проигрывания > 0)
} else if(video.currentTime != 0) {
// продолжаем проигрывание
video.play()
}
}, { threshold: 0.4 })
observer.observe(video)
}
Результат:
Пока видео находится в области просмотра, оно проигрывается.
Как только видео выходит за пределы области просмотра больше чем на 40%, его воспроизведение приостанавливается. При попадании в область просмотра > 40% видео, его воспроизведение возобновляется.
> Codepen
> Github
Прогресс просмотра страницы
Задача: показывать прогресс просмотра страницы по мере прокрутки страницы пользователем.
Код:
// страница состоит из нескольких контейнеров и параграфа для вывода прогресса
let p = document.querySelector('p')
// n - количество просмотренных контейнеров
let n = 0
let observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if(entry.isIntersecting){
// observer наблюдает за div
// и сообщает об увеличении количества просмотренных контейнеров
// выводим эту информацию в параграф
p.textContent = `${n++} div viewed`
observer.unobserve(entry.target)
}
})
}, {threshold: 0.9})
document.querySelectorAll('div').forEach(div => observer.observe(div))
Результат:
Страница только что загрузилась, поэтому мы еще не просмотрели ни одного контейнера.
При достижении конца страницы в параграф выводится информация о просмотре 4 div.
> Codepen
> Github
Бесконечная прокрутка
Задача: реализовать бесконечный список.
Код:
let ul = document.querySelector('ul')
let n = 1
// функция создания элемента списка
function createLi(){
li = document.createElement('li')
li.innerHTML = `${++n} item`
ul.append(li)
}
// для того, чтобы все время наблюдать за последним элементом списка
// мы используем нечто вроде замыкания
// прекращаем наблюдать за целевым элементом после создания очередного li
// и начинаем наблюдать за этим новым (последним) элементом
let observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
createLi()
}
observer.unobserve(entry.target)
observer.observe(document.querySelector('li:last-child'))
})
}, {
threshold: 1
})
observer.observe(document.querySelector('li'))
Результат:
Имеем 12 элементов списка. Последний элемент выходит за пределы области просмотра.
При попытке добраться до последнего элемента создается новый (последний) элемент, скрытый от пользователя. И так до бесконечности.
> Codepen
> Github
Изменение размеров дочернего элемента при изменении размеров родительского элемента
Задача: установить зависимость размеров одного элемента от другого.
Код:
// у нас есть два контейнера - родитель и ребенок
// и параграф для вывода ширины ребенка
let info = document.querySelector('.info')
let parent = document.querySelector('.parent')
let child = document.querySelector('.child')
// отнимаем от ширины ребенка 50px для наглядности
child.style.width = parent.offsetWidth - 50 + 'px'
// выводим ширину ребенка в параграф
info.textContent = `child width: ${child.offsetWidth}px`
let options = {
// областью просмотра для ребенка является родитель
root: parent,
threshold: 1
}
let observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
// если расстояние между родителем и ребенком составляет меньше 50px
if ((entry.target.parentElement.offsetWidth - entry.target.offsetWidth) < 50) {
// уменьшаем ширину ребенка на 50px
entry.target.style.width = entry.target.offsetWidth - 50 + 'px'
}
})
}, options)
observer.observe(child)
// к сожалению, не додумался, как реализовать обратный эффект с помощью IOA
// поэтому реализовал его с помощью обработки resize
window.addEventListener('resize', () => {
info.textContent = `child width: ${child.offsetWidth}px`
if ((parent.offsetWidth - child.offsetWidth) > 51) {
child.style.width = child.offsetWidth + 50 + 'px'
}
})
Результат:
Исходное состояние.
При уменьшении ширины родительского элемента, уменьшается ширина дочернего элемента. При этом расстояние между ними почти всегда равняется 50px («почти» обусловлено реализацией обратного механизма).
> Codepen
> Github
Работа с анимацией
Задача: анимировать объект при его видимости.
Код:
// у нас есть Барт Симпсон и две анимации
// одна анимация перемещает Барта влево
// другая - вправо
let observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
entry.isIntersecting ? entry.target.classList.replace('to-left', 'to-right') : entry.target.classList.replace('to-right', 'to-left')
})
}, {
threshold: .5
})
observer.observe(document.querySelector('img'))
Результат:
Мы видим часть головы Барта. Барт прижался к левой стороне области просмотра.
При попадании более 50% Барта в область просмотра, он перемещается на середину. При выходе более 50% Барта за пределы области просмотра, он возвращается в начальное положение.
> Codepen
> Github
Благодарю за внимание.
funca
Насколько наблюдение затратно по ресурсам? Мне интересно, влияет-ли количество наблюдаемых элементов на отзывчивость страницы (что если их не 4, а 4000 или 40000). Есть-ли отличия между десктопными и мобильными браузерами. Были-ли у вас такие кейсы на практике?