Представь: ты разрабатываешь лендинг с анимациями появления секций и кнопкой «вернуться наверх». Обе функции требуют знания текущей позиции прокрутки. Но обработчик scroll срабатывает 60 раз в секунду — если делать тяжёлую работу на каждое событие, страница начнёт тормозить. Нужен правильный подход.
window.scrollY даёт текущую позицию, scrollTo/scrollBy — управление, событие scroll — реакцию на действия пользователя. IntersectionObserver — современная альтернатива, которая не нагружает главный поток.
scrollHeight, clientHeight, scrollTop работают так же для window// Текущая прокрутка страницы (в пикселях)
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 срабатывает очень часто — иногда 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' })
})Вместо проверки 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
}
})scrollY / (scrollHeight - viewportHeight)background-position обновляется в обработчике scroll с rAFСимуляция прокрутки: кнопка «наверх», 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// Текущая прокрутка страницы (в пикселях)
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 срабатывает очень часто — иногда 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' })
})Вместо проверки 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
}
})scrollY / (scrollHeight - viewportHeight)background-position обновляется в обработчике scroll с rAFСимуляция прокрутки: кнопка «наверх», 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.