← Браузер/Intersection Observer: lazy loading и анимации при скролле#189 из 383← ПредыдущийСледующий →+20 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: async и сетьТермин: Event LoopТермин: Core Web Vitals

Intersection Observer: lazy loading и анимации при скролле

До появления 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 — как только хотя бы пиксель попал во viewport
  • 0.5 — когда видно 50% элемента
  • 1.0 — когда элемент полностью видим
  • [0, 0.25, 0.5, 0.75, 1] — срабатывать на каждом из этих порогов
  • rootMargin — смещение viewport (как CSS margin): "200px 0px" — расширяет зону обнаружения на 200px сверху и снизу. Полезно для preload: начинаем загрузку до того, как элемент появился на экране.

    root — элемент-контейнер вместо viewport. Например, для отслеживания видимости внутри скролящегося списка.

    Поля IntersectionObserverEntry

    entry.isIntersecting   // true если элемент виден
    entry.intersectionRatio  // доля видимой части (0.0 - 1.0)
    entry.target           // наблюдаемый элемент
    entry.boundingClientRect  // размеры элемента
    entry.intersectionRect    // видимая часть элемента
    entry.rootBounds       // размеры viewport/root
    entry.time             // время события (DOMHighResTimeStamp)

    Lazy Loading изображений

    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)

    Intersection Observer: lazy loading и анимации при скролле

    До появления 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 — как только хотя бы пиксель попал во viewport
  • 0.5 — когда видно 50% элемента
  • 1.0 — когда элемент полностью видим
  • [0, 0.25, 0.5, 0.75, 1] — срабатывать на каждом из этих порогов
  • rootMargin — смещение viewport (как CSS margin): "200px 0px" — расширяет зону обнаружения на 200px сверху и снизу. Полезно для preload: начинаем загрузку до того, как элемент появился на экране.

    root — элемент-контейнер вместо viewport. Например, для отслеживания видимости внутри скролящегося списка.

    Поля IntersectionObserverEntry

    entry.isIntersecting   // true если элемент виден
    entry.intersectionRatio  // доля видимой части (0.0 - 1.0)
    entry.target           // наблюдаемый элемент
    entry.boundingClientRect  // размеры элемента
    entry.intersectionRect    // видимая часть элемента
    entry.rootBounds       // размеры viewport/root
    entry.time             // время события (DOMHighResTimeStamp)

    Lazy Loading изображений

    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].

    Загружаем среду выполнения...
    Загружаем AI-помощника...