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

Прокрутка страницы

Представь: ты разрабатываешь лендинг с анимациями появления секций и кнопкой «вернуться наверх». Обе функции требуют знания текущей позиции прокрутки. Но обработчик scroll срабатывает 60 раз в секунду — если делать тяжёлую работу на каждое событие, страница начнёт тормозить. Нужен правильный подход.

Что решает этот механизм

window.scrollY даёт текущую позицию, scrollTo/scrollBy — управление, событие scroll — реакцию на действия пользователя. IntersectionObserver — современная альтернатива, которая не нагружает главный поток.

На основе предыдущих уроков

  • размеры элементов — scrollHeight, clientHeight, scrollTop работают так же для window
  • addEventListener — событие scroll использует те же паттерны подписки
  • throttle/debounce — оптимизация обработчика scroll через throttle
  • Чтение текущей позиции прокрутки

    // Текущая прокрутка страницы (в пикселях)
    console.log(window.scrollY)  // вертикальная (сверху)
    console.log(window.scrollX)  // горизонтальная (слева)
    
    // Устаревшие синонимы (избегайте):
    // window.pageYOffset === window.scrollY
    // window.pageXOffset === window.scrollX

    Управление прокруткой

    // Прокрутить к конкретной позиции
    window.scrollTo(0, 500)      // x=0, y=500 (мгновенно)
    window.scrollTo({ top: 500, left: 0, behavior: 'smooth' })  // плавно
    
    // Прокрутить на заданное смещение от текущей позиции
    window.scrollBy(0, 200)      // прокрутить вниз ещё на 200px
    window.scrollBy({ top: -300, behavior: 'smooth' })  // плавно вверх
    
    // Прокрутить к началу страницы
    window.scrollTo({ top: 0, behavior: 'smooth' })

    Событие scroll и троттлинг

    Событие scroll срабатывает очень часто — иногда 60 раз в секунду. Обработчик должен быть лёгким, иначе страница начнёт «лагать».

    // ПЛОХО: тяжёлая работа на каждое событие
    window.addEventListener('scroll', () => {
      recalculateEverything()  // вызывается 60 раз/с — тормозит!
    })
    
    // ХОРОШО: через requestAnimationFrame (синхронизация с кадрами)
    let ticking = false
    window.addEventListener('scroll', () => {
      if (!ticking) {
        requestAnimationFrame(() => {
          updateUI(window.scrollY)
          ticking = false
        })
        ticking = true
      }
    })
    
    // ХОРОШО: throttle через setTimeout
    let lastCall = 0
    window.addEventListener('scroll', () => {
      const now = Date.now()
      if (now - lastCall >= 100) {  // не чаще раз в 100мс
        lastCall = now
        updateUI(window.scrollY)
      }
    })

    Кнопка «наверх»

    Классический пример использования scroll: показывать кнопку «Вернуться наверх» когда пользователь прокрутил достаточно далеко.

    const backToTopBtn = document.getElementById('back-to-top')
    
    window.addEventListener('scroll', () => {
      // Показываем кнопку после 300px прокрутки
      if (window.scrollY > 300) {
        backToTopBtn.style.display = 'block'
      } else {
        backToTopBtn.style.display = 'none'
      }
    })
    
    backToTopBtn.addEventListener('click', () => {
      window.scrollTo({ top: 0, behavior: 'smooth' })
    })

    IntersectionObserver — современная альтернатива

    Вместо проверки scrollY на каждое событие можно использовать IntersectionObserver — он уведомляет когда элемент входит или выходит из видимой области:

    // Создаём наблюдатель
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          // Элемент виден — запустить анимацию
          entry.target.classList.add('visible')
          observer.unobserve(entry.target)  // один раз
        }
      })
    }, {
      threshold: 0.1,  // срабатывает когда 10% элемента видно
      rootMargin: '0px 0px -50px 0px',  // уменьшить зону снизу
    })
    
    // Начать наблюдение
    document.querySelectorAll('.fade-in').forEach(el => observer.observe(el))

    Преимущества IntersectionObserver: не нагружает главный поток, браузер сам оптимизирует проверки, работает для элементов внутри прокручиваемых контейнеров.

    Отключение прокрутки

    // Заблокировать прокрутку (например, при открытом модальном окне)
    document.body.style.overflow = 'hidden'
    
    // Восстановить прокрутку
    document.body.style.overflow = ''

    Типичные ошибки

    1. Тяжёлая работа на каждое событие scroll без throttle

    // ПЛОХО — вызывается 60 раз/с, дорогие вычисления тормозят страницу
    window.addEventListener('scroll', () => {
      const elements = document.querySelectorAll('.animated')  // дорого!
      elements.forEach(el => {
        const rect = el.getBoundingClientRect()  // reflow на каждый элемент!
        if (rect.top < window.innerHeight) el.classList.add('visible')
      })
    })
    
    // ХОРОШО — IntersectionObserver или throttle
    const observer = new IntersectionObserver(entries => {
      entries.forEach(e => { if (e.isIntersecting) e.target.classList.add('visible') })
    })
    document.querySelectorAll('.animated').forEach(el => observer.observe(el))

    2. Не восстанавливать overflow при закрытии модального окна

    // ПЛОХО — прокрутка заблокирована навсегда
    function openModal() {
      document.body.style.overflow = 'hidden'
    }
    // closeModal не восстанавливает overflow!
    
    // ХОРОШО — симметричная блокировка и восстановление
    function openModal() {
      document.body.style.overflow = 'hidden'
    }
    function closeModal() {
      document.body.style.overflow = ''
    }

    3. Читать window.scrollY в обработчике scroll синхронно — вызывает reflow

    // ПЛОХО — requestAnimationFrame синхронизирует с кадром браузера
    window.addEventListener('scroll', () => {
      // scrollY здесь безопасен, но тяжёлый layout-запрос (getBoundingClientRect) — нет
      updateParallax(window.scrollY)  // если updateParallax делает reflow — лаг
    })
    
    // ХОРОШО — через rAF
    let ticking = false
    window.addEventListener('scroll', () => {
      if (!ticking) {
        requestAnimationFrame(() => {
          updateParallax(window.scrollY)
          ticking = false
        })
        ticking = true
      }
    })

    В реальных проектах

  • Кнопка «наверх»: присутствует на Habr, VC.ru, Wikipedia — появляется после прокрутки вниз на определённое расстояние
  • Прогресс чтения: Medium показывает полосу прогресса в заголовке — рассчитывается через scrollY / (scrollHeight - viewportHeight)
  • Параллакс: Hero-секции на лендингах двигаются медленнее фона — background-position обновляется в обработчике scroll с rAF
  • IntersectionObserver: lazy-loading изображений в Instagram, ВКонтакте — изображение загружается когда входит в viewport
  • Примеры

    Симуляция прокрутки: кнопка «наверх», throttle обработчика и отслеживание прогресса чтения

    // Симуляция логики прокрутки без реального DOM
    // Все вычисления работают с числами — точно как в браузере
    
    // --- Утилита throttle ---
    function throttle(fn, limitMs) {
      let lastCall = 0
      return function(...args) {
        const now = Date.now()
        if (now - lastCall >= limitMs) {
          lastCall = now
          return fn.apply(this, args)
        }
      }
    }
    
    // --- Логика кнопки "наверх" ---
    function shouldShowBackToTop(scrollY, threshold = 300) {
      return scrollY > threshold
    }
    
    // --- Прогресс чтения страницы ---
    function getReadingProgress(scrollY, documentHeight, viewportHeight) {
      const maxScroll = documentHeight - viewportHeight
      if (maxScroll <= 0) return 100
      return Math.min(100, Math.round((scrollY / maxScroll) * 100))
    }
    
    // --- Симуляция прокрутки страницы ---
    console.log('=== Симуляция прокрутки ===')
    
    const PAGE = { documentHeight: 5000, viewportHeight: 800 }
    
    const scrollPositions = [0, 100, 300, 301, 500, 1200, 2400, 4200]
    
    scrollPositions.forEach(y => {
      const showBtn = shouldShowBackToTop(y)
      const progress = getReadingProgress(y, PAGE.documentHeight, PAGE.viewportHeight)
      console.log(`scrollY=${String(y).padStart(4)}: кнопка=${showBtn ? 'видна ' : 'скрыта'}, прогресс=${String(progress).padStart(3)}%`)
    })
    
    // --- Throttle демо ---
    console.log('\n=== Throttle обработчика scroll ===')
    
    let handlerCallCount = 0
    let throttledCallCount = 0
    
    const rawHandler = () => { handlerCallCount++ }
    const throttledHandler = throttle(() => { throttledCallCount++ }, 100)
    
    // Симулируем 20 событий scroll за 200мс
    const startTime = Date.now()
    for (let i = 0; i < 20; i++) {
      rawHandler()
      throttledHandler()
    }
    
    console.log(`Событий scroll: 20`)
    console.log(`Вызовов без throttle: ${handlerCallCount}`)   // 20
    console.log(`Вызовов с throttle: ${throttledCallCount}`)   // меньше
    
    // --- IntersectionObserver симуляция ---
    console.log('\n=== IntersectionObserver симуляция ===')
    
    function createMockObserver(callback, options = {}) {
      const threshold = options.threshold ?? 0
      const observed = []
    
      return {
        observe(element) {
          observed.push(element)
        },
        unobserve(element) {
          const idx = observed.indexOf(element)
          if (idx !== -1) observed.splice(idx, 1)
        },
        // Симулируем проверку видимости
        checkVisibility(scrollY, viewportHeight) {
          observed.forEach(el => {
            const visibleTop    = scrollY
            const visibleBottom = scrollY + viewportHeight
            const isIntersecting = el.offsetTop < visibleBottom && (el.offsetTop + el.height) > visibleTop
            if (isIntersecting) {
              callback([{ target: el, isIntersecting: true }])
            }
          })
        },
      }
    }
    
    const mockElements = [
      { id: 'hero',    offsetTop: 0,    height: 600 },
      { id: 'section1', offsetTop: 700,  height: 400 },
      { id: 'section2', offsetTop: 1200, height: 400 },
      { id: 'footer',  offsetTop: 4600, height: 400 },
    ]
    
    const animatedElements = new Set()
    const observer = createMockObserver((entries) => {
      entries.forEach(({ target }) => {
        if (!animatedElements.has(target.id)) {
          animatedElements.add(target.id)
          console.log(`Элемент "${target.id}" стал видимым — запускаем анимацию`)
        }
      })
    })
    
    mockElements.forEach(el => observer.observe(el))
    
    // Симулируем прокрутку
    console.log('scrollY=0:')
    observer.checkVisibility(0, 800)
    
    console.log('scrollY=700:')
    observer.checkVisibility(700, 800)
    
    console.log('scrollY=4200:')
    observer.checkVisibility(4200, 800)

    Прокрутка страницы

    Представь: ты разрабатываешь лендинг с анимациями появления секций и кнопкой «вернуться наверх». Обе функции требуют знания текущей позиции прокрутки. Но обработчик scroll срабатывает 60 раз в секунду — если делать тяжёлую работу на каждое событие, страница начнёт тормозить. Нужен правильный подход.

    Что решает этот механизм

    window.scrollY даёт текущую позицию, scrollTo/scrollBy — управление, событие scroll — реакцию на действия пользователя. IntersectionObserver — современная альтернатива, которая не нагружает главный поток.

    На основе предыдущих уроков

  • размеры элементов — scrollHeight, clientHeight, scrollTop работают так же для window
  • addEventListener — событие scroll использует те же паттерны подписки
  • throttle/debounce — оптимизация обработчика scroll через throttle
  • Чтение текущей позиции прокрутки

    // Текущая прокрутка страницы (в пикселях)
    console.log(window.scrollY)  // вертикальная (сверху)
    console.log(window.scrollX)  // горизонтальная (слева)
    
    // Устаревшие синонимы (избегайте):
    // window.pageYOffset === window.scrollY
    // window.pageXOffset === window.scrollX

    Управление прокруткой

    // Прокрутить к конкретной позиции
    window.scrollTo(0, 500)      // x=0, y=500 (мгновенно)
    window.scrollTo({ top: 500, left: 0, behavior: 'smooth' })  // плавно
    
    // Прокрутить на заданное смещение от текущей позиции
    window.scrollBy(0, 200)      // прокрутить вниз ещё на 200px
    window.scrollBy({ top: -300, behavior: 'smooth' })  // плавно вверх
    
    // Прокрутить к началу страницы
    window.scrollTo({ top: 0, behavior: 'smooth' })

    Событие scroll и троттлинг

    Событие scroll срабатывает очень часто — иногда 60 раз в секунду. Обработчик должен быть лёгким, иначе страница начнёт «лагать».

    // ПЛОХО: тяжёлая работа на каждое событие
    window.addEventListener('scroll', () => {
      recalculateEverything()  // вызывается 60 раз/с — тормозит!
    })
    
    // ХОРОШО: через requestAnimationFrame (синхронизация с кадрами)
    let ticking = false
    window.addEventListener('scroll', () => {
      if (!ticking) {
        requestAnimationFrame(() => {
          updateUI(window.scrollY)
          ticking = false
        })
        ticking = true
      }
    })
    
    // ХОРОШО: throttle через setTimeout
    let lastCall = 0
    window.addEventListener('scroll', () => {
      const now = Date.now()
      if (now - lastCall >= 100) {  // не чаще раз в 100мс
        lastCall = now
        updateUI(window.scrollY)
      }
    })

    Кнопка «наверх»

    Классический пример использования scroll: показывать кнопку «Вернуться наверх» когда пользователь прокрутил достаточно далеко.

    const backToTopBtn = document.getElementById('back-to-top')
    
    window.addEventListener('scroll', () => {
      // Показываем кнопку после 300px прокрутки
      if (window.scrollY > 300) {
        backToTopBtn.style.display = 'block'
      } else {
        backToTopBtn.style.display = 'none'
      }
    })
    
    backToTopBtn.addEventListener('click', () => {
      window.scrollTo({ top: 0, behavior: 'smooth' })
    })

    IntersectionObserver — современная альтернатива

    Вместо проверки scrollY на каждое событие можно использовать IntersectionObserver — он уведомляет когда элемент входит или выходит из видимой области:

    // Создаём наблюдатель
    const observer = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          // Элемент виден — запустить анимацию
          entry.target.classList.add('visible')
          observer.unobserve(entry.target)  // один раз
        }
      })
    }, {
      threshold: 0.1,  // срабатывает когда 10% элемента видно
      rootMargin: '0px 0px -50px 0px',  // уменьшить зону снизу
    })
    
    // Начать наблюдение
    document.querySelectorAll('.fade-in').forEach(el => observer.observe(el))

    Преимущества IntersectionObserver: не нагружает главный поток, браузер сам оптимизирует проверки, работает для элементов внутри прокручиваемых контейнеров.

    Отключение прокрутки

    // Заблокировать прокрутку (например, при открытом модальном окне)
    document.body.style.overflow = 'hidden'
    
    // Восстановить прокрутку
    document.body.style.overflow = ''

    Типичные ошибки

    1. Тяжёлая работа на каждое событие scroll без throttle

    // ПЛОХО — вызывается 60 раз/с, дорогие вычисления тормозят страницу
    window.addEventListener('scroll', () => {
      const elements = document.querySelectorAll('.animated')  // дорого!
      elements.forEach(el => {
        const rect = el.getBoundingClientRect()  // reflow на каждый элемент!
        if (rect.top < window.innerHeight) el.classList.add('visible')
      })
    })
    
    // ХОРОШО — IntersectionObserver или throttle
    const observer = new IntersectionObserver(entries => {
      entries.forEach(e => { if (e.isIntersecting) e.target.classList.add('visible') })
    })
    document.querySelectorAll('.animated').forEach(el => observer.observe(el))

    2. Не восстанавливать overflow при закрытии модального окна

    // ПЛОХО — прокрутка заблокирована навсегда
    function openModal() {
      document.body.style.overflow = 'hidden'
    }
    // closeModal не восстанавливает overflow!
    
    // ХОРОШО — симметричная блокировка и восстановление
    function openModal() {
      document.body.style.overflow = 'hidden'
    }
    function closeModal() {
      document.body.style.overflow = ''
    }

    3. Читать window.scrollY в обработчике scroll синхронно — вызывает reflow

    // ПЛОХО — requestAnimationFrame синхронизирует с кадром браузера
    window.addEventListener('scroll', () => {
      // scrollY здесь безопасен, но тяжёлый layout-запрос (getBoundingClientRect) — нет
      updateParallax(window.scrollY)  // если updateParallax делает reflow — лаг
    })
    
    // ХОРОШО — через rAF
    let ticking = false
    window.addEventListener('scroll', () => {
      if (!ticking) {
        requestAnimationFrame(() => {
          updateParallax(window.scrollY)
          ticking = false
        })
        ticking = true
      }
    })

    В реальных проектах

  • Кнопка «наверх»: присутствует на Habr, VC.ru, Wikipedia — появляется после прокрутки вниз на определённое расстояние
  • Прогресс чтения: Medium показывает полосу прогресса в заголовке — рассчитывается через scrollY / (scrollHeight - viewportHeight)
  • Параллакс: Hero-секции на лендингах двигаются медленнее фона — background-position обновляется в обработчике scroll с rAF
  • IntersectionObserver: lazy-loading изображений в Instagram, ВКонтакте — изображение загружается когда входит в viewport
  • Примеры

    Симуляция прокрутки: кнопка «наверх», throttle обработчика и отслеживание прогресса чтения

    // Симуляция логики прокрутки без реального DOM
    // Все вычисления работают с числами — точно как в браузере
    
    // --- Утилита throttle ---
    function throttle(fn, limitMs) {
      let lastCall = 0
      return function(...args) {
        const now = Date.now()
        if (now - lastCall >= limitMs) {
          lastCall = now
          return fn.apply(this, args)
        }
      }
    }
    
    // --- Логика кнопки "наверх" ---
    function shouldShowBackToTop(scrollY, threshold = 300) {
      return scrollY > threshold
    }
    
    // --- Прогресс чтения страницы ---
    function getReadingProgress(scrollY, documentHeight, viewportHeight) {
      const maxScroll = documentHeight - viewportHeight
      if (maxScroll <= 0) return 100
      return Math.min(100, Math.round((scrollY / maxScroll) * 100))
    }
    
    // --- Симуляция прокрутки страницы ---
    console.log('=== Симуляция прокрутки ===')
    
    const PAGE = { documentHeight: 5000, viewportHeight: 800 }
    
    const scrollPositions = [0, 100, 300, 301, 500, 1200, 2400, 4200]
    
    scrollPositions.forEach(y => {
      const showBtn = shouldShowBackToTop(y)
      const progress = getReadingProgress(y, PAGE.documentHeight, PAGE.viewportHeight)
      console.log(`scrollY=${String(y).padStart(4)}: кнопка=${showBtn ? 'видна ' : 'скрыта'}, прогресс=${String(progress).padStart(3)}%`)
    })
    
    // --- Throttle демо ---
    console.log('\n=== Throttle обработчика scroll ===')
    
    let handlerCallCount = 0
    let throttledCallCount = 0
    
    const rawHandler = () => { handlerCallCount++ }
    const throttledHandler = throttle(() => { throttledCallCount++ }, 100)
    
    // Симулируем 20 событий scroll за 200мс
    const startTime = Date.now()
    for (let i = 0; i < 20; i++) {
      rawHandler()
      throttledHandler()
    }
    
    console.log(`Событий scroll: 20`)
    console.log(`Вызовов без throttle: ${handlerCallCount}`)   // 20
    console.log(`Вызовов с throttle: ${throttledCallCount}`)   // меньше
    
    // --- IntersectionObserver симуляция ---
    console.log('\n=== IntersectionObserver симуляция ===')
    
    function createMockObserver(callback, options = {}) {
      const threshold = options.threshold ?? 0
      const observed = []
    
      return {
        observe(element) {
          observed.push(element)
        },
        unobserve(element) {
          const idx = observed.indexOf(element)
          if (idx !== -1) observed.splice(idx, 1)
        },
        // Симулируем проверку видимости
        checkVisibility(scrollY, viewportHeight) {
          observed.forEach(el => {
            const visibleTop    = scrollY
            const visibleBottom = scrollY + viewportHeight
            const isIntersecting = el.offsetTop < visibleBottom && (el.offsetTop + el.height) > visibleTop
            if (isIntersecting) {
              callback([{ target: el, isIntersecting: true }])
            }
          })
        },
      }
    }
    
    const mockElements = [
      { id: 'hero',    offsetTop: 0,    height: 600 },
      { id: 'section1', offsetTop: 700,  height: 400 },
      { id: 'section2', offsetTop: 1200, height: 400 },
      { id: 'footer',  offsetTop: 4600, height: 400 },
    ]
    
    const animatedElements = new Set()
    const observer = createMockObserver((entries) => {
      entries.forEach(({ target }) => {
        if (!animatedElements.has(target.id)) {
          animatedElements.add(target.id)
          console.log(`Элемент "${target.id}" стал видимым — запускаем анимацию`)
        }
      })
    })
    
    mockElements.forEach(el => observer.observe(el))
    
    // Симулируем прокрутку
    console.log('scrollY=0:')
    observer.checkVisibility(0, 800)
    
    console.log('scrollY=700:')
    observer.checkVisibility(700, 800)
    
    console.log('scrollY=4200:')
    observer.checkVisibility(4200, 800)

    Задание

    Напиши функцию getReadingProgress(scrollY, documentHeight, viewportHeight) которая возвращает процент прочитанного от 0 до 100 (целое число). Напиши функцию createScrollTracker(threshold) которая возвращает объект с методом update(scrollY) — он принимает текущую позицию прокрутки и возвращает объект { showBackToTop: boolean, progress: number, direction: "up" | "down" | null }. direction определяется по сравнению с предыдущим значением.

    Подсказка

    getReadingProgress: Math.min(100, Math.round((scrollY / maxScroll) * 100)). direction: scrollY > prevScrollY — 'down', scrollY < prevScrollY — 'up', иначе null.

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