12k
All articles

Как реализовать бесконечную прокрутку на чистом JavaScript

Реализуйте бесконечную прокрутку на чистом JavaScript с Intersection Observer, sentinel-элементом, пагинацией, защитой от дублей и доступностью.

OpenReplay Team
OpenReplay Team
Как реализовать бесконечную прокрутку на чистом JavaScript

Реализуйте бесконечную прокрутку на чистом JavaScript с помощью Intersection Observer API: разместите элемент-«сторож» (sentinel) в конце списка, начните наблюдать за ним и подгружайте следующую страницу данных каждый раз, когда он попадает во вьюпорт.

Если вы когда-нибудь делали такое, вы знаете характерный сбой: чуть сильнее дёрнули скроллбар — и одни и те же десять элементов оказались в списке три раза. Базовая механика заводится минут за десять, а вот заставить её выдержать реального пользователя занимает весь остаток дня. Этот подход заменяет старую связку из события scroll и getBoundingClientRect, которая пересчитывает геометрию при каждом тике прокрутки. В этом руководстве мы соберём одну полноценную работающую ленту — с настоящим fetch, пагинацией и добавлением элементов в DOM, — а затем разберём четыре продакшн-подвоха (дублирующиеся запросы, отсутствие остановки, обработка ошибок и тайминг предзагрузки) плюс средства доступности, которые отличают демо от кода, готового к релизу.

Ключевые выводы

  • Используйте IntersectionObserver, а не события прокрутки: слушатель scroll срабатывает непрерывно в основном потоке и требует ручных расчётов позиции, тогда как observer вызывает колбэк только тогда, когда целевой элемент действительно пересекает границу вьюпорта.
  • IntersectionObserver имеет статус Baseline во всех современных браузерах с марта 2019 года, поэтому сегодня бесконечная прокрутка не требует полифилов.
  • Защищайте каждый запрос булевым флагом, чтобы быстрая прокрутка не запустила несколько перекрывающихся запросов до того, как завершится первый.
  • Останавливайтесь, когда API возвращает неполную или пустую страницу. Вызовите observer.disconnect() и скройте sentinel, иначе observer будет продолжать запрашивать страницы, которых больше нет.
  • Дополняйте бесконечную прокрутку видимой кнопкой «Load more»: это одновременно резервный вариант для клавиатуры, скринридеров и случая отключённого JavaScript.

Почему IntersectionObserver лучше событий прокрутки?

Используйте IntersectionObserver вместо слушателя scroll, потому что он сообщает о видимости асинхронно через колбэк, который срабатывает только когда ваш целевой элемент пересекает границу вьюпорта, а не выполняется на каждом кадре прокрутки. Старый паттерн подключает обработчик scroll и вызывает getBoundingClientRect() на каждом тике, чтобы вычислить, близко ли низ списка. Это вычисления с чтением layout в основном потоке, выполняемые гораздо чаще, чем нужно, и хорошо известный источник рывков при прокрутке.

scroll + getBoundingClientRect()IntersectionObserver
СрабатываниеНа каждом кадре прокруткиТолько когда цель пересекает вьюпорт
Расчёт позицииВручную, в вашем кодеВыполняет браузер
ПотокиСинхронно в основном потокеДоставляется асинхронно
Нужен полифилНет (Baseline)

Полифил не требуется. MDN помечает API как Baseline Widely available с поддержкой во всех основных браузерах начиная с марта 2019 года, поэтому старые советы, рекомендующие полифил (и ссылающиеся на поддержку уровня Chrome 51), устарели. Одно исключение: не используйте trackVisibility по умолчанию, поскольку MDN до сих пор относит это свойство определения перекрытия к экспериментальным с ограниченной доступностью.

Что такое паттерн sentinel?

Паттерн sentinel предполагает размещение одного элемента-маркера в конце списка; когда observer сообщает, что sentinel попал во вьюпорт, вы загружаете следующую страницу и добавляете её. Sentinel — это просто пустой элемент после последнего пункта списка, и его никогда не нужно выбирать заново, потому что добавление новых элементов постоянно сдвигает его ниже.

Три составные части:

  1. Создайте observer: new IntersectionObserver(callback, options).
  2. Начните наблюдение: observer.observe(sentinel).
  3. В колбэке проверяйте entry.isIntersecting и загружайте следующую страницу, когда значение равно true.

Перебирайте массив entries, а не читайте entries[0]. Справочник по конструктору IntersectionObserver() предупреждает, что не следует рассчитывать на какое-то определённое количество записей, поскольку один вызов колбэка может содержать сразу несколько пересечений.

Полный пример бесконечной прокрутки на чистом JavaScript

