React Hook для бесконечной прокрутки
Бесконечная загрузка - это патерн, который очень распространен в приложениях. Например, в интернет-магазине этот патерн может быть полезен для загрузки товаров, поскольку он позволяет пользователю беспрепятственно просматривать каждый товар, доступный в категории или общем списке, без необходимости делать частые паузы и ждать загрузки следующей страницы.
В этой статье мы рассмотрим создание сверхмощного бесконечного загрузочного хука для React, который может быть использован в качестве руководства для вас при создании вашего собственного!
Хотя код в этой статье будет специально разработан для React, идея, лежащая в основе кода, легко применима к любому контексту, включая Vue.js и ванильный JavaScript.
Создание хука для бесконечной загрузки
Прежде чем мы углубимся в детали, давайте сначала обрисуем, с чем справится хук, а с чем нет.
Рендеринг не управляется самим хуком - это зависит от компонента. Связь с API также не будет включена, однако хук может быть расширен, чтобы включить вызовы API. На самом деле, в зависимости от вашего варианта использования!
Чем будет управлять наш хук? В первую очередь, это элементы, которые видны на странице. В частности, продукты, сообщения в блоге, элементы списка, ссылки и все, что повторяется на странице и загружается из вызова API.
Мы также предполагаем, что React Router распространен в большинстве, если не во всех, приложениях React, которые включают любой вид маршрутизации, поэтому мы будем использовать эту зависимость.
Давайте начнем с управления состоянием наших элементов:
const useInfiniteLoading = (() => { const [items, setItems] = React.useState([]); return { items, }; }
Далее давайте добавим функцию, которая будет вызываться каждый раз, когда мы захотим загрузить следующую страницу элементов.
Как упоминалось ранее, взаимодействие с API не является частью этой статьи. Фактическая API не имеет значения, нам просто нужна функция, которая принимает переменную номера страницы и возвращает массив элементов, соответствующих этому номеру страницы. Это может быть использование GraphQL
, Rest
, локальный поиск файлов или все, что нужно проекту!
const useInfiniteLoading = (props) => { // 1 const { getItems } = props; const [items, setItems] = React.useState([]); // 2 const pageToLoad = React.useRef(new URLSearchParams(window.location.search).get('page') || 1); const initialPageLoaded = React.useRef(false); const [hasMore, setHasMore] = React.useState(true); // 3 const loadItems = async () => { const data = await getItems({ page: pageToLoad.current }); // 4 setHasMore(data.totalPages > pageToLoad.current); setItems(prevItems => [...prevItems, ...data]); }; useEffect(() => { if (initialPageLoaded.current) { return; } // 5 loadItems(); initialPageLoaded.current = true; }, [loadItems]) return { items, hasMore, loadItems, }; }
Давайте быстро пройдемся по этому коду:
- Во-первых, мы принимаем один проп для хука:
getItems
.getItems
- это функция, которая будет принимать объект со свойством страницы, значением которого является “страница” элементов, которые мы хотим загрузить - Затем мы получаем параметр запроса страницы, который указывает начальную страницу, по умолчанию для первой страницы
loadItems
- это функция, которую наш компонент может вызывать, когда мы хотим фактически загрузить следующую страницу элементов. По мере прочтения статьи мы рассмотрим различные способы использования этой функции, будь то автоматический, ручной или сочетание того и другого- Данные, возвращаемые из
getItems
, также будут включать общее количество доступных страниц элементов. Это будет использоваться для условного скрытия кнопки “Загрузить больше”, когда все элементы будут загружены - Это гарантирует, что страница заполнена исходными элементами
Вот и все, теперь у нас есть хук, который будет обрабатывать бесконечную загрузку наших элементов!
Вот краткий пример того, как выглядит использование этого хука:
import { useInfiniteLoading } from './useInfiniteLoading'; export default MyList = () => { const { items, hasMore, loadItems } = useInfiniteLoading({ getItems: ({ page }) => { /* Вызов API */ } }); return ( <div> <ul> {items.map(item => ( <li key={item.id}> {item.name} </li> ))} </ul> {hasMore && <button onClick={() =>loadItems()}>Загрузить еще</button> } </div> ); }
Это прямолинейно, это просто, и это может быть лучше...
Загрузка данных в двух направлениях
Что делать, если пользователь посещает URL-адрес с номером страницы напрямую? Например, http://www.exampleUrl.com/?page=4 , как пользователи смогут получить доступ к контенту на первой, второй или третьей страницах? Ожидаем ли мы, что они сами отредактируют URL-адрес напрямую?
Мы должны предоставить пользователям способ загрузки предыдущей страницы, что можно сделать просто с помощью кнопки “Загрузить предыдущую” (или аналогичной), расположенной в верхней части списка элементов.
import { useHistory } from 'react-router-dom'; export const useInfiniteLoading = (props) => { const { getItems } = props; const [items, setItems] = React.useState([]); const pageToLoad = React.useRef(new URLSearchParams(window.location.search).get('page') || 1); const initialPageLoaded = React.useRef(false); // 1 const [hasNext, setHasNext] = React.useState(true); // 2 const [hasPrevious, setHasPrevious] = React.useState(() => pageToLoad.current !== 1); const history = useHistory(); const loadItems = async (page, itemCombineMethod) => { const data = await getItems({ page }); // 3 setHasNext(data.totalPages > pageToLoad.current); // 4 setHasPrevious(pageToLoad.current > 1); setItems(prevItems => { // 5 return itemCombineMethod === 'prepend' ? [...data.items, ...prevItems] : [...prevItems, ...data.items] }); }; const loadNext = () => { pageToLoad.current = Number(pageToLoad.current) + 1; history.replace(`?page=${pageToLoad.current}`); loadItems(pageToLoad.current, 'append'); } const loadPrevious = () => { pageToLoad.current = Number(pageToLoad.current) - 1; history.replace(`?page=${pageToLoad.current}`); loadItems(pageToLoad.current, 'prepend'); } React.useEffect(() => { if (initialPageLoaded.current) { return; } loadItems(pageToLoad.current, 'append'); initialPageLoaded.current = true; }, [loadItems]) return { items, hasNext, hasPrevious, loadNext, loadPrevious, }; }
- Порефакторим
hasMore
вhasNext
, так как он будет лучше читаться рядом со следующим пунктом - Добавим
hasPrevious
, который, по сути, будет отслеживать, загрузили ли мы самую низкую страницу (самая низкая страница - страница номер один). - Предполагая, что запрос
getItems
вернет информацию о странице, мы будем использовать значениеtotalPages
для сравнения со страницей, которую мы только что загрузили, чтобы определить, следует ли нам по-прежнему показывать “Загрузить больше”. - Если мы загрузили первую страницу, то нам больше не нужно отображать кнопку “Загрузить предыдущую”.
- Хотя хук не отвечает за рендеринг элементов, он отвечает за порядок, в котором эти элементы отображаются. Эта часть гарантирует, что при загрузке предыдущих элементов мы разместим их на экране перед текущими элементами. Это делает
key
prop
абсолютно критичным для компонента, который отображает элементы, поэтому обязательно имейте это в виду при использовании этого в проде
Вот как это будет выглядеть при правильном использовании:
import { useInfiniteLoading } from './useInfiniteLoading'; export default MyList = () => { const { items, hasNext, hasPrevious, loadNext, loadPrevious } = useInfiniteLoading({ getItems: ({ page }) => { /* Вызов API */ } }); return ( <div> {hasPrevious && <button onClick={() => loadPrevious()}>Загрузить предыдущую страницу</button> } <ul> {items.map(item => ( <li key={item.id}> {item.name} </li> ))} </ul> {hasNext && <button onClick={() =>loadNext()}>Загрузить еще</button> } </div> ) }
Кто-то из вас может заметить ошибку, которая только что была введена при реализации кнопки “Загрузить предыдущую страницу”. Для тех, кто этого не сделал, еще раз взгляните на код и спросите себя, что произойдет, если пользователь нажмет на кнопку “Загрузить предыдущую”, а затем нажмет на “Загрузить следующую”. Какие страницы будут загружаться?
Поскольку мы используем одну переменную для отслеживания последней загруженной страницы, код “забывает”, что мы уже загрузили следующую страницу этой предыдущей страницы. Это означает, что если пользователь начинает с пятой страницы (по прямой ссылке), затем нажимает “Загрузить предыдущую”, приложение прочитает ссылку на загрузку страницы, увидит, что пользователь находится на пятой странице, отправит запрос на получение элементов на четвертой странице, а затем обновит ссылку, чтобы указать пользователь просматривает данные на четвертой странице.
Затем пользователь может решить прокрутить страницу вниз и нажать кнопку “Загрузить еще”. Приложение посмотрит на значение ссылки pageToLoad
, увидит, что пользователь только что просматривал четвертую страницу, отправит запрос на данные пятой страницы, а затем обновит ссылку, чтобы указать, что пользователь просматривает данные пятой страницы. После этого очень простого взаимодействия у пользователя теперь есть данные четвертой страницы и два набора данных пятой страницы.
Чтобы обойти эту проблему, мы снова будем использовать некоторые ссылки для отслеживания самой низкой загруженной страницы и самой высокой загруженной страницы. Это будут переменные, которые мы используем для определения следующей загружаемой страницы:
const useInfiniteLoading = (props) => { // ... // 1 const initialPage = React.useRef(new URLSearchParams(window.location.search).get('page') || 1); // ... // 2 const lowestPageLoaded = React.useRef(initialPage.current); const highestPageLoaded = React.useRef(initialPage.current); const loadItems = (page, itemCombineMethod) => { // ... setHasNext(data.totalPages > page); setHasPrevious(page > 1); // ... } const loadNext = () => { // 3 const nextPage = highestPageLoaded.current + 1; loadItems(nextPage, 'append'); highestPageLoaded.current = nextPage; } const loadPrevious = () => { // 3 const nextPage = lowestPageLoaded.current - 1; if (nextPage < 1) return; // 4 loadItems(pageToLoad.current, 'prepend'); lowestPageLoaded.current = nextPage; } return { // ... }; }
- Преобразуйте
pageToLoad
вinitialPage
, так как он будет использоваться только для инициализации - Настройте две новые ссылки для отслеживания страниц, загружаемых в любом направлении
- Используйте ссылки отслеживания направления, чтобы определить следующую страницу для загрузки
- Проверьте безопасность, чтобы убедиться, что мы не пытаемся загружать страницы ниже первой страницы
Вот оно, бесконечная загрузка в двух направлениях! Обязательно обратите особое внимание на разбивку первого блока кода в этом разделе; пропуск значения key
(или использование индекса массива) приведет к ошибкам рендеринга, которые будет очень трудно исправить.
Автоматическая бесконечная загрузка
Наиболее эффективный способ реализовать что-либо, основанное на прокрутке - это использовать API Intersection Observer.
Несмотря на то, что мы находимся в React, где мы напрямую не взаимодействуем с отображаемыми HTML-элементами, настроить это все равно относительно просто. Используя ссылку, прикрепленную к кнопке “Загрузить больше”, мы можем определить, когда эта кнопка “Загрузить больше” находится в окне просмотра (или вот-вот появится в окне просмотра), а затем автоматически запускать действие на этой кнопке, загружая следующую страницу элементов.
Поскольку целью этой статьи является бесконечная загрузка, мы не собираемся вдаваться в детали реализации API Intersection Observer, а вместо этого используем существующий React-хук, который предоставляет нам эту функциональность, react-cool-inview.
Реализация с использованием react-cool-inview:
import useInView from 'react-cool-inview'; const useInfiniteLoading = (props) => { // ... const { observe } = useInView({ onEnter: () => { loadNext(); }, }); return { // ... loadMoreRef: observe }; }
В этом блоке мы используем loadMoreRef
на нашей кнопке “Загрузить больше”:
import { useInfiniteLoading } from './useInfiniteLoading'; export default MyList = () => { const { loadMoreRef /* ... */ } = useInfiniteLoading({ getItems: ({ page }) => { /* Вызов API */ } }); return ( <div> {/* ... */} {hasNext && <button ref={loadMoreRef} onClick={() =>loadNext()}> Загрузить больше </button> } </div> ) }
Как упоминалось ранее, мы можем ускорить автоматическую бесконечную загрузку, поиграв с опциями, предоставленными хуку наблюдателя пересечений. Например, вместо того, чтобы ждать, пока кнопка “Загрузить больше” появится в окне просмотра, подождите, пока она вот-вот появится в окне просмотра, или подождите, пока не появится одна строка элементов вне поля зрения, что позволит загрузить следующий набор элементов и, следовательно, не позволит пользователю когда-либо на самом деле увидеть кнопку “Загрузить больше”.
Это соображения, с которыми я рекомендую вам поиграться при реализации хука бесконечной загрузки.
Предотвращение запуска бесконечной загрузки при загрузке страницы
Существует распространенная проблема, возникающая при использовании API Intersection Observer для автоматического запуска загрузки страницы.
Когда элемент находится в окне просмотра, то время загрузки данных на странице нечего отображать, поэтому кнопка “Загрузить больше”, которая должна находиться под всеми элементами и за пределами области просмотра, фактически будет находиться внутри области просмотра до тех пор, пока эта первая страница данных не загрузится.
Способ исправить это - принудительно увеличить высоту элементов на странице, пока она находится в состоянии загрузки.
Наконец, у нас есть такая фишка как “загрузка данных в обе стороны”.
То есть, загружаем ли мы автоматически предыдущую страницу элементов с помощью Intersection Observer API?
Мы, конечно, могли бы, но я бы не рекомендовала этого – кнопка “Загрузить предыдущую” запустится в окне просмотра, что означает, что элементы предыдущей страницы будут загружаться автоматически.
Настройки бесконечные загрузки
Давайте начнем расширять наш хук бесконечной загрузки с помощью некоторых опций. У нас будет три варианта подключения:
Ручная загрузка
Это вариант, который мы кратко обсуждали ранее. Следующая страница элементов будет загружаться только тогда, когда пользователь нажмет на кнопку “Загрузить еще”. Реализовать это действительно просто, просто используя функцию обратного вызова, которая запускается, когда пользователь нажимает на кнопку.
Бесконечная бесконечная загрузка
Это забавно сказать, и представляет собой кнопку “Загрузить больше”, которая автоматически запускается приложением при прокрутке пользователем вниз.
Основным результатом этого параметра является то, что страницы данных будут продолжать загружаться до тех пор, пока пользователь прокручивает страницу, и до тех пор, пока есть еще элементы, которые можно загрузить.
Частичная бесконечная загрузка
Наконец, у нас есть патерн, представляющий собой смесь ручной и бесконечной бесконечной загрузки. Этот шаблон будет использовать ссылку для отслеживания того, сколько раз была запущена автоматическая загрузка страницы, и, как только это значение достигнет заданного максимума, автоматическая загрузка страниц прекратится и вместо этого пользователю придется вручную нажимать кнопку “Загрузить еще”.
Вот пример того, как мы могли бы настроить это в нашем хуке:
export const useInfiniteLoading = (props) => { // 1 const { loadingType, partialInfiniteLimit = -1 /* ... */ } = props; const remainingPagesToAutoload = React.useRef(loadingType === 'manual' ? 0 : partialInfiniteLimit); const loadMoreRef = React.useRef(null); const loadNext = () => {/* ... */} const { observe, unobserve } = useInView({ onEnter: () => { // 2 if (remainingPagesToAutoload.current === 0) { unobserve(); return; } remainingPagesToAutoload.current = remainingPagesToAutoload.current - 1; loadNext(); }, }); // ... return { loadMoreRef, handleLoadMore /* ... */ }; }
Здесь мы принимаем два новых пропса:
Первый - это loadingType
, который будет представлять собой одно из трех строковых значений: “ручное”, “частичное” и “бесконечное”.
Второй - partialInfiniteLimit
, который укажет, сколько раз функция loadNext
должна автоматически запускаться, когда loadingType
имеет значение “частичное”.
В реакте хуки не могут быть условными, поэтому мы просто отключаем крючок наблюдателя пересечения при первом его вызове для случаев, когда loadingType
является “ручным” или когда крючок достиг предела автоматической загрузки.
Заключительные мысли
Вот и все, теперь мы рассмотрели процесс создания хука бесконечной загрузки с некоторыми специальными дополнительными функциями.
Надеюсь вы узнали для себя что-то новое!
Не забывайте подписываться на телеграм канал, чтобы не пропустить новые статьи, до встречи ✌️
Читайте еще:
- Что такое сборщики JavaScript и зачем их использовать
- Возможно, вы не все знаете о useState...
- Безопасный каскад стилей CSS с :where