إنشاء تطبيقات React باستخدام مجموعة أدوات Redux واستعلام RTK
نشرت: 2022-03-11هل سبق لك أن أردت استخدام Redux مع ميزات مثل React Query التي توفرها؟ يمكنك الآن ، باستخدام مجموعة أدوات Redux وإضافتها الأخيرة: RTK Query.
RTK Query هي أداة متقدمة لجلب البيانات والتخزين المؤقت من جانب العميل. تشبه وظيفتها وظيفة React Query ولكنها تتمتع بميزة التكامل المباشر مع Redux. بالنسبة لتفاعل API ، يستخدم المطورون عادةً وحدات وسيطة غير متزامنة مثل Thunk عند العمل مع Redux. مثل هذا النهج يحد من المرونة ؛ وبالتالي ، أصبح لدى مطوري React الآن بديل رسمي من فريق Redux يغطي جميع الاحتياجات المتقدمة لاتصالات العميل / الخادم اليوم.
توضح هذه المقالة كيف يمكن استخدام RTK Query في سيناريوهات العالم الحقيقي ، وتتضمن كل خطوة رابطًا إلى فرق الالتزام لتسليط الضوء على الوظائف المضافة. يظهر ارتباط إلى قاعدة البيانات الكاملة في النهاية.
Boilerplate والتكوين
فرق بدء المشروع
أولاً ، نحتاج إلى إنشاء مشروع. يتم ذلك باستخدام نموذج Create React App (CRA) للاستخدام مع TypeScript و Redux:
npx create-react-app . --template redux-typescriptلديها العديد من التبعيات التي سنطلبها على طول الطريق ، وأبرزها:
- مجموعة أدوات الإحياء واستعلام RTK
- واجهة المستخدم المادية
- لوداش
- فورميك
- رد فعل راوتر
يتضمن أيضًا القدرة على توفير تكوين مخصص لحزمة الويب. عادةً ، لا تدعم CRA هذه القدرات إلا إذا قمت بإخراجها.
التهيئة
الطريق الأكثر أمانًا من الإخراج هو استخدام شيء يمكنه تعديل التكوين ، خاصةً إذا كانت هذه التعديلات صغيرة. يستخدم هذا النموذج المعياري رد فعل التطبيق-تم إعادة توصيله وتخصيصه لإنجاز هذه الوظيفة لتقديم تكوين babel مخصص:
const plugins = [ [ 'babel-plugin-import', { 'libraryName': '@material-ui/core', 'libraryDirectory': 'esm', 'camel2DashComponentName': false }, 'core' ], [ 'babel-plugin-import', { 'libraryName': '@material-ui/icons', 'libraryDirectory': 'esm', 'camel2DashComponentName': false }, 'icons' ], [ 'babel-plugin-import', { "libraryName": "lodash", "libraryDirectory": "", "camel2DashComponentName": false, // default: true } ] ]; module.exports = { plugins };هذا يجعل تجربة المطور أفضل من خلال السماح بالواردات. علي سبيل المثال:
import { omit } from 'lodash'; import { Box } from '@material-ui/core';عادةً ما تؤدي عمليات الاستيراد هذه إلى زيادة حجم الحزمة ، ولكن مع وظيفة إعادة الكتابة التي قمنا بتكوينها ، ستعمل هذه الوظائف على النحو التالي:
import omit from 'lodash/omit'; import Box from '@material-ui/core/Box';إعدادات
فرق إعداد الإحياء
نظرًا لأن التطبيق بأكمله يعتمد على Redux ، فسنحتاج بعد التهيئة إلى إعداد تكوين المتجر:
import { combineReducers, configureStore } from '@reduxjs/toolkit'; import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; import { Reducer } from 'redux'; import { FLUSH, PAUSE, PERSIST, persistStore, PURGE, REGISTER, REHYDRATE } from 'redux-persist'; import { RESET_STATE_ACTION_TYPE } from './actions/resetState'; import { unauthenticatedMiddleware } from './middleware/unauthenticatedMiddleware'; const reducers = {}; const combinedReducer = combineReducers<typeof reducers>(reducers); export const rootReducer: Reducer<RootState> = ( state, action ) => { if (action.type === RESET_STATE_ACTION_TYPE) { state = {} as RootState; } return combinedReducer(state, action); }; export const store = configureStore({ reducer: rootReducer, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: { ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER] } }).concat([ unauthenticatedMiddleware ]), }); export const persistor = persistStore(store); export type AppDispatch = typeof store.dispatch; export type RootState = ReturnType<typeof combinedReducer>; export const useTypedDispatch = () => useDispatch<AppDispatch>(); export const useTypedSelector: TypedUseSelectorHook<RootState> = useSelector;بصرف النظر عن تكوين المتجر القياسي ، سنضيف تكوينًا لإجراء إعادة تعيين عالمي يكون مفيدًا في تطبيقات العالم الحقيقي ، سواء للتطبيقات نفسها أو للاختبار:
import { createAction } from '@reduxjs/toolkit'; export const RESET_STATE_ACTION_TYPE = 'resetState'; export const resetStateAction = createAction( RESET_STATE_ACTION_TYPE, () => { return { payload: null }; } );بعد ذلك ، سنضيف برمجية وسيطة مخصصة للتعامل مع ردود 401 ببساطة عن طريق مسح المتجر:
import { isRejectedWithValue, Middleware } from '@reduxjs/toolkit'; import { resetStateAction } from '../actions/resetState'; export const unauthenticatedMiddleware: Middleware = ({ dispatch }) => (next) => (action) => { if (isRejectedWithValue(action) && action.payload.status === 401) { dispatch(resetStateAction()); } return next(action); };حتى الان جيدة جدا. لقد أنشأنا النموذج المعياري وقمنا بتكوين Redux. الآن دعنا نضيف بعض الوظائف.
المصادقة
استرداد اختلاف رمز الوصول
يتم تقسيم المصادقة إلى ثلاث خطوات من أجل البساطة:
- إضافة تعريفات API لاسترداد رمز الوصول
- إضافة مكونات للتعامل مع تدفق مصادقة الويب GitHub
- إنهاء المصادقة من خلال توفير مكونات الأداة المساعدة لتزويد المستخدم بالتطبيق بأكمله
في هذه الخطوة ، نضيف القدرة على استرداد رمز الوصول.
تملي إيديولوجية RTK Query أن تظهر جميع تعريفات API في مكان واحد ، وهو أمر مفيد عند التعامل مع التطبيقات على مستوى المؤسسة مع عدة نقاط نهاية. في تطبيق المؤسسة ، من الأسهل بكثير التفكير في واجهة برمجة التطبيقات المتكاملة ، بالإضافة إلى التخزين المؤقت للعميل ، عندما يكون كل شيء في مكان واحد.
يتميز RTK Query بأدوات لإنشاء تعريفات API تلقائيًا باستخدام معايير OpenAPI أو GraphQL. هذه الأدوات لا تزال في مهدها ، ولكن يتم تطويرها بنشاط. بالإضافة إلى ذلك ، تم تصميم هذه المكتبة لتوفير تجربة مطور ممتازة مع TypeScript ، والتي أصبحت خيارًا متزايدًا لتطبيقات المؤسسات نظرًا لقدرتها على تحسين إمكانية الصيانة.
في حالتنا ، ستوجد التعريفات ضمن مجلد API. في الوقت الحالي ، طلبنا هذا فقط:
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import { AuthResponse } from './types'; export const AUTH_API_REDUCER_KEY = 'authApi'; export const authApi = createApi({ reducerPath: AUTH_API_REDUCER_KEY, baseQuery: fetchBaseQuery({ baseUrl: 'https://tp-auth.herokuapp.com', }), endpoints: (builder) => ({ getAccessToken: builder.query<AuthResponse, string>({ query: (code) => { return ({ url: 'github/access_token', method: 'POST', body: { code } }); }, }), }), });يتم توفير مصادقة GitHub عبر خادم مصادقة مفتوح المصدر ، يتم استضافته بشكل منفصل على Heroku نظرًا لمتطلبات GitHub API.
خادم المصادقة
على الرغم من أنه ليس مطلوبًا لهذا المشروع كمثال ، فإن القراء الراغبين في استضافة نسختهم الخاصة من خادم المصادقة سيحتاجون إلى:
- أنشئ تطبيق OAuth في GitHub لإنشاء معرّف وسر العميل الخاصين بهم.
- قم بتوفير تفاصيل GitHub لخادم المصادقة عبر متغيري البيئة
GITHUB_CLIENT_IDوGITHUB_SECRET. - استبدل قيمة
baseUrlلنقطة نهاية المصادقة في تعريفات API أعلاه. - على جانب React ، استبدل المعلمة
client_idفي نموذج التعليمات البرمجية التالي.
الخطوة التالية هي إضافة المكونات التي تستخدم واجهة برمجة التطبيقات هذه. نظرًا لمتطلبات تدفق تطبيق الويب GitHub ، سنحتاج إلى مكون تسجيل دخول مسؤول عن إعادة التوجيه إلى GitHub:
import { Box, Container, Grid, Link, Typography } from '@material-ui/core'; import GitHubIcon from '@material-ui/icons/GitHub'; import React from 'react'; const Login = () => { return ( <Container maxWidth={false}> <Box height="100vh" textAlign="center" clone> <Grid container spacing={3} justify="center" alignItems="center"> <Grid item xs="auto"> <Typography variant="h5" component="h1" gutterBottom> Log in via Github </Typography> <Link href={`https://github.com/login/oauth/authorize?client_id=b1bd2dfb1d172d1f1589`} color="textPrimary" data-test aria-label="Login Link" > <GitHubIcon fontSize="large"/> </Link> </Grid> </Grid> </Box> </Container> ); }; export default Login; بمجرد إعادة توجيه GitHub إلى تطبيقنا ، سنحتاج إلى مسار للتعامل مع الرمز واسترداد access_token بناءً عليه:
import React, { useEffect } from 'react'; import { Redirect } from 'react-router'; import { StringParam, useQueryParam } from 'use-query-params'; import { authApi } from '../../../../api/auth/api'; import FullscreenProgress from '../../../../shared/components/FullscreenProgress/FullscreenProgress'; import { useTypedDispatch } from '../../../../shared/redux/store'; import { authSlice } from '../../slice'; const OAuth = () => { const dispatch = useTypedDispatch(); const [code] = useQueryParam('code', StringParam); const accessTokenQueryResult = authApi.endpoints.getAccessToken.useQuery( code!, { skip: !code } ); const { data } = accessTokenQueryResult; const accessToken = data?.access_token; useEffect(() => { if (!accessToken) return; dispatch(authSlice.actions.updateAccessToken(accessToken)); }, [dispatch, accessToken]); إذا سبق لك استخدام React Query ، فإن آلية التفاعل مع API مشابهة لـ RTK Query. يوفر هذا بعض الميزات الرائعة بفضل تكامل Redux الذي سنلاحظه أثناء تنفيذ ميزات إضافية. بالنسبة إلى access_token ، على الرغم من ذلك ، ما زلنا بحاجة إلى حفظه في المتجر يدويًا عن طريق إرسال إجراء:
dispatch(authSlice.actions.updateAccessToken(accessToken));نقوم بهذا من أجل القدرة على استمرار الرمز المميز بين عمليات إعادة تحميل الصفحة. من أجل المثابرة والقدرة على إرسال الإجراء ، نحتاج إلى تحديد تكوين متجر لميزة المصادقة الخاصة بنا.
وفقًا للاتفاقية ، تشير مجموعة أدوات Redux إلى هذه الشرائح على أنها شرائح:
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { persistReducer } from 'redux-persist'; import storage from 'redux-persist/lib/storage'; import { AuthState } from './types'; const initialState: AuthState = {}; export const authSlice = createSlice({ name: 'authSlice', initialState, reducers: { updateAccessToken(state, action: PayloadAction<string | undefined>) { state.accessToken = action.payload; }, }, }); export const authReducer = persistReducer({ key: 'rtk:auth', storage, whitelist: ['accessToken'] }, authSlice.reducer);هناك مطلب آخر لكي يعمل الكود السابق. يجب توفير كل واجهة برمجة تطبيقات كمخفض لتهيئة المتجر ، وتأتي كل واجهة برمجة تطبيقات مع برمجيات وسيطة خاصة بها ، والتي يجب عليك تضمينها:
import { combineReducers, configureStore } from '@reduxjs/toolkit'; import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; import { Reducer } from 'redux'; import { FLUSH, PAUSE, PERSIST, persistStore, PURGE, REGISTER, REHYDRATE } from 'redux-persist'; import { AUTH_API_REDUCER_KEY, authApi } from '../../api/auth/api'; import { authReducer, authSlice } from '../../features/auth/slice'; import { RESET_STATE_ACTION_TYPE } from './actions/resetState'; import { unauthenticatedMiddleware } from './middleware/unauthenticatedMiddleware'; const reducers = { [authSlice.name]: authReducer, [AUTH_API_REDUCER_KEY]: authApi.reducer, }; const combinedReducer = combineReducers<typeof reducers>(reducers); export const rootReducer: Reducer<RootState> = ( state, action ) => { if (action.type === RESET_STATE_ACTION_TYPE) { state = {} as RootState; } return combinedReducer(state, action); }; export const store = configureStore({ reducer: rootReducer, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: { ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER] } }).concat([ unauthenticatedMiddleware, authApi.middleware ]), }); export const persistor = persistStore(store); export type AppDispatch = typeof store.dispatch; export type RootState = ReturnType<typeof combinedReducer>; export const useTypedDispatch = () => useDispatch<AppDispatch>(); export const useTypedSelector: TypedUseSelectorHook<RootState> = useSelector; هذا هو! يستعيد تطبيقنا الآن access_token ونحن مستعدون لإضافة المزيد من ميزات المصادقة فوقه.
استكمال المصادقة
إتمام فرق المصادقة
تتضمن قائمة الميزات التالية للمصادقة ما يلي:
- القدرة على استرداد المستخدم من GitHub API وتوفيره لبقية التطبيق.
- الأداة المساعدة للحصول على مسارات لا يمكن الوصول إليها إلا عند المصادقة عليها أو عند التصفح كضيف.
لإضافة القدرة على استرداد المستخدم ، سنحتاج إلى بعض المعايير المعيارية لواجهة برمجة التطبيقات. بخلاف واجهة برمجة تطبيقات المصادقة ، ستحتاج واجهة برمجة تطبيقات GitHub إلى القدرة على استرداد رمز الوصول من متجر Redux الخاص بنا وتطبيقه على كل طلب كرأس ترخيص.
في استعلام RTK الذي يتم تحقيقه عن طريق إنشاء استعلام أساسي مخصص:
import { RequestOptions } from '@octokit/types/dist-types/RequestOptions'; import { BaseQueryFn } from '@reduxjs/toolkit/query/react'; import axios, { AxiosError } from 'axios'; import { omit } from 'lodash'; import { RootState } from '../../shared/redux/store'; import { wrapResponseWithLink } from './utils'; const githubAxiosInstance = axios.create({ baseURL: 'https://api.github.com', headers: { accept: `application/vnd.github.v3+json` } }); const axiosBaseQuery = (): BaseQueryFn<RequestOptions> => async ( requestOpts, { getState } ) => { try { const token = (getState() as RootState).authSlice.accessToken; const result = await githubAxiosInstance({ ...requestOpts, headers: { ...(omit(requestOpts.headers, ['user-agent'])), Authorization: `Bearer ${token}` } }); return { data: wrapResponseWithLink(result.data, result.headers.link) }; } catch (axiosError) { const err = axiosError as AxiosError; return { error: { status: err.response?.status, data: err.response?.data } }; } }; export const githubBaseQuery = axiosBaseQuery();أنا أستخدم axios هنا ، ولكن يمكن استخدام عملاء آخرين أيضًا.
الخطوة التالية هي تحديد API لاسترداد معلومات المستخدم من GitHub:
import { endpoint } from '@octokit/endpoint'; import { createApi } from '@reduxjs/toolkit/query/react'; import { githubBaseQuery } from '../index'; import { ResponseWithLink } from '../types'; import { User } from './types'; export const USER_API_REDUCER_KEY = 'userApi'; export const userApi = createApi({ reducerPath: USER_API_REDUCER_KEY, baseQuery: githubBaseQuery, endpoints: (builder) => ({ getUser: builder.query<ResponseWithLink<User>, null>({ query: () => { return endpoint('GET /user'); }, }), }), }); نستخدم استعلامنا الأساسي المخصص هنا ، مما يعني أن كل طلب في نطاق userApi سيتضمن رأس التفويض. دعنا نعدل تكوين المتجر الرئيسي بحيث تكون واجهة برمجة التطبيقات متاحة:
import { combineReducers, configureStore } from '@reduxjs/toolkit'; import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; import { Reducer } from 'redux'; import { FLUSH, PAUSE, PERSIST, persistStore, PURGE, REGISTER, REHYDRATE } from 'redux-persist'; import { AUTH_API_REDUCER_KEY, authApi } from '../../api/auth/api'; import { USER_API_REDUCER_KEY, userApi } from '../../api/github/user/api'; import { authReducer, authSlice } from '../../features/auth/slice'; import { RESET_STATE_ACTION_TYPE } from './actions/resetState'; import { unauthenticatedMiddleware } from './middleware/unauthenticatedMiddleware'; const reducers = { [authSlice.name]: authReducer, [AUTH_API_REDUCER_KEY]: authApi.reducer, [USER_API_REDUCER_KEY]: userApi.reducer, }; const combinedReducer = combineReducers<typeof reducers>(reducers); export const rootReducer: Reducer<RootState> = ( state, action ) => { if (action.type === RESET_STATE_ACTION_TYPE) { state = {} as RootState; } return combinedReducer(state, action); }; export const store = configureStore({ reducer: rootReducer, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: { ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER] } }).concat([ unauthenticatedMiddleware, authApi.middleware, userApi.middleware ]), }); export const persistor = persistStore(store); export type AppDispatch = typeof store.dispatch; export type RootState = ReturnType<typeof combinedReducer>; export const useTypedDispatch = () => useDispatch<AppDispatch>(); export const useTypedSelector: TypedUseSelectorHook<RootState> = useSelector;بعد ذلك ، نحتاج إلى استدعاء واجهة برمجة التطبيقات هذه قبل عرض تطبيقنا. للتبسيط ، دعنا نفعل ذلك بطريقة تشبه كيفية عمل وظيفة الحل لمسارات Angular بحيث لا يتم عرض أي شيء حتى نحصل على معلومات المستخدم.
يمكن أيضًا معالجة غياب المستخدم بطريقة أكثر دقة ، من خلال توفير بعض واجهة المستخدم مسبقًا بحيث يكون لدى المستخدم أول عرض ذي مغزى بسرعة أكبر. هذا يتطلب مزيدًا من التفكير والعمل ، ويجب بالتأكيد معالجته في تطبيق جاهز للإنتاج.
للقيام بذلك ، نحتاج إلى تحديد مكون وسيط:
import React, { FC } from 'react'; import { userApi } from '../../../../api/github/user/api'; import FullscreenProgress from '../../../../shared/components/FullscreenProgress/FullscreenProgress'; import { RootState, useTypedSelector } from '../../../../shared/redux/store'; import { useAuthUser } from '../../hooks/useAuthUser'; const UserMiddleware: FC = ({ children }) => { const accessToken = useTypedSelector( (state: RootState) => state.authSlice.accessToken ); const user = useAuthUser(); userApi.endpoints.getUser.useQuery(null, { skip: !accessToken }); if (!user && accessToken) { return ( <FullscreenProgress/> ); } return children as React.ReactElement; }; export default UserMiddleware;ما يفعله هذا واضح ومباشر. يتفاعل مع GitHub API للحصول على معلومات المستخدم ولا يعرض الأطفال قبل توفر الاستجابة. الآن إذا قمنا بلف وظيفة التطبيق بهذا المكون ، فإننا نعلم أنه سيتم حل معلومات المستخدم قبل عرض أي شيء آخر:
import { CssBaseline } from '@material-ui/core'; import React from 'react'; import { Provider } from 'react-redux'; import { BrowserRouter as Router, Route, } from 'react-router-dom'; import { PersistGate } from 'redux-persist/integration/react'; import { QueryParamProvider } from 'use-query-params'; import Auth from './features/auth/Auth'; import UserMiddleware from './features/auth/components/UserMiddleware/UserMiddleware'; import './index.css'; import FullscreenProgress from './shared/components/FullscreenProgress/FullscreenProgress'; import { persistor, store } from './shared/redux/store'; const App = () => { return ( <Provider store={store}> <PersistGate loading={<FullscreenProgress/>} persistor={persistor}> <Router> <QueryParamProvider ReactRouterRoute={Route}> <CssBaseline/> <UserMiddleware> <Auth/> </UserMiddleware> </QueryParamProvider> </Router> </PersistGate> </Provider> ); }; export default App; دعنا ننتقل إلى الجزء الأكثر أناقة. لدينا الآن القدرة على الحصول على معلومات المستخدم في أي مكان في التطبيق ، على الرغم من أننا لم نحفظ معلومات المستخدم هذه في المتجر يدويًا كما فعلنا مع access_token .