Ниже — полностью работающая реализация на базе JSONPlaceholder, бесплатного мок-REST API, работающего на JSON Server с LowDB под капотом. Его эндпоинт /posts содержит 100 записей и принимает query-параметры _page и _limit, возвращая запрошенный срез в виде обычного массива. Это даёт конечный набор данных, что удобно для демонстрации того, что происходит, когда данные заканчиваются.

Разметка: список, резервная кнопка, sentinel и строка состояния в live-region.

<main>
  <ul id="list" aria-label="Posts"></ul>
  <button id="load-more" type="button">Load more</button>
  <div id="sentinel" aria-hidden="true"></div>
  <p id="status" role="status" aria-live="polite"></p>
</main>

Скрипт связывает observer с sentinel и загружает по одной странице на каждое пересечение:

const LIMIT = 10;
let page = 1;
let loading = false;   // guard against overlapping requests
let done = false;      // stop at end of data

const list = document.getElementById("list");
const sentinel = document.getElementById("sentinel");
const loadMoreBtn = document.getElementById("load-more");
const status = document.getElementById("status");

async function fetchPosts(page) {
  const url = `https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${LIMIT}`;
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

function render(posts) {
  const frag = document.createDocumentFragment();
  for (const post of posts) {
    const li = document.createElement("li");
    li.innerHTML = `<h2>${post.title}</h2><p>${post.body}</p>`;
    frag.appendChild(li);
  }
  list.appendChild(frag);
}

async function loadNextPage() {
  if (loading || done) return;
  loading = true;
  status.textContent = "Loading…";
  try {
    const posts = await fetchPosts(page);
    render(posts);
    page += 1;
    if (posts.length < LIMIT) {   // short/empty page = no more data
      done = true;
      observer.disconnect();
      loadMoreBtn.hidden = true;
      status.textContent = "You've reached the end.";
    } else {
      status.textContent = "";
    }
  } catch (err) {
    status.textContent = "Could not load posts. Tap Load more to retry.";
    console.error(err);
  } finally {
    loading = false;
  }
}

const observer = new IntersectionObserver(
  (entries) => {
    for (const entry of entries) {
      if (entry.isIntersecting) loadNextPage();
    }
  },
  { root: null, rootMargin: "200px", threshold: 0 }
);

observer.observe(sentinel);
loadMoreBtn.addEventListener("click", loadNextPage);
document.addEventListener("DOMContentLoaded", loadNextPage);

Каждый запрос проходит через loadNextPage, поэтому колбэк observer, клик по кнопке и первоначальная загрузка по DOMContentLoaded используют одну и ту же логику защиты и остановки.

Четыре подвоха, отличающие демо от кода, готового к релизу

Большинство руководств заканчиваются на «данные добавляются». Именно эти четыре исправления позволяют коду выдержать реальных пользователей.

СимптомПричинаРешение
Дублирующиеся запросы при быстрой прокруткеНет защиты от повторного запросаБулев флаг if (loading) return;
Список никогда не останавливается, повторно запрашивает пустые страницыНет определения конца данныхif (posts.length < LIMIT) observer.disconnect()
Ошибки исчезают незаметноНет обработки ошибок fetchПроверяйте res.ok, try/catch, показывайте возможность повтора
Заметная пауза внизуrootMargin: "0px"rootMargin: "200px" для ранней предзагрузки

Защита от двойной загрузки. Быстрый рывок прокрутки может вызвать колбэк несколько раз до того, как разрешится первый await. Флаг loading заставляет все лишние вызовы завершаться сразу, пока выполняющийся запрос не будет завершён в блоке finally. Также можно вызывать unobserve для sentinel во время запроса и возобновлять наблюдение после. Только не путайте unobserve (одна цель) с disconnect (все цели).

Останавливайтесь на конце данных. С конечным источником продолжение запросов приведёт к спаму запросов к несуществующим страницам. Определяйте страницу короче LIMIT — тот же признак окончания данных используется в руководстве Prismatic по циклу обхода пагинированного API, где цикл прерывается, как только запрос возвращает пустой массив. Затем вызывайте disconnect() и скрывайте sentinel вместе с кнопкой.

Предпочитайте threshold: 0 в сочетании с rootMargin. Установка rootMargin: "200px" запускает следующую загрузку примерно за 200 пикселей до того, как пользователь доберётся до низа, устраняя заметную заминку. Комбинируйте это с threshold: 0, а не с 1.0: sentinel, превышающий по высоте вьюпорт, может никогда не стать видимым на 100%, поэтому порог полной видимости может незаметно так и не сработать.

Баги бесконечной прокрутки зависят от таймингов и скорости прокрутки, поэтому аккуратная локальная прокрутка редко их воспроизводит. Просмотр реальных сессий с помощью инструмента вроде session replay — один из способов выявить именно тот класс сбоев, который остаётся невидимым при быстрой проверке: дублирующиеся запросы при резком рывке или список, который никогда не останавливается.

Доступность и резервный вариант «Load more»

Всегда дополняйте бесконечную прокрутку видимой кнопкой «Load more»: это резервный вариант для клавиатуры и скринридеров, резервный вариант при отключённом JavaScript и зачастую единственный способ для пользователя приостановить поток и добраться до футера. Бесконечно автоматически подгружаемый контент запирает пользователей вспомогательных технологий, прячет ссылки футера за постоянно растущим содержимым и ломает восстановление позиции прокрутки при нажатии кнопки «Назад», когда пользователь возвращается к позиции, которой больше нет в DOM.

Три конкретных шага, все они есть в коде выше:

  • Объявляйте состояние загрузки через live-region: <p role="status" aria-live="polite"> позволяет скринридерам озвучить «Loading…» и «You’ve reached the end».
  • Оставляйте кнопку настоящим фокусируемым элементом управления, чтобы она работала, когда observer так и не срабатывает или JavaScript отключён.
  • Помечайте sentinel атрибутом aria-hidden="true". Это механизм, а не контент, и он не должен попадать в дерево доступности.

Если футер ленты действительно важен (контактные ссылки, юридическая информация, пагинация для глубоких ссылок), подумайте, не будет ли кнопка «Load more» сама по себе более подходящим паттерном, а автоподгрузку оставьте для контента, где бесконечный поток и есть суть.

Бесконечная прокрутка на чистом JavaScript сводится к одной устойчивой идее: наблюдайте за sentinel, загружайте данные при пересечении и обрабатывайте краевые случаи. Возьмите полный файл выше, направьте fetchPosts на свой пагинированный эндпоинт и убедитесь, что и защита от повторных запросов, и остановка в конце данных срабатывают, прежде чем отправлять код в релиз. Именно эти две строки превращают работающее демо в код, которому можно доверять в продакшене.

Часто задаваемые вопросы

В чём разница между unobserve и disconnect у IntersectionObserver?

Вызывайте unobserve, когда нужно, чтобы observer перестал следить за одним конкретным элементом и продолжил работу с остальными, и вызывайте disconnect, когда нужно, чтобы он прекратил наблюдение за всем, что отслеживает сейчас. Для бесконечной прокрутки это означает unobserve для приостановки наблюдения за единственным sentinel, пока выполняется запрос, и disconnect после того, как данные закончились и у observer больше нет работы.

Когда стоит использовать пагинацию или кнопку Load more вместо бесконечной прокрутки?

Выбирайте пагинацию или кнопку Load more, когда важен футер — например, контактные ссылки, юридический текст или пагинация для глубоких ссылок, — потому что бесконечная автоподгрузка навсегда отодвигает содержимое футера за пределы досягаемости и запирает пользователей клавиатуры и скринридеров. Бесконечная прокрутка подходит для открытого контента, где бесконечный поток и есть суть, например для социальных лент. Когда пользователям нужна точка остановки или необходимо добраться до низа страницы, явный элемент управления — более удачный паттерн.

Почему threshold 1.0 иногда не запускает бесконечную прокрутку?

Порог 1.0 требует, чтобы наблюдаемый элемент был видим на 100 процентов до срабатывания колбэка, поэтому sentinel, превышающий по высоте вьюпорт, никогда не попадёт в область видимости полностью, и колбэк незаметно так и не выполнится. Вместо этого используйте threshold 0 в сочетании с буфером rootMargin: тогда колбэк срабатывает, как только любая часть sentinel пересекает расширенную границу root, что является более надёжным вариантом по умолчанию для бесконечной прокрутки.

Нужно ли обрабатывать несколько записей в колбэке IntersectionObserver?

Да. Справочник MDN по конструктору говорит, что не следует полагаться на какую-то определённую длину массива entries, поскольку один вызов колбэка может содержать более одного пересечения. Для единственного sentinel entries[0] на практике часто работает, но перебор всех записей с проверкой isIntersecting у каждой — корректный подход, который предотвращает пропущенные или неправильно сопоставленные пересечения, когда одновременно сообщают о себе несколько целей.

Ломает ли бесконечная прокрутка кнопку «Назад» в браузере?

Да, бесконечная прокрутка может ломать восстановление позиции прокрутки по кнопке «Назад», потому что браузер пытается вернуть пользователя к позиции прокрутки, которой больше нет в DOM после того, как динамически загруженный контент был отброшен при навигации. Пользователь попадает не туда или в начало списка. Способы смягчения: сохранение загруженного состояния, ручное восстановление позиции прокрутки или предоставление кнопки Load more, чтобы навигация соответствовала стабильному, воспроизводимому состоянию.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.