Создание компонентов с отслеживанием скролла в React
Ваш обработчик скролла срабатывает 60 раз в секунду. Ваш компонент перерисовывается на каждом тике. Ваши пользователи видят подтормаживания вместо плавных анимаций. Этот паттерн ломает React-приложения быстрее, чем почти любая другая ошибка производительности.
Создание компонентов с отслеживанием скролла в React требует понимания того, когда следует полностью избегать системы состояния React. В этой статье рассматриваются правильные инструменты для отслеживания позиции скролла в React, почему Intersection Observer в React должен быть вашим выбором по умолчанию, и как реализовать производительную обработку скролла, когда вам действительно нужны необработанные данные о скролле.
Ключевые выводы
- Intersection Observer обрабатывает большинство паттернов с отслеживанием скролла без потерь производительности
- Когда вам нужны непрерывные значения скролла, используйте рефы и
requestAnimationFrameвместо состояния - Резервируйте
useSyncExternalStoreдля общего состояния скролла между компонентами - Тестируйте с роутингом фреймворка и всегда предоставляйте альтернативы для reduced-motion
Почему Intersection Observer должен быть вашим первым выбором
Большинство UI-паттернов в React, управляемых скроллом, на самом деле не нуждаются в позиции скролла. Им нужно определение видимости: виден ли этот элемент на экране? Пользователь прокрутил эту секцию?
Intersection Observer обрабатывает эти случаи без запуска кода приложения на каждом событии скролла. Браузер оптимизирует наблюдение внутренне, что делает его значительно более производительным, чем слушатели скролла.
import { useInView } from 'react-intersection-observer';
function FadeInSection({ children }) {
const { ref, inView } = useInView({
threshold: 0.1,
triggerOnce: true,
});
return (
<div ref={ref} className={inView ? 'visible' : 'hidden'}>
{children}
</div>
);
}
Библиотека react-intersection-observer чисто оборачивает этот API. Используйте её для отслеживания секций, ленивой загрузки и анимаций появления.
Когда вам действительно нужна позиция скролла
Некоторые паттерны требуют непрерывных значений скролла: эффекты параллакса, индикаторы прогресса или трансформации, связанные со скроллом. Вот где большинство туториалов вас подводят.
Никогда не делайте так:
// Это вызывает перерисовки на каждом тике скролла
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handleScroll = () => setScrollY(window.scrollY);
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
Этот паттерн запускает процесс согласования React 60+ раз в секунду во время активного скроллинга.
Паттерн на основе рефов
Храните значения скролла в рефах и обновляйте DOM напрямую:
import { useRef, useEffect } from 'react';
function ParallaxElement() {
const elementRef = useRef(null);
const rafId = useRef(null);
useEffect(() => {
const handleScroll = () => {
if (rafId.current) return;
rafId.current = requestAnimationFrame(() => {
if (elementRef.current) {
const offset = window.scrollY * 0.5;
elementRef.current.style.transform = `translateY(${offset}px)`;
}
rafId.current = null;
});
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
if (rafId.current) cancelAnimationFrame(rafId.current);
};
}, []);
return <div ref={elementRef}>Parallax content</div>;
}
Ключевые детали: requestAnimationFrame ограничивает обновления частотой обновления дисплея. Опция { passive: true } сообщает браузеру, что вы не будете вызывать preventDefault(), что включает оптимизации скролла.
Discover how at OpenReplay.com.
useSyncExternalStore для общего состояния скролла
Когда нескольким компонентам нужна позиция скролла, рассматривайте её как внешнее хранилище:
import { useSyncExternalStore } from 'react';
const scrollStore = {
subscribe: (callback) => {
window.addEventListener('scroll', callback, { passive: true });
return () => window.removeEventListener('scroll', callback);
},
getSnapshot: () => window.scrollY,
getServerSnapshot: () => 0,
};
function useScrollPosition() {
return useSyncExternalStore(
scrollStore.subscribe,
scrollStore.getSnapshot,
scrollStore.getServerSnapshot
);
}
Этот паттерн корректно работает с конкурентными возможностями React и обрабатывает серверный рендеринг. На практике вам всё равно следует ограничивать или укрупнять обновления (например, отправляя изменения секций вместо сырых пикселей), чтобы избежать перерисовок на каждом кадре скролла.
useLayoutEffect vs useEffect для скролла
Используйте useLayoutEffect, когда вам нужно измерить DOM-элементы перед тем, как браузер отрисует страницу. Это предотвращает визуальное мерцание при вычислении позиций с помощью getBoundingClientRect().
Используйте useEffect для подключения слушателей скролла. Настройка слушателя не требует блокировки отрисовки.
Особенности фреймворков
Next.js App Router и подобные фреймворки автоматически управляют восстановлением скролла. Пользовательские обработчики скролла могут конфликтовать с этим поведением. Тестируйте компоненты с отслеживанием скролла после клиентской навигации, чтобы выявить проблемы с восстановлением.
CSS-анимации, управляемые скроллом
Современные браузеры поддерживают animation-timeline: scroll() для чистых CSS-эффектов скролла. Поддержка браузерами остаётся неполной, но этот подход полностью устраняет JavaScript для поддерживаемых случаев. Используйте его с прогрессивным улучшением — предоставьте запасной вариант для неподдерживаемых браузеров.
Доступность
Всегда учитывайте prefers-reduced-motion. Оборачивайте анимации, запускаемые скроллом:
const prefersReducedMotion =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
Пропускайте анимации или предоставляйте мгновенные переходы, когда это возвращает true.
Заключение
Компоненты с отслеживанием скролла не обязаны убивать производительность вашего React-приложения. Ключ — выбор правильного инструмента для каждого случая использования. Сначала обращайтесь к Intersection Observer — он обрабатывает определение видимости без потерь производительности. Когда вам действительно нужны непрерывные значения скролла, обходите систему состояния React, используя рефы и requestAnimationFrame для прямой манипуляции DOM. Для общего состояния скролла между несколькими компонентами useSyncExternalStore предоставляет чистый, безопасный для конкурентности паттерн. Какой бы подход вы ни выбрали, тщательно тестируйте с роутингом вашего фреймворка и всегда учитывайте пользовательские предпочтения для уменьшенного движения.
Часто задаваемые вопросы
Установка состояния на каждом событии скролла запускает процесс согласования React 60+ раз в секунду. Каждое обновление состояния вызывает перерисовку, что означает, что React должен сравнивать виртуальный DOM и потенциально обновлять реальный DOM многократно. Это перегружает основной поток и вызывает видимые подтормаживания. Использование рефов полностью обходит это, поскольку обновления рефов не запускают перерисовки.
Используйте useSyncExternalStore, когда нескольким компонентам нужно одно и то же значение позиции скролла. Он правильно интегрируется с возможностями конкурентного рендеринга React и обрабатывает крайние случаи, такие как разрывы (tearing), когда разные компоненты могут видеть разные значения во время рендеринга. Он также обеспечивает встроенную поддержку серверного рендеринга через getServerSnapshot.
Вы можете использовать их с прогрессивным улучшением. CSS-анимации, управляемые скроллом, в настоящее время работают в Chrome и Edge, при этом поддержка Safari и Firefox всё ещё неполная или в разработке. Реализуйте их как улучшение для поддерживаемых браузеров, одновременно предоставляя JavaScript-запасной вариант или упрощённый опыт для остальных. Проверьте caniuse.com для актуальной поддержки браузерами, прежде чем полагаться на них.
Тестируйте после клиентской навигации, а не только при начальной загрузке страницы. App Router автоматически управляет восстановлением скролла, что может конфликтовать с пользовательскими обработчиками скролла. Переходите между страницами, используя Link-компоненты, и проверяйте, что ваши слушатели скролла правильно переподключаются и не мешают поведению восстановления позиции скролла фреймворка.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.