การสร้างแอป React ด้วย Redux Toolkit และ RTK Query
เผยแพร่แล้ว: 2022-03-11คุณเคยต้องการใช้ Redux ด้วยคุณสมบัติเช่น React Query หรือไม่? ตอนนี้คุณสามารถโดยใช้ Redux Toolkit และการเพิ่มล่าสุด: 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มีการขึ้นต่อกันหลายอย่างที่เราต้องการระหว่างทาง สิ่งที่น่าสังเกตมากที่สุดคือ:
- Redux Toolkit และ RTK Query
- วัสดุ UI
- โลดัช
- ฟอร์มิก
- ตอบสนองเราเตอร์
นอกจากนี้ยังมีความสามารถในการให้การกำหนดค่าแบบกำหนดเองสำหรับ webpack โดยปกติ CRA จะไม่รองรับความสามารถดังกล่าว เว้นแต่คุณจะดีดออก
การเริ่มต้น
เส้นทางที่ปลอดภัยกว่าการดีดออกมากคือการใช้สิ่งที่สามารถปรับเปลี่ยนการกำหนดค่าได้ โดยเฉพาะอย่างยิ่งหากการปรับเปลี่ยนเหล่านั้นมีขนาดเล็ก ต้นแบบนี้ใช้ react-app-rewired และ Customize-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 Diff
เนื่องจากแอปทั้งหมดใช้ 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 ตอนนี้ มาเพิ่มฟังก์ชันการทำงานบางอย่างกัน
การตรวจสอบสิทธิ์
กำลังเรียกการเข้าถึงโทเค็น Diff
การรับรองความถูกต้องแบ่งออกเป็นสามขั้นตอนเพื่อความง่าย:
- การเพิ่มคำจำกัดความ API เพื่อดึงโทเค็นการเข้าถึง
- การเพิ่มส่วนประกอบเพื่อจัดการขั้นตอนการรับรองความถูกต้องของเว็บ GitHub
- การยืนยันตัวตนขั้นสุดท้ายโดยการจัดหาส่วนประกอบยูทิลิตี้เพื่อให้ผู้ใช้เข้าถึงแอพทั้งหมด
ในขั้นตอนนี้ เราเพิ่มความสามารถในการดึงโทเค็นการเข้าถึง
RTK Query ideology กำหนดให้คำจำกัดความ API ทั้งหมดปรากฏในที่เดียว ซึ่งสะดวกเมื่อต้องจัดการกับแอปพลิเคชันระดับองค์กรที่มีจุดปลายหลายจุด ในแอปพลิเคชันระดับองค์กร การพิจารณา 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ในตัวอย่างโค้ดถัดไป
ขั้นตอนต่อไปคือการเพิ่มส่วนประกอบที่ใช้ API นี้ เนื่องจากข้อกำหนดของการไหลของเว็บแอปพลิเคชัน 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 Toolkit อ้างถึงสิ่งเหล่านี้เป็นสไลซ์:
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);มีข้อกำหนดเพิ่มเติมอีกหนึ่งข้อเพื่อให้โค้ดก่อนหน้าทำงานได้ แต่ละ API จะต้องถูกจัดเตรียมเป็นตัวลดสำหรับการกำหนดค่าร้านค้า และแต่ละ API มาพร้อมกับมิดเดิลแวร์ของตัวเอง ซึ่งคุณต้องรวม:
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 และเราพร้อมที่จะเพิ่มคุณสมบัติการรับรองความถูกต้องเพิ่มเติม
กำลังดำเนินการตรวจสอบสิทธิ์ให้เสร็จสิ้น
เสร็จสิ้นการตรวจสอบความถูกต้อง Diff
รายการคุณสมบัติต่อไปสำหรับการตรวจสอบรวมถึง:
- ความสามารถในการดึงข้อมูลผู้ใช้จาก GitHub API และมอบให้กับส่วนที่เหลือของแอป
- ยูทิลิตี้ที่จะมีเส้นทางที่เข้าถึงได้เฉพาะเมื่อตรวจสอบสิทธิ์หรือเมื่อเรียกดูในฐานะแขก
ในการเพิ่มความสามารถในการดึงข้อมูลผู้ใช้ เราจำเป็นต้องมี API ต้นแบบ ไม่เหมือนกับ API การตรวจสอบสิทธิ์ GitHub API จะต้องมีความสามารถในการดึงโทเค็นการเข้าถึงจากร้าน Redux ของเราและนำไปใช้กับทุกคำขอในฐานะส่วนหัวการอนุญาต
ใน RTK Query ที่ทำได้โดยการสร้างการสืบค้นฐานแบบกำหนดเอง:
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 จะรวมส่วนหัวการอนุญาต มาปรับแต่งการกำหนดค่าร้านค้าหลักเพื่อให้ API ใช้งานได้:
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;ต่อไป เราต้องเรียก API นี้ก่อนที่แอปของเราจะแสดงผล เพื่อความง่าย ลองทำในลักษณะที่คล้ายกับการทำงานของฟังก์ชันแก้ไขสำหรับเส้นทาง Angular เพื่อไม่ให้มีการแสดงผลใด ๆ จนกว่าเราจะได้รับข้อมูลผู้ใช้
การไม่มีผู้ใช้สามารถจัดการได้อย่างละเอียดยิ่งขึ้น โดยให้ UI บางส่วนล่วงหน้า เพื่อให้ผู้ใช้มีการแสดงผลที่มีความหมายครั้งแรกได้รวดเร็วยิ่งขึ้น สิ่งนี้ต้องใช้ความคิดและการทำงานมากขึ้น และควรได้รับการแก้ไขในแอปที่พร้อมสำหรับใช้งานจริงอย่างแน่นอน
ในการทำเช่นนั้น เราต้องกำหนดองค์ประกอบมิดเดิลแวร์:
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 Hook แบบกำหนดเองอย่างง่ายสำหรับมัน:
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 Query ให้ตัวเลือก useQueryState สำหรับทุกปลายทาง ซึ่งทำให้เราสามารถดึงสถานะปัจจุบันสำหรับปลายทางนั้นได้
ทำไมสิ่งนี้จึงสำคัญและมีประโยชน์? เพราะเราไม่ต้องเขียน overhead มากมายเพื่อจัดการโค้ด เป็นโบนัส เราได้แยกข้อมูล API/ข้อมูลไคลเอ็นต์ใน Redux ออกจากกล่อง
การใช้ RTK Query ช่วยหลีกเลี่ยงความยุ่งยาก การรวมการดึงข้อมูลเข้ากับการจัดการสถานะ ทำให้ RTK Query ขจัดช่องว่างที่อาจอยู่ที่นั่นแม้ว่าเราจะใช้ React Query ก็ตาม (ด้วย React Query จะต้องเข้าถึงข้อมูลที่ดึงมาโดยส่วนประกอบที่ไม่เกี่ยวข้องในเลเยอร์ UI)
ในขั้นสุดท้าย เรากำหนดองค์ประกอบเส้นทางแบบกำหนดเองมาตรฐานที่ใช้ hook นี้เพื่อกำหนดว่าควรแสดงเส้นทางหรือไม่:
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;การทดสอบการรับรองความถูกต้อง Diff
ไม่มีอะไรเฉพาะเจาะจงสำหรับ RTK Query เมื่อต้องเขียนการทดสอบสำหรับแอป React โดยส่วนตัวแล้ว ฉันชอบแนวทางการทดสอบของ Kent C. Dodds และรูปแบบการทดสอบที่เน้นที่ประสบการณ์ของผู้ใช้และการโต้ตอบกับผู้ใช้ ไม่มีอะไรเปลี่ยนแปลงมากนักเมื่อใช้ RTK Query
ดังที่กล่าวไว้ แต่ละขั้นตอนจะยังคงรวมการทดสอบของตัวเองเพื่อแสดงให้เห็นว่าแอปที่เขียนด้วย RTK Query นั้นสามารถทดสอบได้อย่างสมบูรณ์
หมายเหตุ: ตัวอย่างนี้แสดงให้เห็นว่าฉันควรเขียนการทดสอบอย่างไร โดยพิจารณาจากสิ่งที่ต้องทดสอบ สิ่งที่ควรเยาะเย้ย และความสามารถในการนำโค้ดกลับมาใช้ใหม่ได้มากน้อยเพียงใด
ที่เก็บแบบสอบถาม RTK
เพื่อแสดง RTK Query เราจะแนะนำคุณสมบัติเพิ่มเติมบางอย่างในแอปพลิเคชันเพื่อดูว่ามันทำงานอย่างไรในบางสถานการณ์และจะใช้งานอย่างไร
ที่เก็บ Diff และการทดสอบ Diff
สิ่งแรกที่เราจะทำคือแนะนำคุณลักษณะสำหรับที่เก็บ ฟีเจอร์นี้จะพยายามเลียนแบบการทำงานของแท็บ Repositories ที่คุณสามารถพบได้ใน 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 });เมื่อพร้อมแล้ว มาแนะนำฟีเจอร์ Repository ที่ประกอบด้วย Search/Grid/Pagination:
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 นั้นซับซ้อนกว่าที่เราเคยพบมา ดังนั้นเรามากำหนด hooks แบบกำหนดเองที่จะช่วยให้เราสามารถ:
- รับอาร์กิวเมนต์สำหรับการเรียก 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 Query
ตามที่คุณอาจสังเกตเห็นเมื่อเราแนะนำ hook ที่ดึงข้อมูลผู้ใช้โดยใช้ 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; }; null ที่เราให้ไว้เป็นอาร์กิวเมนต์อยู่ที่นั่นไม่ว่าเราจะเรียก useQuery หรือ useQueryState นั่นเป็นสิ่งจำเป็นเนื่องจาก RTK Query ระบุและแคชข้อมูลบางส่วนโดยอาร์กิวเมนต์ที่ใช้ในการดึงข้อมูลนั้นตั้งแต่แรก
ซึ่งหมายความว่าเราต้องสามารถรับอาร์กิวเมนต์ที่จำเป็นสำหรับการเรียก API แยกต่างหากจากการเรียก API จริงได้ตลอดเวลา ด้วยวิธีนี้ เราจึงสามารถใช้เพื่อดึงสถานะแคชของข้อมูล API ได้ทุกเมื่อที่ต้องการ
มีอีกสิ่งหนึ่งที่คุณต้องใส่ใจในโค้ดชิ้นนี้ในคำจำกัดความ API ของเรา:
refetchOnMountOrArgChange: 60ทำไม? เนื่องจากจุดสำคัญอย่างหนึ่งเมื่อใช้ไลบรารีเช่น RTK Query คือการจัดการแคชของไคลเอ็นต์และการทำให้แคชใช้ไม่ได้ สิ่งนี้มีความสำคัญและต้องใช้ความพยายามอย่างมาก ซึ่งอาจเป็นเรื่องยากขึ้นอยู่กับขั้นตอนของการพัฒนาที่คุณอยู่
ฉันพบว่า RTK Query นั้นยืดหยุ่นมากในเรื่องนั้น การใช้คุณสมบัติการกำหนดค่านี้ช่วยให้เราสามารถ:
- ปิดใช้งานการแคชทั้งหมด ซึ่งสะดวกเมื่อคุณต้องการย้ายไปยัง RTK Query โดยหลีกเลี่ยงปัญหาแคชเป็นขั้นตอนเริ่มต้น
- แนะนำการแคชตามเวลา ซึ่งเป็นกลไกการทำให้ใช้ไม่ได้ง่ายๆ เพื่อใช้เมื่อคุณทราบว่าข้อมูลบางอย่างสามารถแคชได้สำหรับระยะเวลา X
ภาระผูกพัน
คอมมิชชันดิฟและการทดสอบดิฟ
ขั้นตอนนี้จะเพิ่มฟังก์ชันการทำงานให้กับเพจที่เก็บโดยเพิ่มความสามารถในการดูคอมมิตสำหรับแต่ละที่เก็บ แบ่งหน้าคอมมิตเหล่านั้น และกรองตามสาขา นอกจากนี้ยังพยายามเลียนแบบฟังก์ชันการทำงานที่คุณได้รับในหน้า GitHub
เราได้แนะนำจุดปลายอีกสองจุดสำหรับการรับสาขาและการคอมมิต เช่นเดียวกับ hooks ที่กำหนดเองสำหรับปลายทางเหล่านี้ ตามสไตล์ที่เรากำหนดไว้ระหว่างการใช้งานที่เก็บ:
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.
