← JavaScript/Размеры и прокрутка элементов#144 из 383← ПредыдущийСледующий →+25 XP
Полезно по теме:Гайд: как учить JavaScriptПрактика: JS базаПрактика: async и сетьТермин: Closure

Размеры и прокрутка элементов

Представь, что ты реализуешь infinite scroll для ленты новостей. Когда загружать следующую порцию данных? Нужно знать, сколько осталось до конца контейнера. Для этого нужно понимать разницу между clientHeight, scrollHeight и scrollTop — три числа, которые постоянно путают даже опытные разработчики.

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

Браузер хранит несколько видов «размера» элемента: видимый размер, полный размер контента, позицию прокрутки, позицию в viewport. Выбор неправильного свойства — источник багов: кнопка «наверх» появляется не вовремя, lazy-load изображений не срабатывает, или попап отображается в неправильном месте.

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

  • события мыши — clientX/clientY из событий работают так же, как clientWidth/clientHeight элементов
  • addEventListener — события scroll используются вместе с этими свойствами
  • offsetWidth / offsetHeight

    Полный размер элемента, включая паддинги и границы (border), но без внешних отступов (margin):

    // offsetWidth = width + padding-left + padding-right + border-left + border-right
    // offsetHeight = height + padding-top + padding-bottom + border-top + border-bottom
    const box = document.getElementById('box')
    console.log(box.offsetWidth)   // например, 320
    console.log(box.offsetHeight)  // например, 200

    clientWidth / clientHeight

    Внутренний размер элемента — без border и без полосы прокрутки:

    // clientWidth = width + padding-left + padding-right - scrollbar
    // clientHeight = height + padding-top + padding-bottom - scrollbar
    console.log(box.clientWidth)   // offsetWidth минус border и scrollbar
    console.log(box.clientHeight)

    Разница между offsetWidth и clientWidth — это ширина border плюс ширина полосы прокрутки (обычно 17px).

    scrollWidth / scrollHeight

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

    // scrollWidth >= clientWidth всегда
    // scrollHeight >= clientHeight всегда
    console.log(box.scrollHeight)  // реальная высота всего контента
    console.log(box.clientHeight)  // видимая высота
    console.log(box.scrollHeight - box.clientHeight)  // сколько можно прокрутить

    scrollTop / scrollLeft

    Текущая позиция прокрутки — сколько пикселей уже прокручено:

    // Получить позицию прокрутки
    console.log(box.scrollTop)   // 0 в начале, растёт при прокрутке вниз
    console.log(box.scrollLeft)  // для горизонтальной прокрутки
    
    // Установить позицию программно
    box.scrollTop = 200  // прокрутить к 200px от верха
    box.scrollLeft = 0
    
    // Плавная прокрутка
    box.scrollTo({ top: 500, behavior: 'smooth' })

    getBoundingClientRect()

    Возвращает позицию элемента относительно viewport (видимой области):

    const rect = element.getBoundingClientRect()
    console.log(rect.top)     // расстояние от верха viewport до верха элемента
    console.log(rect.left)    // расстояние от левого края viewport
    console.log(rect.right)   // rect.left + rect.width
    console.log(rect.bottom)  // rect.top + rect.height
    console.log(rect.width)   // ширина элемента
    console.log(rect.height)  // высота элемента

    Важно: значения изменяются при прокрутке! Элемент, ушедший за пределы viewport, будет иметь отрицательный rect.top.

    pageX/pageY vs clientX/clientY

    В событиях мыши:

    document.addEventListener('click', (event) => {
      // clientX/Y — от верхнего левого угла VIEWPORT (видимой области)
      // НЕ меняются при прокрутке страницы
      console.log(event.clientX, event.clientY)
    
      // pageX/Y — от верхнего левого угла ВСЕГО ДОКУМЕНТА
      // pageX = clientX + window.scrollX
      // pageY = clientY + window.scrollY
      console.log(event.pageX, event.pageY)
    })

    scrollIntoView()

    // Прокрутить так, чтобы элемент стал видимым
    element.scrollIntoView()
    element.scrollIntoView({ behavior: 'smooth', block: 'start' })
    element.scrollIntoView({ behavior: 'smooth', block: 'center' })

    Реальный пример: Infinite Scroll

    Бесконечная прокрутка — загрузка новых данных когда пользователь долистал до низа:

    function isScrolledToBottom(element, threshold = 100) {
      // scrollTop — прокрутили сверху
      // clientHeight — видимая высота элемента
      // scrollHeight — полная высота содержимого
      return element.scrollTop + element.clientHeight >= element.scrollHeight - threshold
    }
    
    container.addEventListener('scroll', () => {
      if (isScrolledToBottom(container)) {
        loadMoreItems()  // загрузить следующую порцию данных
      }
    })

    Реальный пример: Sticky Header

    const header = document.querySelector('.header')
    const hero = document.querySelector('.hero')
    
    window.addEventListener('scroll', () => {
      const heroBottom = hero.getBoundingClientRect().bottom
      // Если нижний край hero ушёл выше viewport — добавить класс sticky
      if (heroBottom <= 0) {
        header.classList.add('sticky')
      } else {
        header.classList.remove('sticky')
      }
    })

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

    1. Использовать scrollTop вместо scrollHeight для проверки достижения конца

    // ПЛОХО — scrollTop не учитывает высоту видимой области
    function isAtBottom(el) {
      return el.scrollTop >= el.scrollHeight  // почти никогда не true!
    }
    
    // ХОРОШО — учитываем clientHeight
    function isAtBottom(el, threshold = 0) {
      return el.scrollTop + el.clientHeight >= el.scrollHeight - threshold
    }

    2. Вызывать getBoundingClientRect() в цикле — вызывает принудительный layout (reflow)

    // ПЛОХО — 100 reflow подряд тормозят браузер
    items.forEach(item => {
      const rect = item.getBoundingClientRect()  // каждый вызов — reflow!
      if (rect.top < window.innerHeight) item.classList.add('visible')
    })
    
    // ХОРОШО — использовать IntersectionObserver вместо этого паттерна
    const observer = new IntersectionObserver(entries => {
      entries.forEach(e => { if (e.isIntersecting) e.target.classList.add('visible') })
    })
    items.forEach(item => observer.observe(item))

    3. Читать offsetWidth у скрытого элемента (display: none)

    // ПЛОХО — display:none даёт 0, visibility:hidden даёт реальный размер
    const hiddenEl = document.getElementById('hidden')  // display: none
    console.log(hiddenEl.offsetWidth)  // 0 — неожиданно!
    
    // ХОРОШО — показать, измерить, скрыть
    hiddenEl.style.visibility = 'hidden'
    hiddenEl.style.display = 'block'
    const width = hiddenEl.offsetWidth
    hiddenEl.style.display = 'none'

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

  • Infinite scroll: Twitter, Instagram, ВКонтакте — scrollTop + clientHeight >= scrollHeight - threshold сигнализирует о загрузке следующей порции
  • Sticky header: Avito, Ozon — getBoundingClientRect().bottom <= 0 определяет когда hero-секция ушла из viewport
  • Виртуальный список: react-virtual, vue-virtual-scroller — рассчитывают видимые элементы через те же свойства для рендера тысяч строк таблицы
  • Примеры

    Симуляция infinite scroll: проверка позиции прокрутки через mock-объект элемента

    // Симулируем объект элемента с размерами и прокруткой
    // В браузере это были бы реальные DOM-свойства
    
    function createMockScrollContainer(options) {
      const { clientHeight, scrollHeight } = options
      let scrollTop = options.scrollTop || 0
    
      return {
        get clientHeight() { return clientHeight },
        get scrollHeight() { return scrollHeight },
        get scrollTop() { return scrollTop },
        set scrollTop(val) { scrollTop = Math.max(0, Math.min(val, scrollHeight - clientHeight)) },
    
        // scrollTo — как в браузере
        scrollTo({ top }) {
          this.scrollTop = top
        },
    
        // Вычислить оставшееся расстояние до конца
        get distanceToBottom() {
          return this.scrollHeight - this.scrollTop - this.clientHeight
        }
      }
    }
    
    // Основная функция проверки
    function isScrolledToBottom(scrollTop, clientHeight, scrollHeight, threshold = 100) {
      return scrollTop + clientHeight >= scrollHeight - threshold
    }
    
    // Демонстрация: контейнер 600px видимой высоты, 3000px полного контента
    console.log('=== Infinite Scroll симуляция ===')
    const container = createMockScrollContainer({
      clientHeight: 600,
      scrollHeight: 3000,
      scrollTop: 0,
    })
    
    // Начало страницы
    console.log('Позиция:', container.scrollTop)
    console.log('До конца:', container.distanceToBottom, 'px')
    console.log('Загружать?', isScrolledToBottom(container.scrollTop, container.clientHeight, container.scrollHeight))
    // false — только начали
    
    // Прокрутили до середины
    container.scrollTop = 1200
    console.log('\nПосле прокрутки на 1200px:')
    console.log('До конца:', container.distanceToBottom, 'px')
    console.log('Загружать?', isScrolledToBottom(container.scrollTop, container.clientHeight, container.scrollHeight))
    // false
    
    // Прокрутили почти до конца (осталось 80px — меньше threshold 100)
    container.scrollTop = 2320
    console.log('\nПочти у конца (осталось ~80px):')
    console.log('Позиция:', container.scrollTop)
    console.log('До конца:', container.distanceToBottom, 'px')
    console.log('Загружать?', isScrolledToBottom(container.scrollTop, container.clientHeight, container.scrollHeight))
    // true — надо загружать!
    
    // Симуляция загрузки новых данных (scrollHeight увеличился)
    console.log('\n=== Загрузили ещё данные ===')
    const newScrollHeight = 3000 + 1500  // добавили ещё 1500px контента
    console.log('Новая scrollHeight:', newScrollHeight)
    console.log('Загружать снова?', isScrolledToBottom(container.scrollTop, container.clientHeight, newScrollHeight))
    // false — контент добавился, порог не достигнут
    
    // getBoundingClientRect симуляция
    console.log('\n=== getBoundingClientRect симуляция ===')
    function createMockRect(top, left, width, height) {
      return {
        top,
        left,
        width,
        height,
        right: left + width,
        bottom: top + height,
      }
    }
    
    // Sticky header: hero-секция ушла выше viewport (bottom <= 0)
    const viewportHeight = 768
    const heroRect = createMockRect(-200, 0, 1200, 500)  // top=-200 значит прокрутили мимо
    console.log('heroRect.bottom:', heroRect.bottom)       // 300 — ещё виден
    console.log('Нужен sticky?', heroRect.bottom <= 0)    // false — 300 > 0
    
    const heroRect2 = createMockRect(-550, 0, 1200, 500)  // прокрутили дальше
    console.log('\nheroRect2.bottom:', heroRect2.bottom)  // -50 — вышел за viewport
    console.log('Нужен sticky?', heroRect2.bottom <= 0)   // true — добавить класс!

    Размеры и прокрутка элементов

    Представь, что ты реализуешь infinite scroll для ленты новостей. Когда загружать следующую порцию данных? Нужно знать, сколько осталось до конца контейнера. Для этого нужно понимать разницу между clientHeight, scrollHeight и scrollTop — три числа, которые постоянно путают даже опытные разработчики.

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

    Браузер хранит несколько видов «размера» элемента: видимый размер, полный размер контента, позицию прокрутки, позицию в viewport. Выбор неправильного свойства — источник багов: кнопка «наверх» появляется не вовремя, lazy-load изображений не срабатывает, или попап отображается в неправильном месте.

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

  • события мыши — clientX/clientY из событий работают так же, как clientWidth/clientHeight элементов
  • addEventListener — события scroll используются вместе с этими свойствами
  • offsetWidth / offsetHeight

    Полный размер элемента, включая паддинги и границы (border), но без внешних отступов (margin):

    // offsetWidth = width + padding-left + padding-right + border-left + border-right
    // offsetHeight = height + padding-top + padding-bottom + border-top + border-bottom
    const box = document.getElementById('box')
    console.log(box.offsetWidth)   // например, 320
    console.log(box.offsetHeight)  // например, 200

    clientWidth / clientHeight

    Внутренний размер элемента — без border и без полосы прокрутки:

    // clientWidth = width + padding-left + padding-right - scrollbar
    // clientHeight = height + padding-top + padding-bottom - scrollbar
    console.log(box.clientWidth)   // offsetWidth минус border и scrollbar
    console.log(box.clientHeight)

    Разница между offsetWidth и clientWidth — это ширина border плюс ширина полосы прокрутки (обычно 17px).

    scrollWidth / scrollHeight

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

    // scrollWidth >= clientWidth всегда
    // scrollHeight >= clientHeight всегда
    console.log(box.scrollHeight)  // реальная высота всего контента
    console.log(box.clientHeight)  // видимая высота
    console.log(box.scrollHeight - box.clientHeight)  // сколько можно прокрутить

    scrollTop / scrollLeft

    Текущая позиция прокрутки — сколько пикселей уже прокручено:

    // Получить позицию прокрутки
    console.log(box.scrollTop)   // 0 в начале, растёт при прокрутке вниз
    console.log(box.scrollLeft)  // для горизонтальной прокрутки
    
    // Установить позицию программно
    box.scrollTop = 200  // прокрутить к 200px от верха
    box.scrollLeft = 0
    
    // Плавная прокрутка
    box.scrollTo({ top: 500, behavior: 'smooth' })

    getBoundingClientRect()

    Возвращает позицию элемента относительно viewport (видимой области):

    const rect = element.getBoundingClientRect()
    console.log(rect.top)     // расстояние от верха viewport до верха элемента
    console.log(rect.left)    // расстояние от левого края viewport
    console.log(rect.right)   // rect.left + rect.width
    console.log(rect.bottom)  // rect.top + rect.height
    console.log(rect.width)   // ширина элемента
    console.log(rect.height)  // высота элемента

    Важно: значения изменяются при прокрутке! Элемент, ушедший за пределы viewport, будет иметь отрицательный rect.top.

    pageX/pageY vs clientX/clientY

    В событиях мыши:

    document.addEventListener('click', (event) => {
      // clientX/Y — от верхнего левого угла VIEWPORT (видимой области)
      // НЕ меняются при прокрутке страницы
      console.log(event.clientX, event.clientY)
    
      // pageX/Y — от верхнего левого угла ВСЕГО ДОКУМЕНТА
      // pageX = clientX + window.scrollX
      // pageY = clientY + window.scrollY
      console.log(event.pageX, event.pageY)
    })

    scrollIntoView()

    // Прокрутить так, чтобы элемент стал видимым
    element.scrollIntoView()
    element.scrollIntoView({ behavior: 'smooth', block: 'start' })
    element.scrollIntoView({ behavior: 'smooth', block: 'center' })

    Реальный пример: Infinite Scroll

    Бесконечная прокрутка — загрузка новых данных когда пользователь долистал до низа:

    function isScrolledToBottom(element, threshold = 100) {
      // scrollTop — прокрутили сверху
      // clientHeight — видимая высота элемента
      // scrollHeight — полная высота содержимого
      return element.scrollTop + element.clientHeight >= element.scrollHeight - threshold
    }
    
    container.addEventListener('scroll', () => {
      if (isScrolledToBottom(container)) {
        loadMoreItems()  // загрузить следующую порцию данных
      }
    })

    Реальный пример: Sticky Header

    const header = document.querySelector('.header')
    const hero = document.querySelector('.hero')
    
    window.addEventListener('scroll', () => {
      const heroBottom = hero.getBoundingClientRect().bottom
      // Если нижний край hero ушёл выше viewport — добавить класс sticky
      if (heroBottom <= 0) {
        header.classList.add('sticky')
      } else {
        header.classList.remove('sticky')
      }
    })

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

    1. Использовать scrollTop вместо scrollHeight для проверки достижения конца

    // ПЛОХО — scrollTop не учитывает высоту видимой области
    function isAtBottom(el) {
      return el.scrollTop >= el.scrollHeight  // почти никогда не true!
    }
    
    // ХОРОШО — учитываем clientHeight
    function isAtBottom(el, threshold = 0) {
      return el.scrollTop + el.clientHeight >= el.scrollHeight - threshold
    }

    2. Вызывать getBoundingClientRect() в цикле — вызывает принудительный layout (reflow)

    // ПЛОХО — 100 reflow подряд тормозят браузер
    items.forEach(item => {
      const rect = item.getBoundingClientRect()  // каждый вызов — reflow!
      if (rect.top < window.innerHeight) item.classList.add('visible')
    })
    
    // ХОРОШО — использовать IntersectionObserver вместо этого паттерна
    const observer = new IntersectionObserver(entries => {
      entries.forEach(e => { if (e.isIntersecting) e.target.classList.add('visible') })
    })
    items.forEach(item => observer.observe(item))

    3. Читать offsetWidth у скрытого элемента (display: none)

    // ПЛОХО — display:none даёт 0, visibility:hidden даёт реальный размер
    const hiddenEl = document.getElementById('hidden')  // display: none
    console.log(hiddenEl.offsetWidth)  // 0 — неожиданно!
    
    // ХОРОШО — показать, измерить, скрыть
    hiddenEl.style.visibility = 'hidden'
    hiddenEl.style.display = 'block'
    const width = hiddenEl.offsetWidth
    hiddenEl.style.display = 'none'

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

  • Infinite scroll: Twitter, Instagram, ВКонтакте — scrollTop + clientHeight >= scrollHeight - threshold сигнализирует о загрузке следующей порции
  • Sticky header: Avito, Ozon — getBoundingClientRect().bottom <= 0 определяет когда hero-секция ушла из viewport
  • Виртуальный список: react-virtual, vue-virtual-scroller — рассчитывают видимые элементы через те же свойства для рендера тысяч строк таблицы
  • Примеры

    Симуляция infinite scroll: проверка позиции прокрутки через mock-объект элемента

    // Симулируем объект элемента с размерами и прокруткой
    // В браузере это были бы реальные DOM-свойства
    
    function createMockScrollContainer(options) {
      const { clientHeight, scrollHeight } = options
      let scrollTop = options.scrollTop || 0
    
      return {
        get clientHeight() { return clientHeight },
        get scrollHeight() { return scrollHeight },
        get scrollTop() { return scrollTop },
        set scrollTop(val) { scrollTop = Math.max(0, Math.min(val, scrollHeight - clientHeight)) },
    
        // scrollTo — как в браузере
        scrollTo({ top }) {
          this.scrollTop = top
        },
    
        // Вычислить оставшееся расстояние до конца
        get distanceToBottom() {
          return this.scrollHeight - this.scrollTop - this.clientHeight
        }
      }
    }
    
    // Основная функция проверки
    function isScrolledToBottom(scrollTop, clientHeight, scrollHeight, threshold = 100) {
      return scrollTop + clientHeight >= scrollHeight - threshold
    }
    
    // Демонстрация: контейнер 600px видимой высоты, 3000px полного контента
    console.log('=== Infinite Scroll симуляция ===')
    const container = createMockScrollContainer({
      clientHeight: 600,
      scrollHeight: 3000,
      scrollTop: 0,
    })
    
    // Начало страницы
    console.log('Позиция:', container.scrollTop)
    console.log('До конца:', container.distanceToBottom, 'px')
    console.log('Загружать?', isScrolledToBottom(container.scrollTop, container.clientHeight, container.scrollHeight))
    // false — только начали
    
    // Прокрутили до середины
    container.scrollTop = 1200
    console.log('\nПосле прокрутки на 1200px:')
    console.log('До конца:', container.distanceToBottom, 'px')
    console.log('Загружать?', isScrolledToBottom(container.scrollTop, container.clientHeight, container.scrollHeight))
    // false
    
    // Прокрутили почти до конца (осталось 80px — меньше threshold 100)
    container.scrollTop = 2320
    console.log('\nПочти у конца (осталось ~80px):')
    console.log('Позиция:', container.scrollTop)
    console.log('До конца:', container.distanceToBottom, 'px')
    console.log('Загружать?', isScrolledToBottom(container.scrollTop, container.clientHeight, container.scrollHeight))
    // true — надо загружать!
    
    // Симуляция загрузки новых данных (scrollHeight увеличился)
    console.log('\n=== Загрузили ещё данные ===')
    const newScrollHeight = 3000 + 1500  // добавили ещё 1500px контента
    console.log('Новая scrollHeight:', newScrollHeight)
    console.log('Загружать снова?', isScrolledToBottom(container.scrollTop, container.clientHeight, newScrollHeight))
    // false — контент добавился, порог не достигнут
    
    // getBoundingClientRect симуляция
    console.log('\n=== getBoundingClientRect симуляция ===')
    function createMockRect(top, left, width, height) {
      return {
        top,
        left,
        width,
        height,
        right: left + width,
        bottom: top + height,
      }
    }
    
    // Sticky header: hero-секция ушла выше viewport (bottom <= 0)
    const viewportHeight = 768
    const heroRect = createMockRect(-200, 0, 1200, 500)  // top=-200 значит прокрутили мимо
    console.log('heroRect.bottom:', heroRect.bottom)       // 300 — ещё виден
    console.log('Нужен sticky?', heroRect.bottom <= 0)    // false — 300 > 0
    
    const heroRect2 = createMockRect(-550, 0, 1200, 500)  // прокрутили дальше
    console.log('\nheroRect2.bottom:', heroRect2.bottom)  // -50 — вышел за viewport
    console.log('Нужен sticky?', heroRect2.bottom <= 0)   // true — добавить класс!

    Задание

    Напиши функцию isScrolledToBottom(scrollTop, clientHeight, scrollHeight, threshold), которая возвращает true, если пользователь прокрутил до низа в пределах threshold пикселей. Также напиши getScrollPercent(scrollTop, clientHeight, scrollHeight), возвращающую процент прокрутки от 0 до 100.

    Подсказка

    isScrolledToBottom: return scrollTop + clientHeight >= scrollHeight - threshold. getScrollPercent: const maxScroll = scrollHeight - clientHeight; return Math.round((scrollTop / maxScroll) * 1000) / 10

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