
Доброго времени суток, друзья!
Обзор
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). Есть-ли отличия между десктопными и мобильными браузерами. Были-ли у вас такие кейсы на практике?