Доброго времени суток, друзья!

Обзор


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

Благодарю за внимание.