Представь, что ты реализуешь infinite scroll для ленты новостей. Когда загружать следующую порцию данных? Нужно знать, сколько осталось до конца контейнера. Для этого нужно понимать разницу между clientHeight, scrollHeight и scrollTop — три числа, которые постоянно путают даже опытные разработчики.
Браузер хранит несколько видов «размера» элемента: видимый размер, полный размер контента, позицию прокрутки, позицию в viewport. Выбор неправильного свойства — источник багов: кнопка «наверх» появляется не вовремя, lazy-load изображений не срабатывает, или попап отображается в неправильном месте.
clientX/clientY из событий работают так же, как clientWidth/clientHeight элементовПолный размер элемента, включая паддинги и границы (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Внутренний размер элемента — без 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 >= clientWidth всегда
// scrollHeight >= clientHeight всегда
console.log(box.scrollHeight) // реальная высота всего контента
console.log(box.clientHeight) // видимая высота
console.log(box.scrollHeight - box.clientHeight) // сколько можно прокрутитьТекущая позиция прокрутки — сколько пикселей уже прокручено:
// Получить позицию прокрутки
console.log(box.scrollTop) // 0 в начале, растёт при прокрутке вниз
console.log(box.scrollLeft) // для горизонтальной прокрутки
// Установить позицию программно
box.scrollTop = 200 // прокрутить к 200px от верха
box.scrollLeft = 0
// Плавная прокрутка
box.scrollTo({ top: 500, behavior: 'smooth' })Возвращает позицию элемента относительно 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.
В событиях мыши:
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)
})// Прокрутить так, чтобы элемент стал видимым
element.scrollIntoView()
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
element.scrollIntoView({ behavior: 'smooth', block: 'center' })Бесконечная прокрутка — загрузка новых данных когда пользователь долистал до низа:
function isScrolledToBottom(element, threshold = 100) {
// scrollTop — прокрутили сверху
// clientHeight — видимая высота элемента
// scrollHeight — полная высота содержимого
return element.scrollTop + element.clientHeight >= element.scrollHeight - threshold
}
container.addEventListener('scroll', () => {
if (isScrolledToBottom(container)) {
loadMoreItems() // загрузить следующую порцию данных
}
})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'scrollTop + clientHeight >= scrollHeight - threshold сигнализирует о загрузке следующей порцииgetBoundingClientRect().bottom <= 0 определяет когда hero-секция ушла из viewportСимуляция 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 элементовПолный размер элемента, включая паддинги и границы (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Внутренний размер элемента — без 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 >= clientWidth всегда
// scrollHeight >= clientHeight всегда
console.log(box.scrollHeight) // реальная высота всего контента
console.log(box.clientHeight) // видимая высота
console.log(box.scrollHeight - box.clientHeight) // сколько можно прокрутитьТекущая позиция прокрутки — сколько пикселей уже прокручено:
// Получить позицию прокрутки
console.log(box.scrollTop) // 0 в начале, растёт при прокрутке вниз
console.log(box.scrollLeft) // для горизонтальной прокрутки
// Установить позицию программно
box.scrollTop = 200 // прокрутить к 200px от верха
box.scrollLeft = 0
// Плавная прокрутка
box.scrollTo({ top: 500, behavior: 'smooth' })Возвращает позицию элемента относительно 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.
В событиях мыши:
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)
})// Прокрутить так, чтобы элемент стал видимым
element.scrollIntoView()
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
element.scrollIntoView({ behavior: 'smooth', block: 'center' })Бесконечная прокрутка — загрузка новых данных когда пользователь долистал до низа:
function isScrolledToBottom(element, threshold = 100) {
// scrollTop — прокрутили сверху
// clientHeight — видимая высота элемента
// scrollHeight — полная высота содержимого
return element.scrollTop + element.clientHeight >= element.scrollHeight - threshold
}
container.addEventListener('scroll', () => {
if (isScrolledToBottom(container)) {
loadMoreItems() // загрузить следующую порцию данных
}
})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'scrollTop + clientHeight >= scrollHeight - threshold сигнализирует о загрузке следующей порцииgetBoundingClientRect().bottom <= 0 определяет когда hero-секция ушла из viewportСимуляция 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