Библиотека RRC для управления запросами и кэшем на базе Redux: [лучшая] альтернатива RTK-Query и другим решениям

от автора

Вашему вниманию представляется 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *