使用 Redux Toolkit 和 RTK Query 創建 React 應用程序

已發表: 2022-03-11

你有沒有想過將 Redux 與 React Query 提供的功能一起使用? 現在您可以使用 Redux Toolkit 及其最新添加:RTK Query。

RTK Query 是一種高級數據獲取和客戶端緩存工具。 它的功能類似於 React Query,但它具有直接與 Redux 集成的優勢。 對於 API 交互,開發人員在使用 Redux 時通常會使用 Thunk 等異步中間件模塊。 這種方法限制了靈活性; 因此,React 開發人員現在擁有來自 Redux 團隊的官方替代方案,它涵蓋了當今客戶端/服務器通信的所有高級需求。

本文演示瞭如何在實際場景中使用 RTK Query,每個步驟都包含一個指向提交差異的鏈接,以突出顯示添加的功能。 完整代碼庫的鏈接出現在末尾。

樣板和配置

項目初始化差異

首先,我們需要創建一個項目。 這是使用用於 TypeScript 和 Redux 的 Create React App (CRA) 模板完成的:

 npx create-react-app . --template redux-typescript

它有幾個我們將需要的依賴項,最值得注意的是:

  • Redux 工具包和 RTK 查詢
  • 材質界面
  • 洛達什
  • 福米克
  • 反應路由器

它還包括為 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 設置差異

由於整個應用是基於 Redux 的,初始化後我們需要設置 store 配置:

 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 Web 身份驗證流程
  • 通過提供用於向用戶提供整個應用程序的實用程序組件來完成身份驗證

在這一步,我們添加了檢索訪問令牌的功能。

RTK Query 思想要求所有 API 定義都出現在一個地方,這在處理具有多個端點的企業級應用程序時很方便。 在企業應用程序中,當一切都在一個地方時,考慮集成 API 以及客戶端緩存要容易得多。

RTK Query 具有使用 OpenAPI 標准或 GraphQL 自動生成 API 定義的工具。 這些工具仍處於起步階段,但正在積極開發中。 此外,該庫旨在為 TypeScript 提供出色的開發人員體驗,由於其提高可維護性的能力,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 身份驗證是通過開源身份驗證服務器提供的,由於 GitHub API 的要求,該服務器在 Heroku 上單獨託管。

認證服務器

雖然此示例項目不需要,但希望託管自己的身份驗證服務器副本的讀者將需要:

  1. 在 GitHub 中創建 OAuth 應用程序以生成自己的客戶端 ID 和密碼。
  2. 通過環境變量GITHUB_CLIENT_IDGITHUB_SECRET向身份驗證服務器提供 GitHub 詳細信息。
  3. 替換上述 API 定義中的身份驗證端點baseUrl值。
  4. 在 React 端,替換下一個代碼示例中的client_id參數。

下一步是添加使用此 API 的組件。 由於 GitHub Web 應用流程的要求,我們需要一個登錄組件負責重定向到 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 ,我們仍然需要通過調度一個操作手動將其保存在 store 中:

 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 都必須作為 store 配置的 reducer 提供,並且每個 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 ,我們準備在其之上添加更多身份驗證功能。

完成認證

完成認證差異

下一個身份驗證功能列表包括:

  • 從 GitHub API 檢索用戶並將其提供給應用程序的其餘部分的能力。
  • 該實用程序具有僅在經過身份驗證或以訪客身份瀏覽時才能訪問的路由。

要添加檢索用戶的功能,我們將需要一些 API 樣板。 與身份驗證 API 不同,GitHub API 需要能夠從我們的 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,其他的客戶端也可以用。

下一步是定義一個用於從 GitHub 檢索用戶信息的 API:

 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範圍內的每個請求都將包含一個 Authorization 標頭。 讓我們調整主存儲配置,以便 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選項,這使我們能夠檢索該端點的當前狀態。

為什麼這如此重要和有用? 因為我們不必編寫大量開銷來管理代碼。 作為獎勵,我們在 Redux 中開箱即用地分離了 API/客戶端數據。

使用 RTK Query 避免了麻煩。 通過將數據獲取與狀態管理相結合,RTK Query 消除了即使我們使用 React Query 也會存在的差距。 (使用 React Query,獲取的數據必須由 UI 層上不相關的組件訪問。)

作為最後一步,我們定義了一個標準的自定義路由組件,它使用這個鉤子來確定是否應該渲染路由:

 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;

身份驗證測試差異

在為 React 應用程序編寫測試時,RTK Query 本身並沒有什麼特別之處。 就個人而言,我贊成 Kent C. Dodds 的測試方法和專注於用戶體驗和用戶交互的測試風格。 使用 RTK Query 時沒有太大變化。

話雖如此,每個步驟仍將包含其自己的測試,以證明使用 RTK Query 編寫的應用程序是完全可測試的。

注意:這個例子展示了我對如何編寫這些測試的看法,包括要測試什麼、要模擬什麼以及引入多少代碼可重用性。

RTK 查詢存儲庫

為了展示 RTK Query,我們將向應用程序引入一些附加功能,以了解它在某些場景中的表現以及如何使用它。

存儲庫差異和測試差異

我們要做的第一件事是為存儲庫引入一個功能。 此功能將嘗試模仿您可以在 GitHub 中體驗的 Repositories 選項卡的功能。 它將訪問您的個人資料,並能夠搜索存儲庫並根據某些條件對其進行排序。 此步驟中引入了許多文件更改。 我鼓勵您深入研究您感興趣的部分。

讓我們首先添加涵蓋存儲庫功能所需的 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 });

準備就緒後,讓我們介紹一個由 Search/Grid/Pagination 組成的 Repository 功能:

 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 ,我們作為參數提供的那個 null 都存在。 這是必需的,因為 RTK Query 通過首先用於檢索該信息的參數來識別和緩存一條信息。

這意味著我們需要能夠在任何時間點與實際 API 調用分開獲取 API 調用所需的參數。 這樣,我們可以在需要時使用它來檢索 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:

  • 錯誤處理
  • 突變
  • 輪詢
  • 樂觀的更新

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.