Привет, друзья!
В этой статье я хочу поделиться с вами опытом разработки хука для загрузки дополнительных данных (авось кому-нибудь пригодится).
На самом деле, хуков будет целых 2 штуки:
useLoadMore
— для загрузки дополнительных данных при нажатии кнопки "Загрузить еще"useLoadPage
— для постраничной загрузки данных (аля пагинация на основе курсора)
Первый хук попроще, второй посложнее.
Полагаю, лишним будет говорить, что необходимость в использовании подобных хуков возникает в каждом втором проекте (по крайней мере, в моей сфере деятельности).
Для запуска проекта в песочнице в терминале необходимо выполнить команду yarn start
.
Команды для локального запуска проекта:
# клонируем репозиторий
git clone https://github.com/harryheman/use-load.git
# переходим в директорию с проектом
cd use-load
# устанавливаем общие зависимости
yarn
# устанавливаем зависимости для сервера
cd server && yarn
# устанавливаем зависимости для клиента
cd .. && cd client && yarn
# запускаем сервер для разработки
cd .. && yarn start
Скриншот страницы, на которой используется хук useLoadPage
, для затравки:
Проект состоит из двух частей:
- сервера для генерации фиктивных данных и обработки запросов, поступающих от клиента;
- клиента для выполнения запросов и отображения данных, полученных от сервера.
Сервер написан на Node
, клиент — на React
и TypeScript
.
Структура проекта:
Код сервера состоит из 2 файлов:
index.js
— код для "роутов" иexpress-сервера
seed.js
— код для генерации фиктивных данных с помощьюfaker
Фиктивные данные представляют собой массив из 100
товаров (allItems
). Каждый товар — это примерно такой объект:
{
"id": "5b45a471-3429-4bde-bf0e-1750e84fd4bd",
"title": "Generic Plastic Chair",
"description": "Ergonomic executive chair upholstered in bonded black leather and PVC padded seat and back for all-day comfort and support",
"price": "940.00",
"image": "http://placeimg.com/640/480/tech?82281"
}
Товары разделены на 10
страниц (allPages
). Каждая страница — это объект, ключом которого является номер страницы, а значением — массив из 10
товаров:
// pages
{
1: [
{
// item
},
],
// etc.
}
После генерации фиктивные данные записываются в файл server/data/fake.json
.
Сервер обрабатывает запросы к 3 конечным точкам:
/all-items
: в ответ возвращаются все товары —{ items: allItems }
/more-items
: в ответ возвращается часть товаров, начиная с первого и заканчивая номером страницы из строки запроса (req.query
), умноженной на10
, а также общее количество страниц —{ items: allItems.slice(0, page * 10), totalPages: Object.keys(allPages).length }
/items-by-page
: в ответ возвращается10
товаров, соответствующих номеру страницы из строки запроса, а также общее количество страниц —{ items: allPages[page], totalPages: Object.keys(allPages).length }
В целом, это все, что касается сервера.
На клиенте у нас имеется следующее:
API
для взаимодействия с сервером (api/index.ts
), включающее 3 функции, соответствующие 3 конечным точкам на сервере:
fetchAllItems
— функция для получения всех товаровfetchItemsAndPages
иfetchItemsByPage
— функции для получения части товаров на основе номера (значения) страницы
- 3 страницы (
pages
), соответствующие 3API-функциям
:
AllProducts.tsx
— страница для отображения всех товаров. На этой странице используется функцияfetchAllItems
. Роут для страницы —/
MoreProducts.tsx
— страница для отображения части товаров с возможностью загрузки дополнительных товаров при нажатии кнопки "Загрузить еще" в виде "?". На этой странице используется хукuseLoadMore
, которому в качестве аргумента передается функцияfetchItemsAndPages
. Здесь мы двигаемся только вперед. Роут для страницы —/more-products
ProductsByPage.tsx
— страница для постраничного отображения товаров с возможностью переключения страниц при нажатии кнопок "Вперед" в виде "?" или "Назад" в виде "?". На этой странице используется хукuseLoadPage
, которому в качестве аргумента передается функцияfetchItemsByPage
. В данном случае мы можем двигаться в обоих направлениях, т.е. как вперед, так и назад. Роут для страницы —/products-by-page
- 3 компонента (
components
):
Navbar.tsx
— панель навигации для переключения между страницами приложенияProductCard.tsx
— карточка товараProductList.tsx
— список товаров
- 2 хука (
hooks
):
useLoadMore.ts
— хук для загрузки дополнительных товаровuseLoadPage.ts
— хук для загрузки товаров, соответствующих определенной странице
Перейдем непосредственно к рассмотрению хуков.
Начнем с более простого — useLoadMore
.
Импортируем хуки из react
и react-router-dom
, а также типы для функции получения товаров и объекта товара из types.ts
:
import { useEffect, useRef, useState } from 'react'
import { useHistory } from 'react-router-dom'
import { Item, FetchItems } from 'types'
Типы выглядят так:
export type Item = {
id: string
title: string
description: string
price: number
image: string
}
export type AllItems = {
items: Item[]
}
export type ItemsAndPages = AllItems & {
totalPages: number
}
export type FetchItems = (page: number) => Promise<ItemsAndPages>
Определяем тип для объекта, возвращаемого хуком:
type UseLoadMoreReturn = {
loading: boolean
items: Item[]
loadMore: () => void
hasMore: boolean
}
Как мы видим, хук возвращает следующее:
- индикатор загрузки;
- товары;
- метод для загрузки дополнительных товаров;
- индикатор наличия товаров.
Определяем хук:
// хук принимает единственный параметр - функцию для получения дополнительных данных
export const useLoadMore = (fetchItems: FetchItems): UseLoadMoreReturn => {
// код хука
}
Определяем переменные для товаров, значения (номера) текущей страницы, всех (доступных) страниц и индикатора загрузки, а также получаем объект истории браузера:
// товары
const [items, setItems] = useState<Item[]>([])
// значение текущей страницы либо извлекается из строки запроса,
// например, `?page=1`, либо устанавливается в значение 1
const page = Number(new URLSearchParams(window.location.search).get('page'))
const currentPage = useRef(page > 0 ? page : 1)
// все страницы
const allPages = useRef(0)
// индикатор загрузки
const [loading, setLoading] = useState(false)
// объект истории
const history = useHistory()
Определяем функцию (внутренний метод) для загрузки товаров:
// функция принимает единственный параметр - номер страницы
async function loadItems(page: number) {
setLoading(true)
try {
const { items, totalPages } = await fetchItems(page)
setItems(items)
// меняем значение переменной только при инициализации и изменении данных из ответа сервера
if (allPages.current !== totalPages) {
allPages.current = totalPages
}
} catch (e) {
console.error(e)
} finally {
setLoading(false)
}
}
Выполняем однократный побочный эффект для загрузки товаров на основе значения текущей страницы:
useEffect(() => {
loadItems(currentPage.current)
// eslint-disable-next-line
}, [])
Определяем функцию (публичный интерфейс) для загрузки дополнительных товаров:
function loadMore() {
// код функции выполняется только при условии, что значение текущей страницы
// меньше количества доступных страниц
if (currentPage.current < allPages.current) {
// в данном случае мы двигаемся только в одном направлении
// поэтому следующая страница - это всегда текущая страница + 1
const nextPage = currentPage.current + 1
// обновляем значение текущей страницы
currentPage.current = nextPage
// манипулируем адресом страницы
history.replace(`?page=${nextPage}`)
// загружаем товары
loadItems(nextPage)
}
}
Зачем мы манипулируем адресом страницы? На это существует, как минимум, 2 причины:
- это позволяет пользователю вернуться к тому списку, с которого он ушел, например, переключившись на страницу товара (в проекте не реализовано, но это можно увидеть, если переключиться на другую страницу и нажать "Назад" в браузере). В идеале, на странице также должно быть реализовано восстановление прокрутки (
scroll restoration
), но это тема для отдельной статьи; - это позволяет поделиться ссылкой на товар, который находится дальше первой страницы. Если, например, перейти по прямой ссылке
more-products?pages=3
, то будет загружено не10
, а30
первых товаров (здесь опять же не хватает привязки к конкретному товару).
Наконец, возвращаем объект:
return {
loading,
items,
loadMore,
// обратите внимание, что здесь мы должны использовать `<`, а не `<=`
hasMore: currentPage.current < allPages.current
}
Пример использования этого хука можно увидеть на странице MoreProducts.tsx
.
При нажатии кнопки ?
вызывается функция loadMore
из хука. Когда индикатор наличия товаров получается значение false
, эта кнопка не рендерится. Данный индикатор также можно установить в качестве значения атрибута disabled
кнопки. Даже если разблокировать кнопку через редактирование разметки, загрузки несуществующих товаров не произойдет благодаря проверке currentPage.current < allPages.current
.
Теперь рассмотрим более продвинутый хук — useLoadPage
.
В начале мы также импортируем хуки и типы:
import { useEffect, useRef, useState } from 'react'
import { useHistory } from 'react-router-dom'
import { Item, FetchItems } from 'types'
Определяем тип для возвращаемого хуком объекта:
type UseLoadPageReturn = {
loading: boolean
items: Item[]
hasNext: boolean
hasPrev: boolean
loadNext: () => void
loadPrev: () => void
currentPage: number
allPages: number
loadPage: (page: number) => void
}
Как мы видим, хук возвращает много всего интересного:
loading
— индикатор загрузкиitems
— товарыhasNext
— индикатор наличия следующей страницы товаровhasPrev
— индикатор наличия предыдущей страницыloadNext
— функция для загрузки следующей страницыloadPrev
— функция для загрузки предыдущей страницыcurrentPage
— текущая страницаallPages
— все страницыloadPage
— функция для загрузки товаров, соответствующих определенной странице
В дополнение к этому я решил реализовать кеширование загруженных ранее страниц товаров. Определяем тип для соответствующего объекта:
type PagesCache = {
[page: string]: Item[]
}
Приступаем к реализации хука:
// хук принимает единственный параметр - функцию для получения дополнительных данных
export const useLoadPage = (fetchItems: FetchItems): UseLoadPageReturn => {
// код хука
}
Определяем переменные для товаров, кеша, текущей страницы, первой страницы, всех страниц и индикатора загрузки, а также получаем объект истории браузера:
// товары
const [items, setItems] = useState<Item[]>([])
// кеш для товаров
const cachedItems = useRef<PagesCache>({})
// значение текущей страницы либо извлекается из строки запроса,
// например, `?page=1`, либо устанавливается в значение 1
const page = Number(new URLSearchParams(window.location.search).get('page'))
const currentPage = useRef(page > 0 ? page : 1)
// первая страница - хак (см. ниже)
const firstPage = useRef(Infinity)
// все страницы
const allPages = useRef(0)
// индикатор загрузки
const [loading, setLoading] = useState(false)
// объект истории
const history = useHistory()
Определяем функцию (внутренний метод) для загрузки товаров:
// функция принимает единственный параметр - номер страницы
async function loadItems(page: number) {
// если для переданной страницы в кеше имеются товары
if (cachedItems.current[page]) {
// возвращаем их без выполнения запроса к серверу
return setItems(cachedItems.current[page])
}
setLoading(true)
try {
const { items, totalPages } = await fetchItems(page)
setItems(items)
// записываем загруженные товары в кеш - ключом является номер страницы
cachedItems.current[page] = items
// обновляем значения переменных для всех и первой страницы
if (allPages.current !== totalPages) {
allPages.current = totalPages
firstPage.current = 1
}
} catch (e) {
console.error(e)
} finally {
setLoading(false)
}
}
Выполняем однократный побочный эффект для загрузки товаров на основе значения текущей страницы:
useEffect(() => {
loadItems(currentPage.current)
// eslint-disable-next-line
}, [])
Определяем функцию (публичный интерфейс) для загрузки товаров по номеру страницы:
// функция принимает единственный параметр - номер страницы
function loadPage(page: number) {
currentPage.current = page
history.replace(`?page=${page}`)
loadItems(page)
}
Определяем функции (публичный интерфейс) для загрузки следующей и предыдущей страниц товаров:
function loadNext() {
// код функции выполняется только при условии, что значение текущей страницы
// меньше количества доступных страниц
if (currentPage.current < allPages.current) {
const nextPage = currentPage.current + 1
loadPage(nextPage)
}
}
function loadPrev() {
// код функции выполняется только при условии, что значение текущей страницы
// больше значения первой страницы
if (currentPage.current > firstPage.current) {
const nextPage = currentPage.current - 1
loadPage(nextPage)
}
}
Наконец, возвращаем объект:
return {
loading,
items,
hasNext: currentPage.current < allPages.current,
hasPrev: currentPage.current > firstPage.current,
loadNext,
loadPrev,
currentPage: currentPage.current,
allPages: allPages.current,
loadPage
}
Хак с первой страницей нужен для первоначального рендеринга страницы ProductsByPage
, когда мы начинаем со второй и далее страницы. Если определить первую страницу явно (т.е. как 1
), то индикатор hasPrev
получит значение true
и мы увидим заблокированную кнопку "Назад" над "лоадером". Попробуйте поэкспериментировать. Возможно, вы найдете лучшее решение.
Пример использования данного хука можно увидеть на странице ProductsByPage
. Вот для чего используется каждое из возвращаемых хуком значений:
loading
— если имеет значениеtrue
, вместо списка товаров рендерится лоадер, кнопки для переключения между страницами блокируютсяitems
— передаются в качестве пропа компонентуProductList
для отображения списка товаровhasNext
— если имеет значениеtrue
, кнопка для загрузки следующей страницы товаров не рендеритсяhasPrev
— не рендерится кнопка для загрузки предыдущей страницы товаровloadNext
— вызывается при нажатии кнопки?
loadPrev
— вызывается при нажатии кнопки?
currentPage
— используется при формировании компонентаPageLinks
для определения текущей страницы товаров и ее визуальной индикацииallPages
— используется при формировании компонентаPageLinks
для определения общего количества "ссылок"loadPage
— вызывается при клике по ссылке изPageLinks
На странице ProductsByPage
также реализовано переключение между страницами товаров при нажатии стрелок на клавиатуре.
Пожалуй, это все, чем я хотел поделиться с вами в данной статье.
Буду рад любой форме обратной связи.
Благодарю за внимание и хорошего дня!
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