Вашему вниманию представляется react-redux-cache (RRC) — легковесная библиотека для загрузки и кэширования данных в React приложениях, которая поддерживает нормализацию, в отличие от React Query и RTK Query, при этом имеет похожий, но очень простой интерфейс. Построена на базе Redux, покрыта тестами, полностью типизирована и написана на Typescript.
RRC можно рассматривать как ApolloClient для протоколов, отличных от GraphQL (хотя теоретически и для него тоже), но с хранилищем Redux — с возможностью писать собственные селекторы (selector), экшены (action) и редьюсеры (reducer), имея полный контроль над кэшированным состоянием.
Зачем?
Далее пойдет сравнение с имеющимися библиотеками для управления запросами и состоянием. Почему вообще стоит пользоваться библиотеками для этого, а не писать все вручную с помощью useEffect / redux-saga и тп — оставим эту тему для других статей.
-
Полный контроль над хранилищем не только дает больше возможностей, упрощает отладку и написание кода, но и позволяет городить меньше костылей если задача выходит за рамки типичного hello world из документации, не тратя огромное время на страдания с очень сомнительными интерфейсами библиотек и чтением огромных исходников.
-
Redux это отличный — простой и проверенный инструмент для хранения “медленных” данных, то есть тех, что не требуют обновления на каждый кадр экрана / каждое нажатие клавиши пользователем. Порог входа для тех, кто знаком с библиотекой — минимальный. Экосистема предлагает удобную отладку и множетсво готовых решений, таких как хранение состояния на диске (redux-persist). Написан в функциональном стиле.
-
Нормализация — это лучший способ поддерживать согласованное состояние приложения между различными экранами, сокращает количество запросов и без проблем позволяет сразу отображать кэшированные данные при навигации, что значительно улучшает пользовательский опыт. А аналогов, поддерживающих нормализацию, практически нет — ApolloClient поддерживает только протокол GraphQL, и сделан в весьма сомнительном, переусложненном ООП стиле.
-
Легковесность, как размера библиотеки, так и ее интерфейса — еще одно преимущество. Чем проще, тем лучше — главное правило инженера, и данной конкретной библиотеки.
Краткое сравнение библиотек в таблице:
|
React Query |
Apollo Client |
RTK-Query |
RRC |
Полный доступ хранилищу |
— |
— |
+- |
+ |
Поддержка REST |
+ |
— |
+ |
+ |
Нормализация |
— |
+ |
— |
+ |
Бесконечная пагинация |
+ |
+ |
— |
+ |
Не переусложнена |
+ |
— |
— |
+ |
Популярность |
+ |
+ |
— |
— |
Почему только React?
Поддержка всевозможных UI библиотек кроме самой популярной (используемой в том числе в React Native) — усложнение, на которое я пока не готов.
Примеры
Для запуска примеров из папки /example
используйте npm run example
. Доступны три примера:
-
С нормализацией (рекомендуется).
-
Без нормализации.
-
Без нормализации, оптимизированный.
Данные примеры — лучшее доказательство того, как сильно зависит пользовательский опыт и нагрузка на серверы от реализации клиентского кэширования. В плохих реализациях на любую навигацию в приложении:
-
пользователь вынужден наблюдать спинеры и прочие состояния загрузки, будучи заблокированным в своих действиях, пока она не закончится.
-
запросы постоянно отправляются, даже если данные все еще достаточно свежие.
Пример состояния redux с нормализацией
{ entities: { // Каждый тип имеет свой словарь сущностей, хранящихся по id. users: { "0": {id: 0, bankId: "0", name: "User 0 *"}, "1": {id: 1, bankId: "1", name: "User 1 *"}, "2": {id: 2, bankId: "2", name: "User 2"}, "3": {id: 3, bankId: "3", name: "User 3"} }, banks: { "0": {id: "0", name: "Bank 0"}, "1": {id: "1", name: "Bank 1"}, "2": {id: "2", name: "Bank 2"}, "3": {id: "3", name: "Bank 3"} } }, queries: { // Каждый запрос имеет свой словарь состояний, хранящихся по ключу кэша, генерируемого из параметров запроса getUser: { "2": {loading: false, error: undefined, result: 2, params: 2}, "3": {loading: true, params: 3} }, getUsers: { // Пример состояния с пагинацией под переопределенным ключом кэша (см. далее в пункте про пагинацию) "all-pages": { loading: false, result: {items: [0,1,2], page: 1}, params: {page: 1} } } }, mutations: { // Каждая мутация так же имеет свое состояния updateUser: { loading: false, result: 1, params: {id: 1, name: "User 1 *"} } } }
Пример состояния redux без нормализации
{ // Словарь сущностей используется только для нормализации, и здесь пуст entities: {}, queries: { // Каждый запрос имеет свой словарь состояний, хранящихся по ключу кэша, генерируемого из параметров запроса getUser: { "2": { loading: false, error: undefined, result: {id: 2, bank: {id: "2", name: "Bank 2"}, name: "User 2"}, params: 2 }, "3": {loading: true, params: 3} }, getUsers: { // Пример состояния с пагинацией под переопределенным ключом кэша (см. далее в пункте про пагинацию) "all-pages": { loading: false, result: { items: [ {id: 0, bank: {id: "0", name: "Bank 0"}, name: "User 0 *"}, {id: 1, bank: {id: "1", name: "Bank 1"}, name: "User 1 *"}, {id: 2, bank: {id: "2", name: "Bank 2"}, name: "User 2"} ], page: 1 }, params: {page: 1} } } }, mutations: { // Каждая мутация так же имеет свое состояния updateUser: { loading: false, result: {id: 1, bank: {id: "1", name: "Bank 1"}, name: "User 1 *"}, params: {id: 1, name: "User 1 *"} } } }
Установка
react
, redux
и react-redux
являются peer-зависимостями.
npm add react-redux-cache react redux react-redux
Инициализация
Единственная функция, которую нужно импортировать — это createCache
, которая создаёт полностью типизированные редьюсер, хуки, экшены, селекторы и утилиты для использования в приложении. Можно создать столько кэшей, сколько нужно, но учтите, что нормализация не переиспользуется между ними. Все типы, запросы и мутации должны быть переданы при инициализации кэша для корректной типизации.
cache.ts
export const { cache, reducer, hooks: {useClient, useMutation, useQuery}, } = createCache({ // Используется как префикс для экшенов и в селекторе выбора состояния кэша из состояния redux name: 'cache', // Словарь соответствия нормализованных сущностей их типам TS // Можно оставить пустым, если нормализация не нужна typenames: { users: {} as User, // здесь сущности `users` будут иметь тип `User` banks: {} as Bank, }, queries: { getUsers: { query: getUsers }, getUser: { query: getUser }, }, mutations: { updateUser: { mutation: updateUser }, removeUser: { mutation: removeUser }, }, })
Для нормализации требуется две вещи:
-
Задать typenames при создании кэша — список всех сущностей и соответствующие им типы TS.
-
Возвращать из функций query или mutation объект, содержащий помимо поля result данные следующего типа:
type EntityChanges<T extends Typenames> = { // Сущности, что будут объединены с имеющимися в кэше merge?: PartialEntitiesMap<T> // Сущности что заменят имеющиеся в кэше replace?: Partial<EntitiesMap<T>> // Идентификаторы сущностей, что будут удалены из кэша remove?: EntityIds<T> // Алиас для `merge` для поддержки библиотеки normalizr entities?: EntityChanges<T>['merge'] }
store.ts
Создайте store как обычно, передав новый редьюсер кэша под именем кэша. Если нужна другая структура redux, нужно дополнительно передать селектор состояния кэша при создании кэша.
const store = configureStore({ reducer: { [cache.name]: reducer, ... } })
api.ts
Результат запроса должен быть типа QueryResponse
, результат мутации — типа MutationResponse
. Для нормализации в этом примере используется пакет normalizr, но можно использовать другие инструменты, если результат запроса соответствует нужному типу. В идеале — бэкэнд возвращает уже нормализованные данные.
По части race condition:
-
Для query используется throttling — пока идет запрос с определенными параметрами, другие с теми же параметрами отменяются.
-
Для мутаций используется debounce — каждая следующая мутация отменяет предыдущую, если та еще не завершилась. Для этого вторым параметром в мутации передается abortController.signal.
// Пример запроса с нормализацией (рекомендуется) export const getUser = async (id: number) => { const result = await ... const normalizedResult: { // result - id пользователя result: number // entities содержат все нормализованные сущности entities: { users: Record<number, User> banks: Record<string, Bank> } } = normalize(result, getUserSchema) return normalizedResult } // Пример запроса без нормализации export const getBank = (id: string) => { const result: Bank = ... return {result} } // Пример мутации с нормализацией export const removeUser = async (id: number, abortSignal: AbortSignal) => { await ... return { remove: { users: [id] }, // result не задан, но указан id пользователя, что должен быть удален из кэша } }
UserScreen.tsx
export const UserScreen = () => { const {id} = useParams() // useQuery подключается к состоянию redux, и если пользователь с таким id уже закэширован, // запрос не будет выполнен (по умолчанию политика кэширования 'cache-first') const [{result: userId, loading, error}] = useQuery({ query: 'getUser', params: Number(id), }) const [updateUser, {loading: updatingUser}] = useMutation({ mutation: 'updateUser', }) // Этот hook возвращает сущности с правильными типами — User и Bank const user = useSelectEntityById(userId, 'users') const bank = useSelectEntityById(user?.bankId, 'banks') if (loading) { return ... } return ... }
Продвинутые возможности
Расширенная политика кэширования
По умолчанию политика cache-first
не загружает данные, если результат уже закэширован, но иногда она не может определить, что данные уже присутствуют в ответе другого запроса или нормализованном кэше. В этом случае можно использовать параметр skip:
export const UserScreen = () => { ... const user = useSelectEntityById(userId, 'users') const [{loading, error}] = useQuery({ query: 'getUser', params: userId, skip: !!user // Пропускаем запрос, если пользователь уже закэширован ранее, например, запросом getUsers }) ... }
Мы можем дополнительно проверить, достаточно ли полный объект, или, например, время его последнего обновления:
skip: !!user && isFullUser(user)
Другой подход — установить skip: true и вручную запускать запрос, когда это необходимо:
export const UserScreen = () => { const screenIsVisible = useScreenIsVisible() const [{result, loading, error}, fetchUser] = useQuery({ query: 'getUser', params: userId, skip: true }) useEffect(() => { if (screenIsVisible) { fetchUser() } }, [screenIsVisible]) ... }
Бесконечная прокрутка с пагинацией
Вот пример конфигурации запроса getUsers
с поддержкой бесконечной пагинации — фичи, недоступной в RTK-Query (facepalm). Полную реализацию можно найти в папке /example
.
// createCache ... } = createCache({ ... queries: { getUsers: { query: getUsers, getCacheKey: () => 'all-pages', // Для всех страниц используется единый ключ кэша mergeResults: (oldResult, {result: newResult}) => { if (!oldResult || newResult.page === 1) { return newResult } if (newResult.page === oldResult.page + 1) { return { ...newResult, items: [...oldResult.items, ...newResult.items], } } return oldResult }, }, }, ... }) // Компонент export const GetUsersScreen = () => { const [{result: usersResult, loading, error, params}, fetchUsers] = useQuery({ query: 'getUsers', params: 1 // страница }) const refreshing = loading && params === 1 const loadingNextPage = loading && !refreshing const onRefresh = () => fetchUsers() const onLoadNextPage = () => { const lastLoadedPage = usersResult?.page ?? 0 fetchUsers({ query: 'getUsers', params: lastLoadedPage + 1, }) } const renderUser = (userId: number) => ( <UserRow key={userId} userId={userId}> ) ... return ( <div> {refreshing && <div className="spinner" />} {usersResult?.items.map(renderUser)} <button onClick={onRefresh}>Refresh</button> {loadingNextPage ? ( <div className="spinner" /> ) : ( <button onClick={onLoadNextPage}>Load next page</button> )} </div> ) }
redux-persist
Вот простейшая конфигурация redux-persist:
// Удаляет `loading` и `error` из сохраняемого состояния function stringifyReplacer(key: string, value: unknown) { return key === 'loading' || key === 'error' ? undefined : value } const persistedReducer = persistReducer( { key: 'cache', storage, whitelist: ['entities', 'queries'], // Cостояние мутаций не сохраняем throttle: 1000, // ms serialize: (value: unknown) => JSON.stringify(value, stringifyReplacer), }, cacheReducer )
Заключение
Хоть проект и находится на стадии развития, но уже готов к использованию. Конструктивная критика и квалифицированная помощь приветствуется.
ссылка на оригинал статьи https://habr.com/ru/articles/842940/
Добавить комментарий