使用 Redux 工具包和 RTK 查询创建 React 应用程序
您是否曾经想将 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 上。
认证服务器
虽然本示例项目不需要,但希望托管自己的身份验证服务器副本的读者需要:
- 在 GitHub 中创建一个 OAuth 应用程序以生成他们自己的客户端 ID 和密钥。
- 通过环境变量
GITHUB_CLIENT_ID
和GITHUB_SECRET
. - 替换
baseUrl
上述 API 定义中的身份验证端点值。 - 在 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`,
visibilit
上一篇: 用 Raspberry Pi 控制伺服 MG90D
下一篇: 聊点八卦