使用 Redux Toolkit 和 RTK 查询创建 React 应用程序

3,102 阅读12分钟

您是否曾经想将 Redux 与 React Query 提供的功能一起使用?现在,您可以使用 Redux Toolkit 及其最新添加的功能:RTK Query

RTK Query 是一种高级数据获取和客户端缓存工具。它的功能类似于 React Query,但它的好处是直接与 Redux 集成。对于 API 交互,开发人员在使用 Redux 时通常会使用像 Thunk 这样的异步中间件模块。这种方法限制了灵活性;因此React 开发人员现在有了 Redux 团队的官方替代方案,它涵盖了当今客户端/服务器通信的所有高级需求。

本文演示了如何在实际场景中使用 RTK 查询,每个步骤都包含一个指向提交差异的链接,以突出显示添加的功能。最后会出现一个指向完整代码库的链接。

样板和配置

项目初始化差异

首先,我们需要创建一个项目。这是使用用于 TypeScript 和 Redux的Create React App (CRA) 模板完成的:

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

它有几个我们将需要的依赖项,最值得注意的是:

  • Redux 工具包和 RTK 查询
  • 材质界面
  • 洛达什
  • 福米克
  • 反应路由器

它还包括为webpack提供自定义配置的能力。通常,除非您退出,否则 CRA 不支持此类能力。

初始化

比弹出更安全的方法是使用可以修改配置的东西,特别是如果这些修改很小。该样板使用react-app-rewired和custom -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 设置差异

由于整个app都是基于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 查询思想规定所有 API 定义都出现在一个地方,这在处理具有多个端点的企业级应用程序时非常方便。在企业应用程序中,当一切都在一个地方时,考虑集成 API 以及客户端缓存要容易得多。

RTK 查询具有使用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.
  3. 替换baseUrl上述 API 定义中的身份验证端点值。
  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-testid="login-link"
             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 都必须作为 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 查询useQueryState为每个端点提供了选项,这使我们能够检索该端点的当前状态。

为什么这如此重要和有用?因为我们不必编写大量开销来管理代码。作为奖励,我们在 Redux 中开箱即用地分离了 API/客户端数据。

使用 RTK 查询避免了麻烦。通过将数据获取与状态管理相结合,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 查询时没有太大变化。

话虽如此,每个步骤仍将包括自己的测试,以证明用 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还是 ,我们提供的作为参数的 null 都存在useQueryState。这是必需的,因为 RTK 查询首先通过用于检索该信息的参数来识别和缓存一条信息。

这意味着我们需要能够在任何时间点分别从实际的 API 调用中获取 API 调用所需的参数。这样,我们就可以在需要时使用它来检索 API 数据的缓存状态。

在我们的 API 定义中的这段代码中,您还需要注意一件事:

refetchOnMountOrArgChange: 60

为什么?因为使用像 RTK Query 这样的库的重点之一是处理客户端缓存和缓存失效。这是至关重要的,也需要大量的努力,根据您所处的开发阶段,这可能难以提供。

我发现 RTK Query 在这方面非常灵活。使用此配置属性允许我们:

  • 完全禁用缓存,当您想要迁移到 RTK 查询时,这会派上用场,避免缓存问题作为初始步骤。
  • 引入基于时间的缓存,这是一种简单的失效机制,当您知道某些信息可以缓存 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;

虽然悬停似乎是人为的,但这会严重影响实际应用程序中的 UX,并且在我们用于 API 交互的库的工具集中提供此类功能总是很方便的。

RTK 查询的优缺点

最终源代码

我们已经了解了如何在我们的应用程序中使用 RTK 查询,如何测试这些应用程序,以及如何处理不同的问题,例如状态检索、缓存失效和预取。

本文展示了许多高级优势:

  • 数据获取建立在 Redux 之上,利用其状态管理系统。
  • API 定义和缓存失效策略位于一处。
  • TypeScript 提升了开发体验和可维护性。

还有一些值得注意的缺点:

  • 该库仍在积极开发中,因此 API 可能会发生变化。
  • 信息稀缺:除了可能已经过时的文档之外,没有太多信息。

我们在使用 GitHub API 的实际演练中涵盖了很多内容,但 RTK 查询还有更多内容,例如:

如果您对 RTK Query 的好处感兴趣,我鼓励您进一步深入研究这些概念。随意使用这个例子作为构建的基础。