كيف؟ من خلال إنشاء خطاف React مخصص بسيط لذلك:
import { userApi } from '../../../api/github/user/api'; import { User } from '../../../api/github/user/types'; export const useAuthUser = (): User | undefined => { const state = userApi.endpoints.getUser.useQueryState(null); return state.data?.response; }; يوفر استعلام RTK خيار useQueryState لكل نقطة نهاية ، مما يمنحنا القدرة على استرداد الحالة الحالية لنقطة النهاية هذه.
لماذا هذا مهم جدا ومفيد؟ لأننا لسنا مضطرين إلى كتابة الكثير من النفقات العامة لإدارة التعليمات البرمجية. كمكافأة ، نحصل على فصل بين بيانات API / العميل في Redux خارج الصندوق.
يؤدي استخدام استعلام RTK إلى تجنب المتاعب. من خلال الجمع بين جلب البيانات وإدارة الحالة ، يقضي RTK Query على الفجوة التي قد تكون موجودة حتى لو استخدمنا React Query. (باستخدام React Query ، يجب الوصول إلى البيانات التي تم جلبها بواسطة مكونات غير مرتبطة في طبقة واجهة المستخدم.)
كخطوة أخيرة ، نحدد مكون مسار مخصص قياسي يستخدم هذا الخطاف لتحديد ما إذا كان يجب عرض المسار أم لا:
import React, { FC } from 'react'; import { Redirect, Route, RouteProps } from 'react-router'; import { useAuthUser } from '../../hooks/useAuthUser'; export type AuthenticatedRouteProps = { onlyPublic?: boolean; } & RouteProps; const AuthenticatedRoute: FC<AuthenticatedRouteProps> = ({ children, onlyPublic = false, ...routeProps }) => { const user = useAuthUser(); return ( <Route {...routeProps} render={({ location }) => { if (onlyPublic) { return !user ? ( children ) : ( <Redirect to={{ pathname: '/', state: { from: location } }} /> ); } return user ? ( children ) : ( <Redirect to={{ pathname: '/login', state: { from: location } }} /> ); }} /> ); }; export default AuthenticatedRoute;اختلاف اختبارات المصادقة
لا يوجد شيء محدد بطبيعته لـ RTK Query عندما يتعلق الأمر بكتابة الاختبارات لتطبيقات React. أنا شخصياً أؤيد نهج Kent C. Dodds للاختبار وأسلوب الاختبار الذي يركز على تجربة المستخدم وتفاعل المستخدم. لا شيء يتغير عند استخدام استعلام RTK.
ومع ذلك ، ستظل كل خطوة تتضمن اختباراتها الخاصة لإثبات أن التطبيق المكتوب باستخدام RTK Query قابل للاختبار تمامًا.
ملحوظة: يوضح المثال ما رأيي في كيفية كتابة هذه الاختبارات فيما يتعلق بما يجب اختباره ، وما يجب السخرية منه ، ومقدار الكود الذي يمكن إعادة استخدامه.
مستودعات الاستعلام RTK
لعرض RTK Query ، سنقدم بعض الميزات الإضافية للتطبيق لمعرفة كيفية أدائه في سيناريوهات معينة وكيف يمكن استخدامه.
فرق المستودعات والاختبارات فرق
أول شيء سنفعله هو تقديم ميزة للمستودعات. ستحاول هذه الميزة محاكاة وظائف علامة التبويب المستودعات التي يمكنك تجربتها في GitHub. سيزور ملف التعريف الخاص بك ويكون لديه القدرة على البحث عن المستودعات وفرزها بناءً على معايير معينة. تم إدخال العديد من التغييرات على الملفات في هذه الخطوة. أنا أشجعك على البحث في الأجزاء التي تهتم بها.
دعنا نضيف تعريفات API المطلوبة لتغطية وظائف المستودعات أولاً:
import { endpoint } from '@octokit/endpoint'; import { createApi } from '@reduxjs/toolkit/query/react'; import { githubBaseQuery } from '../index'; import { ResponseWithLink } from '../types'; import { RepositorySearchArgs, RepositorySearchData } from './types'; export const REPOSITORY_API_REDUCER_KEY = 'repositoryApi'; export const repositoryApi = createApi({ reducerPath: REPOSITORY_API_REDUCER_KEY, baseQuery: githubBaseQuery, endpoints: (builder) => ({ searchRepositories: builder.query< ResponseWithLink<RepositorySearchData>, RepositorySearchArgs >( { query: (args) => { return endpoint('GET /search/repositories', args); }, }), }), refetchOnMountOrArgChange: 60 });بمجرد أن يصبح ذلك جاهزًا ، دعنا نقدم ميزة المستودع التي تتكون من بحث / شبكة / ترقيم الصفحات:
import { Grid } from '@material-ui/core'; import React from 'react'; import PageContainer from '../../../../../../shared/components/PageContainer/PageContainer'; import PageHeader from '../../../../../../shared/components/PageHeader/PageHeader'; import RepositoryGrid from './components/RepositoryGrid/RepositoryGrid'; import RepositoryPagination from './components/RepositoryPagination/RepositoryPagination'; import RepositorySearch from './components/RepositorySearch/RepositorySearch'; import RepositorySearchFormContext from './components/RepositorySearch/RepositorySearchFormContext'; const Repositories = () => { return ( <RepositorySearchFormContext> <PageContainer> <PageHeader title="Repositories"/> <Grid container spacing={3}> <Grid item xs={12}> <RepositorySearch/> </Grid> <Grid item xs={12}> <RepositoryGrid/> </Grid> <Grid item xs={12}> <RepositoryPagination/> </Grid> </Grid> </PageContainer> </RepositorySearchFormContext> ); }; export default Repositories;يعد التفاعل مع Repositories API أكثر تعقيدًا مما واجهناه حتى الآن ، لذلك دعونا نحدد الخطافات المخصصة التي ستزودنا بالقدرة على:
- الحصول على وسيطات لاستدعاءات API.
- احصل على نتيجة API الحالية كما هي مخزنة في الحالة.
- إحضار البيانات عن طريق استدعاء نقاط نهاية API.
import { debounce } from 'lodash'; import { useCallback, useEffect, useMemo } from 'react'; import urltemplate from 'url-template'; import { repositoryApi } from '../../../../../../../api/github/repository/api'; import { RepositorySearchArgs } from '../../../../../../../api/github/repository/types'; import { useTypedDispatch } from '../../../../../../../shared/redux/store'; import { useAuthUser } from '../../../../../../auth/hooks/useAuthUser'; import { useRepositorySearchFormContext } from './useRepositorySearchFormContext'; const searchQs = urltemplate.parse('user:{user} {name} {visibility}'); export const useSearchRepositoriesArgs = (): RepositorySearchArgs => { const user = useAuthUser()!; const { values } = useRepositorySearchFormContext(); return useMemo<RepositorySearchArgs>(() => { return { q: decodeURIComponent( searchQs.expand({ user: user.login, name: values.name && `${values.name} in:name`, visibility: ['is:public', 'is:private'][values.type] ?? '', }) ).trim(), sort: values.sort, per_page: values.per_page, page: values.page, }; }, [values, user.login]); }; export const useSearchRepositoriesState = () => { const searchArgs = useSearchRepositoriesArgs(); return repositoryApi.endpoints.searchRepositories.useQueryState(searchArgs); }; export const useSearchRepositories = () => { const dispatch = useTypedDispatch(); const searchArgs = useSearchRepositoriesArgs(); const repositorySearchFn = useCallback((args: typeof searchArgs) => { dispatch(repositoryApi.endpoints.searchRepositories.initiate(args)); }, [dispatch]); const debouncedRepositorySearchFn = useMemo( () => debounce((args: typeof searchArgs) => { repositorySearchFn(args); }, 100), [repositorySearchFn] ); useEffect(() => { repositorySearchFn(searchArgs); // Non debounced invocation should be called only on initial render // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { debouncedRepositorySearchFn(searchArgs); }, [searchArgs, debouncedRepositorySearchFn]); return useSearchRepositoriesState(); };يعد الحصول على هذا المستوى من الفصل كطبقة تجريد أمرًا مهمًا في هذه الحالة من منظور قابلية القراءة وبسبب متطلبات استعلام RTK.
كما لاحظت عندما قدمنا خطافًا يسترد بيانات المستخدم باستخدام useQueryState ، كان علينا توفير نفس الوسيطات التي قدمناها لاستدعاء API الفعلي.
import { userApi } from '../../../api/github/user/api'; import { User } from '../../../api/github/user/types'; export const useAuthUser = (): User | undefined => { const state = userApi.endpoints.getUser.useQueryState(null); return state.data?.response; }; القيمة الفارغة التي نقدمها كوسيطة موجودة سواء useQuery أو useQueryState . هذا مطلوب لأن RTK Query يحدد جزءًا من المعلومات ويخزنه مؤقتًا بواسطة الوسيطات التي تم استخدامها لاسترداد تلك المعلومات في المقام الأول.
هذا يعني أننا بحاجة إلى أن نكون قادرين على الحصول على الوسائط المطلوبة لاستدعاء API بشكل منفصل عن استدعاء API الفعلي في أي وقت. بهذه الطريقة ، يمكننا استخدامها لاسترداد الحالة المخبأة لبيانات واجهة برمجة التطبيقات كلما احتجنا إلى ذلك.
هناك شيء آخر تحتاج إلى الانتباه إليه في هذا الجزء من التعليمات البرمجية في تعريف واجهة برمجة التطبيقات لدينا:
refetchOnMountOrArgChange: 60لماذا ا؟ لأن إحدى النقاط المهمة عند استخدام مكتبات مثل RTK Query هي معالجة ذاكرة التخزين المؤقت للعميل وإلغاء صلاحية ذاكرة التخزين المؤقت. هذا أمر حيوي ويتطلب أيضًا قدرًا كبيرًا من الجهد ، والذي قد يكون من الصعب تقديمه اعتمادًا على مرحلة التطوير التي أنت فيها.
لقد وجدت RTK Query مرنًا جدًا في هذا الصدد. يتيح لنا استخدام خاصية التكوين هذه:
- قم بتعطيل التخزين المؤقت تمامًا ، والذي يكون مفيدًا عندما تريد الانتقال إلى RTK Query ، وتجنب مشكلات ذاكرة التخزين المؤقت كخطوة أولية.
- أدخل التخزين المؤقت المستند إلى الوقت ، وهي آلية إبطال بسيطة لاستخدامها عندما تعلم أنه يمكن تخزين بعض المعلومات مؤقتًا لمدة X مقدار من الوقت.
يلتزم
الاختلاف في الالتزامات والفرق في الاختبارات
تضيف هذه الخطوة المزيد من الوظائف إلى صفحة المستودع عن طريق إضافة القدرة على عرض الالتزامات لكل مستودع ، وترقيم تلك الالتزامات ، والتصفية حسب الفرع. يحاول أيضًا محاكاة الوظيفة التي تحصل عليها في صفحة GitHub.
لقد قدمنا نقطتي نهاية أخريين للحصول على الفروع والالتزامات ، بالإضافة إلى خطافات مخصصة لنقاط النهاية هذه ، باتباع النمط الذي أنشأناه أثناء تنفيذ المستودعات:
github/repository/api.ts
import { endpoint } from '@octokit/endpoint'; import { createApi } from '@reduxjs/toolkit/query/react'; import { githubBaseQuery } from '../index'; import { ResponseWithLink } from '../types'; import { RepositoryBranchesArgs, RepositoryBranchesData, RepositoryCommitsArgs, RepositoryCommitsData, RepositorySearchArgs, RepositorySearchData } from './types'; export const REPOSITORY_API_REDUCER_KEY = 'repositoryApi'; export const repositoryApi = createApi({ reducerPath: REPOSITORY_API_REDUCER_KEY, baseQuery: githubBaseQuery, endpoints: (builder) => ({ searchRepositories: builder.query< ResponseWithLink<RepositorySearchData>, RepositorySearchArgs >( { query: (args) => { return endpoint('GET /search/repositories', args); }, }), getRepositoryBranches: builder.query< ResponseWithLink<RepositoryBranchesData>, RepositoryBranchesArgs >( { query(args) { return endpoint('GET /repos/{owner}/{repo}/branches', args); } }), getRepositoryCommits: builder.query< ResponseWithLink<RepositoryCommitsData>, RepositoryCommitsArgs >( { query(args) { return endpoint('GET /repos/{owner}/{repo}/commits', args); }, }), }), refetchOnMountOrArgChange: 60 }); useGetRepositoryBranches.ts
import { useEffect, useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { repositoryApi } from '../../../../../../../api/github/repository/api'; import { RepositoryBranchesArgs } from '../../../../../../../api/github/repository/types'; import { useTypedDispatch } from '../../../../../../../shared/redux/store'; import { useAuthUser } from '../../../../../../auth/hooks/useAuthUser'; import { CommitsRouteParams } from '../types'; export const useGetRepositoryBranchesArgs = (): RepositoryBranchesArgs => { const user = useAuthUser()!; const { repositoryName } = useParams<CommitsRouteParams>(); return useMemo<RepositoryBranchesArgs>(() => { return { owner: user.login, repo: repositoryName, }; }, [repositoryName, user.login]); }; export const useGetRepositoryBranchesState = () => { const queryArgs = useGetRepositoryBranchesArgs(); return repositoryApi.endpoints.getRepositoryBranches.useQueryState(queryArgs); }; export const useGetRepositoryBranches = () => { const dispatch = useTypedDispatch(); const queryArgs = useGetRepositoryBranchesArgs(); useEffect(() => { dispatch(repositoryApi.endpoints.getRepositoryBranches.initiate(queryArgs)); }, [dispatch, queryArgs]); return useGetRepositoryBranchesState(); }; useGetRepositoryCommits.ts
import isSameDay from 'date-fns/isSameDay'; import { useEffect, useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { repositoryApi } from '../../../../../../../api/github/repository/api'; import { RepositoryCommitsArgs } from '../../../../../../../api/github/repository/types'; import { useTypedDispatch } from '../../../../../../../shared/redux/store'; import { useAuthUser } from '../../../../../../auth/hooks/useAuthUser'; import { AggregatedCommitsData, CommitsRouteParams } from '../types'; import { useCommitsSearchFormContext } from './useCommitsSearchFormContext'; export const useGetRepositoryCommitsArgs = (): RepositoryCommitsArgs => { const user = useAuthUser()!; const { repositoryName } = useParams<CommitsRouteParams>(); const { values } = useCommitsSearchFormContext(); return useMemo<RepositoryCommitsArgs>(() => { return { owner: user.login, repo: repositoryName, sha: values.branch, page: values.page, per_page: 15 }; }, [repositoryName, user.login, values]); }; export const useGetRepositoryCommitsState = () => { const queryArgs = useGetRepositoryCommitsArgs(); return repositoryApi.endpoints.getRepositoryCommits.useQueryState(queryArgs); }; export const useGetRepositoryCommits = () => { const dispatch = useTypedDispatch(); const queryArgs = useGetRepositoryCommitsArgs(); useEffect(() => { if (!queryArgs.sha) return; dispatch(repositoryApi.endpoints.getRepositoryCommits.initiate(queryArgs)); }, [dispatch, queryArgs]); return useGetRepositoryCommitsState(); }; export const useAggregatedRepositoryCommitsData = (): AggregatedCommitsData => { const { data: repositoryCommits } = useGetRepositoryCommitsState(); return useMemo(() => { if (!repositoryCommits) return []; return repositoryCommits.response.reduce((aggregated, commit) => { const existingCommitsGroup = aggregated.find(a => isSameDay( new Date(a.date), new Date(commit.commit.author!.date!) )); if (existingCommitsGroup) { existingCommitsGroup.commits.push(commit); } else { aggregated.push({ date: commit.commit.author!.date!, commits: [commit] }); } return aggregated; }, [] as AggregatedCommitsData); }, [repositoryCommits]); };بعد القيام بذلك ، يمكننا الآن تحسين UX عن طريق الجلب المسبق لبيانات الالتزام بمجرد أن يحوم شخص ما فوق اسم المستودع:
import { Badge, Box, Chip, Divider, Grid, Link, Typography } from '@material-ui/core'; import StarOutlineIcon from '@material-ui/icons/StarOutline'; import formatDistance from 'date-fns/formatDistance'; import React, { FC } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { repositoryApi } from '../../../../../../../../api/github/repository/api'; import { Repository } from '../../../../../../../../api/github/repository/types'; import { useGetRepositoryBranchesArgs } from '../../../Commits/hooks/useGetRepositoryBranches'; import { useGetRepositoryCommitsArgs } from '../../../Commits/hooks/useGetRepositoryCommits'; const RepositoryGridItem: FC<{ repo: Repository }> = ({ repo }) => { const getRepositoryCommitsArgs = useGetRepositoryCommitsArgs(); const prefetchGetRepositoryCommits = repositoryApi.usePrefetch( 'getRepositoryCommits'); const getRepositoryBranchesArgs = useGetRepositoryBranchesArgs(); const prefetchGetRepositoryBranches = repositoryApi.usePrefetch( 'getRepositoryBranches'); return ( <Grid container spacing={1}> <Grid item xs={12}> <Typography variant="subtitle1" gutterBottom aria-label="repository-name"> <Link aria-label="commit-link" component={RouterLink} to={`/repositories/${repo.name}`} onMouseEnter={() => { prefetchGetRepositoryBranches({ ...getRepositoryBranchesArgs, repo: repo.name, }); prefetchGetRepositoryCommits({ ...getRepositoryCommitsArgs, sha: repo.default_branch, repo: repo.name, page: 1 }); }} > {repo.name} </Link> <Box marginLeft={1} clone> <Chip label={repo.private ? 'Private' : 'Public'} size="small"/> </Box> </Typography> <Typography component="p" variant="subtitle2" gutterBottom color="textSecondary"> {repo.description} </Typography> </Grid> <Grid item xs={12}> <Grid container alignItems="center" spacing={2}> <Box clone flex="0 0 auto" display="flex" alignItems="center" marginRight={2}> <Grid item> <Box clone marginRight={1} marginLeft={0.5}> <Badge color="primary" variant="dot"/> </Box> <Typography variant="body2" color="textSecondary"> {repo.language} </Typography> </Grid> </Box> <Box clone flex="0 0 auto" display="flex" alignItems="center" marginRight={2}> <Grid item> <Box clone marginRight={0.5}> <StarOutlineIcon fontSize="small"/> </Box> <Typography variant="body2" color="textSecondary"> {repo.stargazers_count} </Typography> </Grid> </Box> <Grid item> <Typography variant="body2" color="textSecondary"> Updated {formatDistance(new Date(repo.pushed_at), new Date())} ago </Typography> </Grid> </Grid> </Grid> <Grid item xs={12}> <Divider/> </Grid> </Grid> ); }; export default RepositoryGridItem;While the hover may seem artificial, this heavily impacts UX in real-world applications, and it is always handy to have such functionality available in the toolset of the library we use for API interaction.
The Pros and Cons of RTK Query
Final Source Code
We have seen how to use RTK Query in our apps, how to test those apps, and how to handle different concerns like state retrieval, cache invalidation, and prefetching.
There are a number of high-level benefits showcased throughout this article:
- Data fetching is built on top of Redux, leveraging its state management system.
- API definitions and cache invalidation strategies are located in one place.
- TypeScript improves the development experience and maintainability.
There are some downsides worth noting as well:
- The library is still in active development and, as a result, APIs may change.
- Information scarcity: Besides the documentation, which may be out of date, there isn't much information around.
We covered a lot in this practical walkthrough using the GitHub API, but there is much more to RTK Query, such as:
- معالجة الأخطاء
- الطفرات
- Polling
- Optimistic Updates
If you're intrigued by RTK Query's benefits, I encourage you to dig into those concepts further. Feel free to use this example as a basis to build on.
