До появления IntersectionObserver единственным способом отслеживать видимость элементов было слушать событие scroll и вызывать getBoundingClientRect() на каждый элемент. Это происходило в главном потоке, вызывало forced reflow и приводило к лагам. IntersectionObserver — асинхронный API, который работает за пределами главного потока.
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log('Элемент виден:', entry.target)
// Можем прекратить наблюдение
obs.unobserve(entry.target)
}
})
}, {
threshold: 0.5, // срабатывать когда 50% элемента видно
rootMargin: '0px', // без отступов
root: null, // null = viewport
})
observer.observe(document.querySelector('.my-element'))threshold — процент видимости для срабатывания колбэка:
0 — как только хотя бы пиксель попал во viewport0.5 — когда видно 50% элемента1.0 — когда элемент полностью видим[0, 0.25, 0.5, 0.75, 1] — срабатывать на каждом из этих пороговrootMargin — смещение viewport (как CSS margin): "200px 0px" — расширяет зону обнаружения на 200px сверху и снизу. Полезно для preload: начинаем загрузку до того, как элемент появился на экране.
root — элемент-контейнер вместо viewport. Например, для отслеживания видимости внутри скролящегося списка.
entry.isIntersecting // true если элемент виден
entry.intersectionRatio // доля видимой части (0.0 - 1.0)
entry.target // наблюдаемый элемент
entry.boundingClientRect // размеры элемента
entry.intersectionRect // видимая часть элемента
entry.rootBounds // размеры viewport/root
entry.time // время события (DOMHighResTimeStamp)const imgObserver = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target
img.src = img.dataset.src
img.classList.add('loaded')
obs.unobserve(img)
}
})
}, { rootMargin: '300px' })
document.querySelectorAll('img[data-src]').forEach(img => {
imgObserver.observe(img)
})const sentinel = document.querySelector('#load-more')
const loadObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMoreItems()
}
})
loadObserver.observe(sentinel)const animObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
entry.target.classList.toggle('visible', entry.isIntersecting)
})
}, { threshold: 0.1 })
document.querySelectorAll('.animate-on-scroll').forEach(el => {
animObserver.observe(el)
})Можно отслеживать сколько времени элемент был виден — для измерения реального просмотра рекламы или контента:
let visibleStart = null
const viewabilityObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
visibleStart = entry.time
} else if (visibleStart) {
const visibleFor = entry.time - visibleStart
console.log(`Элемент был виден ${visibleFor.toFixed(0)} мс`)
visibleStart = null
}
})
}, { threshold: 0.5 })IntersectionObserver работает в отдельном потоке и не вызывает forced layout. Колбэки вызываются асинхронно — не на каждый пиксель прокрутки, а когда браузер готов. Это принципиальное отличие от обработчика scroll с getBoundingClientRect().
Симуляция IntersectionObserver с отслеживанием позиций элементов и порогами видимости
// Симулируем IntersectionObserver без браузера
class VirtualElement {
constructor(id, top, height) {
this.id = id
this.top = top // позиция верхнего края в px
this.height = height // высота элемента
this.dataset = {}
}
get bottom() { return this.top + this.height }
toString() { return `#${this.id}[top:${this.top}-${this.bottom}]` }
}
class SimulatedIntersectionObserver {
constructor(callback, options = {}) {
this._callback = callback
this._threshold = options.threshold ?? 0
this._rootMargin = options.rootMargin ?? 0 // упрощённо — px сверху/снизу
this._elements = new Map() // element → lastRatio
}
observe(element) {
this._elements.set(element, 0)
console.log(`[IO] Наблюдаем за ${element}`)
}
unobserve(element) {
this._elements.delete(element)
console.log(`[IO] Прекратили наблюдение за ${element}`)
}
disconnect() {
this._elements.clear()
console.log('[IO] Отключён')
}
// Симулируем обновление viewport
updateViewport(viewportTop, viewportHeight) {
const viewportBottom = viewportTop + viewportHeight
const expandedTop = viewportTop - this._rootMargin
const expandedBottom = viewportBottom + this._rootMargin
const entries = []
for (const [element, lastRatio] of this._elements) {
// Вычисляем пересечение
const overlapTop = Math.max(element.top, expandedTop)
const overlapBottom = Math.min(element.bottom, expandedBottom)
const overlapHeight = Math.max(0, overlapBottom - overlapTop)
const ratio = element.height > 0 ? overlapHeight / element.height : 0
// Срабатываем если пересекли порог
const wasIntersecting = lastRatio >= this._threshold
const isIntersecting = ratio >= this._threshold
if (wasIntersecting !== isIntersecting || ratio !== lastRatio) {
entries.push({
target: element,
isIntersecting,
intersectionRatio: ratio,
boundingClientRect: { top: element.top - viewportTop, height: element.height },
})
this._elements.set(element, ratio)
}
}
if (entries.length > 0) {
this._callback(entries, this)
}
}
}
// Создаём виртуальные элементы на странице
const elements = [
new VirtualElement('hero', 0, 400),
new VirtualElement('features', 450, 300),
new VirtualElement('pricing', 800, 250),
new VirtualElement('testimonials', 1100, 350),
new VirtualElement('footer', 1500, 200),
]
// Настраиваем наблюдатель с rootMargin для preload
const observer = new SimulatedIntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log(`Виден: ${entry.target} (ratio: ${entry.intersectionRatio.toFixed(2)})`)
} else {
console.log(`Скрыт: ${entry.target}`)
}
})
},
{ threshold: 0.1, rootMargin: 50 }
)
elements.forEach(el => observer.observe(el))
// Симулируем прокрутку
console.log('\n=== Viewport: 0-600px (первый экран) ===')
observer.updateViewport(0, 600)
console.log('\n=== Прокрутили до 400px ===')
observer.updateViewport(400, 600)
console.log('\n=== Прокрутили до 900px ===')
observer.updateViewport(900, 600)До появления IntersectionObserver единственным способом отслеживать видимость элементов было слушать событие scroll и вызывать getBoundingClientRect() на каждый элемент. Это происходило в главном потоке, вызывало forced reflow и приводило к лагам. IntersectionObserver — асинхронный API, который работает за пределами главного потока.
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log('Элемент виден:', entry.target)
// Можем прекратить наблюдение
obs.unobserve(entry.target)
}
})
}, {
threshold: 0.5, // срабатывать когда 50% элемента видно
rootMargin: '0px', // без отступов
root: null, // null = viewport
})
observer.observe(document.querySelector('.my-element'))threshold — процент видимости для срабатывания колбэка:
0 — как только хотя бы пиксель попал во viewport0.5 — когда видно 50% элемента1.0 — когда элемент полностью видим[0, 0.25, 0.5, 0.75, 1] — срабатывать на каждом из этих пороговrootMargin — смещение viewport (как CSS margin): "200px 0px" — расширяет зону обнаружения на 200px сверху и снизу. Полезно для preload: начинаем загрузку до того, как элемент появился на экране.
root — элемент-контейнер вместо viewport. Например, для отслеживания видимости внутри скролящегося списка.
entry.isIntersecting // true если элемент виден
entry.intersectionRatio // доля видимой части (0.0 - 1.0)
entry.target // наблюдаемый элемент
entry.boundingClientRect // размеры элемента
entry.intersectionRect // видимая часть элемента
entry.rootBounds // размеры viewport/root
entry.time // время события (DOMHighResTimeStamp)const imgObserver = new IntersectionObserver((entries, obs) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target
img.src = img.dataset.src
img.classList.add('loaded')
obs.unobserve(img)
}
})
}, { rootMargin: '300px' })
document.querySelectorAll('img[data-src]').forEach(img => {
imgObserver.observe(img)
})const sentinel = document.querySelector('#load-more')
const loadObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMoreItems()
}
})
loadObserver.observe(sentinel)const animObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
entry.target.classList.toggle('visible', entry.isIntersecting)
})
}, { threshold: 0.1 })
document.querySelectorAll('.animate-on-scroll').forEach(el => {
animObserver.observe(el)
})Можно отслеживать сколько времени элемент был виден — для измерения реального просмотра рекламы или контента:
let visibleStart = null
const viewabilityObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
visibleStart = entry.time
} else if (visibleStart) {
const visibleFor = entry.time - visibleStart
console.log(`Элемент был виден ${visibleFor.toFixed(0)} мс`)
visibleStart = null
}
})
}, { threshold: 0.5 })IntersectionObserver работает в отдельном потоке и не вызывает forced layout. Колбэки вызываются асинхронно — не на каждый пиксель прокрутки, а когда браузер готов. Это принципиальное отличие от обработчика scroll с getBoundingClientRect().
Симуляция IntersectionObserver с отслеживанием позиций элементов и порогами видимости
// Симулируем IntersectionObserver без браузера
class VirtualElement {
constructor(id, top, height) {
this.id = id
this.top = top // позиция верхнего края в px
this.height = height // высота элемента
this.dataset = {}
}
get bottom() { return this.top + this.height }
toString() { return `#${this.id}[top:${this.top}-${this.bottom}]` }
}
class SimulatedIntersectionObserver {
constructor(callback, options = {}) {
this._callback = callback
this._threshold = options.threshold ?? 0
this._rootMargin = options.rootMargin ?? 0 // упрощённо — px сверху/снизу
this._elements = new Map() // element → lastRatio
}
observe(element) {
this._elements.set(element, 0)
console.log(`[IO] Наблюдаем за ${element}`)
}
unobserve(element) {
this._elements.delete(element)
console.log(`[IO] Прекратили наблюдение за ${element}`)
}
disconnect() {
this._elements.clear()
console.log('[IO] Отключён')
}
// Симулируем обновление viewport
updateViewport(viewportTop, viewportHeight) {
const viewportBottom = viewportTop + viewportHeight
const expandedTop = viewportTop - this._rootMargin
const expandedBottom = viewportBottom + this._rootMargin
const entries = []
for (const [element, lastRatio] of this._elements) {
// Вычисляем пересечение
const overlapTop = Math.max(element.top, expandedTop)
const overlapBottom = Math.min(element.bottom, expandedBottom)
const overlapHeight = Math.max(0, overlapBottom - overlapTop)
const ratio = element.height > 0 ? overlapHeight / element.height : 0
// Срабатываем если пересекли порог
const wasIntersecting = lastRatio >= this._threshold
const isIntersecting = ratio >= this._threshold
if (wasIntersecting !== isIntersecting || ratio !== lastRatio) {
entries.push({
target: element,
isIntersecting,
intersectionRatio: ratio,
boundingClientRect: { top: element.top - viewportTop, height: element.height },
})
this._elements.set(element, ratio)
}
}
if (entries.length > 0) {
this._callback(entries, this)
}
}
}
// Создаём виртуальные элементы на странице
const elements = [
new VirtualElement('hero', 0, 400),
new VirtualElement('features', 450, 300),
new VirtualElement('pricing', 800, 250),
new VirtualElement('testimonials', 1100, 350),
new VirtualElement('footer', 1500, 200),
]
// Настраиваем наблюдатель с rootMargin для preload
const observer = new SimulatedIntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log(`Виден: ${entry.target} (ratio: ${entry.intersectionRatio.toFixed(2)})`)
} else {
console.log(`Скрыт: ${entry.target}`)
}
})
},
{ threshold: 0.1, rootMargin: 50 }
)
elements.forEach(el => observer.observe(el))
// Симулируем прокрутку
console.log('\n=== Viewport: 0-600px (первый экран) ===')
observer.updateViewport(0, 600)
console.log('\n=== Прокрутили до 400px ===')
observer.updateViewport(400, 600)
console.log('\n=== Прокрутили до 900px ===')
observer.updateViewport(900, 600)Реализуй createLazyLoader(threshold = 0.1) — систему ленивой загрузки с методами: observe(element, loadFn) начинает отслеживать элемент, unobserve(element) прекращает отслеживание, simulateScroll(viewportTop, viewportBottom) вычисляет видимость каждого наблюдаемого элемента и вызывает loadFn когда элемент входит во viewport (только один раз). Каждый элемент имеет поля top, bottom, id.
ratio вычисляется как overlapHeight / elementHeight. Сравнивай с threshold параметром. state.loaded = true предотвращает повторный вызов loadFn. Для Map итерации используй for...of с деструктуризацией [element, state].