Привет, друзья!
В этой статье я хочу поделиться с вами опытом разработки хука для загрузки дополнительных данных (авось кому-нибудь пригодится).
На самом деле, хуков будет целых 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
также реализовано переключение между страницами товаров при нажатии стрелок на клавиатуре.
Пожалуй, это все, чем я хотел поделиться с вами в данной статье.
Буду рад любой форме обратной связи.
Благодарю за внимание и хорошего дня!
Комментарии (9)
namikiri
22.10.2021 16:25+1Автор статьи, уберите, пожалуйста, виджет StackBlitz под кат. Иначе он передёргивает фокус на себя и главная Хабра автоматом прокручивается на него при каждом открытии.
faiwer
22.10.2021 16:47+1А лучше под <spoiler/>, чтобы не только главная не прыгала, но ещё и сама страница статьи.
faiwer
Пара советов:
useReducer
. Проще хранить всё в одном объекте (все этиpage
и пр. штуки, которые вы почему-то храните в ref-ах), и менять разом.console.error
-ы раскиданные по коду это плохо. И вообще хорошо бы уметь возвращать её (ошибку) из хука.Мы используем похожую, но кратно более сложную схему.
P.S. ещё можно посмотреть в сторону отказа от
page
, в пользу курсоров.faiwer
Вот тут грубая ошибка. Либо у вас это реактивный state, и тогда вы можете вернуть это из хука наружу. Либо это не реактивный стейт, и тогда render vDom древа ни в коем случае не должен от таких значений зависеть. Т.е. не используйте
useRef
для рективных вещей. Для этого естьuseState
иuseReducer
(ну и всякиеmobX
и прочие внешние сторы).Возможно у вас сейчас этот баг никак не проявляется ввиду того, что помимо
currentPage
иallPages
обновляется что-нибудь ещё и соответственно необходимыйrender
всё равно происходит. Но на такие вещи точно нельзя полагаться. Это скорее из области "случайно работает".Если вы используете
useRef
вместоuseState
только чтобы избежать лишних ререндеров —useReducer
илиunstable_...
вам в помощь. А сейчас вы ходите по минному полю.aio350 Автор
Ты же не думаешь, что я представил общественности первые версии хуков?) Изначально `currentPage` и `allPages` не должны были быть частью публичного интерфейса. Когда в этом возникла необходимость, я начал с `useState`. Результатом стал многократный повторный рендеринг при переключении страницы (`hasPrev` и `hasNext` также были реактивными). Потом я понял, что любое взаимодействие с хуком - это вызов `loadItems`, который влечет повторное вычисление кода хука за счет обновления состояния `items` или, в крайнем случае, `loading`.
faiwer
Перечитай внимательно комментарий выше. Использование
useRef
для реактивных значений — грубая ошибка. Ибо это бомба замедленного действия. Тот самый скользкий тип багов, которые потом тяжело воспроизводить и выяснять причины странного поведения. Один из самых дорогостоящих видов багов для бизнеса.Да я вижу, что смена
items
всё равно вызывает ререндер, но на такие вещи полагаться нельзя. Ни в коем случае. Минимальный рефакторинг в будущем, когда человек не будет иметь всей картинки костылей в голове, легко поломает этот "код". И да, текущее "случайно" работающее поведение это как раз костыль. Красный флаг.Вот чтобы таких вещей не было надо вникать в то как хуки работают, какие задачи они выполняют, и каков вообще hook way в реакте. Судя по всему (по твоим ответам и коду в статье) ты пока пишешь "на ощупь". Отсюда и типовые ошибки и типовые костыли. Серьёзно, я не хочу обидеть, просто это видно издалека.
Вот это тоже грубая ошибка. Которую, насколько я понял, ты уже усвоил. Тут действует простое правило — всё что можно посчитать на основе уже существующих данных — не нужно хранить в стейте. Максимум мемоизировать (
useMemo
), если вычисления тяжёлые. Причина банальна — ручная синхронизация = новый источник багов = дорого. В твоём случае ещё и rerender-ы.За что боролся на то и напоролся. Когда пишешь core-вещи, т.е. обобщённый многократно переиспользуемый код (а твой хук как раз из таких), то это должна быть вылизанная до мелочей оптимизированная штука. Иначе — руки прочь из core части. Даже вопроса такого не должно возникать.
А где я сказал "всегда"? У нас на 80к строк кода всего несколько
useReducer
. У тебя как раз такой случай, когдаuseReducer
упрощает понимание кода, убирает лишние рендеры, легко scale-уется в случае сложных доработок. То, что доктор прописал. Да ещё и все переменные тесно связанные между собой. Особенно если учесть что в настоящем боевом коде этот хук будет куда сложнее, когда полезут corner case-ы.Не возможно, а точно, я тебе говорю. На этапе system design такой ответ это красный флаг и "мы вам перезвоним". Тебе завтра потребуется подключить этот хук в другую часть приложения где более сложная работа с URL (или просто другая) и тебе придётся выпиливать всё до последней буквы. Да даже просто наличие на странице сразу двух постраничных виджетов (или списка списокв) и "приехали".
А причина банальная — это не задача для хука который занимается вызовом асинхр. метода который подтягивает данные согласно постраничной навигации. "low in coupling and high in cohesion" — вот главная мантра любой архитектуры. Из неё автоматически вытекает что не должно быть таких пучков которые умеют во всё сразу, особенно как-то конкретно (
?page=
в любой URL игнорируя рутинг приложения).beDenz
Что вы имеете ввиду под "курсором"?
faiwer
Когда вместо
SELECT ... OFFSET {(page - 1) * limit} LIMIT {limit}
используются более сложные схемы. Например берётсяitems.last().createdBy.toUnixTime()
и возвращается в качестве курсора\якоря\как-угодно-можно-назвать. А на сервереWHERE createdBy >= {cursor}
.Это не даёт вам общего числа "страниц", зато сильно улучшает выборку данных, когда просматриваемый список элементов не статичен. Из-за постраничной навигации вы получаете дублирующие элементы или вообще теряете часть. Просто потому что между кликаниями по страницам кто-то меняет выборку.
aio350 Автор
Спасибо за ревью, друг.
1. Я бы не сказал, что "хранить все в одном объекте" всегда проще.
2. По поводу гонки условий ты (ничего, что я на ты?) прав. Добавил парочку условий.
3. Согласен, но это всего лишь пример хука, а не полноценное приложение. Думаю, что возвращение ошибки лучше оставить `fetchItems`.
4. Согласен, мне просто хотелось показать, как это можно сделать. Если в качестве `fetchItems` использовать `swr`, например, то за кеширование будет отвечать хук `useSWR`.
5. Возможно.
6. Нет смысла, оптимизация будет преждевременной.