使用 rtk-query 优化你的数据请求

6,054 阅读6分钟

很多朋友想要咨询这个技术栈可以单独加我微信:332904234

一、目前前端常见的发起ajax请求的方式

  • 1、使用原生的ajax请求
  • 2、使用jquery封装好的ajax请求
  • 3、使用fetch发起请求
  • 4、第三方的比如axios请求
  • 5、angular中自带的HttpClient

就目前前端框架开发中来说我们在开发vuereact的时候一般都是使用fetchaxios自己封装一层来与后端数据交互,至于angular肯定是用自带的HttpClient请求方式,但是依然存在几个致命的弱点,

  • 1、对当前请求数据不能缓存,
  • 2、一个页面上由多个组件组成,但是刚好有遇到复用相同组件的时候,那么就会发起多次ajax请求

📢 针对同一个接口发起多次请求的解决方法,目前常见的解决方案

  • 1、使用axios的取消发起请求,参考文档
  • 2、vue中还没看到比较好的方法
  • 3、在rect中可以借用类似react-query工具对请求包装一层
  • 4、对于angular中直接使用rxjs的操作符shareReplay

二、rtk-query的介绍

rtk-queryredux-toolkit里面的一个分之,专门用来优化前端接口请求,目前也只支持在react中使用,本文章不去介绍如何在redux-toolkit的使用方式,我相信在网上也能陆续的搜索到对应的资料,但是对于rtk-query的除了官网,几乎是没有的,有也是一些残卷,简单的demo使用,并不能适用于企业实际项目开发中,本人在项目中使用redux-toolkit,axios,react-query的基础上优化实际项目中,看到官网上有rtk-query的介绍,经过一段时间的研究和实际项目中使用逐渐取代了项目中的axiosreact-query

📢 rtk-query的使用环境,必须是react版本大于 17,可以使用hooks的版本,因为使用rtk-query的查询都是hooks的方式,如果你项目简单redux都未使用到,本人不建议你用rtk-query,可能直接使用axios请求更加的简单方便。

rtk-query中我们可以使用中间件和拦截器优雅的处理异常信息,使用代码拆分将不同类型的接口拆分到不同的模块下

三、环境的搭建

  • 1、使用脚手架创建一个typescript的工程

    npx create-react-app react-reduxjs-toolkit --template typescript
    
  • 2、安装依赖包

    npm install @reduxjs/toolkit react-redux
    
  • 3、创建store文件夹来存放状态管理:src/store

    ➜  store git:(dev2) ✗ tree .
    .
    ├── api  # 接口请求的
    │   ├── base.ts # 基础的
    │   └── posts.ts # 帖子的接口
    ├── hooks.ts # 自定义hooks优化在组件中使用的时候不能联想出来
    ├── index.ts
    └── store.ts
    
    1 directory, 5 files
    
  • 4、base.ts中提供拆分代码的基础服务,参考文档

    import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
    
    export const baseApi = createApi({
      baseQuery: fetchBaseQuery({ baseUrl: 'http://localhost:5000' }),
      reducerPath: 'baseApi',
      // 缓存,默认时间是秒,默认时长60秒
      keepUnusedDataFor: 5 * 60,
      refetchOnMountOrArgChange: 30 * 60,
      endpoints: () => ({}),
    });
    
  • 5、在posts.ts文件中是关于帖子的一切请求,如果是用户的请求,我们可以同理创建一个user.ts的文件

    //React entry point 会自动根据endpoints生成hooks
    import { baseApi } from './base';
    interface IPostVo {
      id: number;
      name: string;
    }
    //使用base URL 和endpoints 定义服务
    const postsApi = baseApi.injectEndpoints({
      endpoints: (builder) => ({
        // 查询列表
        getPostsList: builder.query<Promise<IPostVo[]>, void>({
          query: () => '/posts',
          transformResponse: (response: { data: Promise<IPostVo[]> }) => {
            return response.data;
          },
        }),
        // 根据id去查询,第一个参数是返回值的类型,第二个参是传递给后端的数据类型
        getPostsById: builder.query<{ id: number; name: string }, number>({
          query: (id: number) => `/posts/${id}`,
        }),
        // 创建帖子
        createPosts: builder.mutation({
          query: (data) => ({
            url: '/posts',
            method: 'post',
            body: data,
          }),
        }),
        // 根据id删除帖子
        deletePostById: builder.mutation({
          query: (id: number) => ({
            url: `/posts/${id}`,
            method: 'delete',
          }),
        }),
        // 根据id修改帖子
        modifyPostById: builder.mutation({
          query: ({ id, data }: { id: number; data: any }) => ({
            url: `posts/${id}`,
            method: 'PATCH',
            body: data,
          }),
        }),
      }),
      overrideExisting: false,
    });
    //导出可在函数式组件使用的hooks,它是基于定义的endpoints自动生成的
    export const {
      useGetPostsListQuery,
      useGetPostsByIdQuery,
      useCreatePostsMutation,
      useDeletePostByIdMutation,
      useModifyPostByIdMutation,
      // 惰性的查询
      useLazyGetPostsListQuery,
      useLazyGetPostsByIdQuery,
    } = postsApi;
    export default postsApi;
    
  • 6、store.ts文件中对数据的组合

    import {
      configureStore,
      combineReducers,
      Dispatch,
      AnyAction,
    } from '@reduxjs/toolkit';
    import { setupListeners } from '@reduxjs/toolkit/dist/query/react';
    import { baseApi } from './api/base';
    
    const rootReducer = combineReducers({
      [baseApi.reducerPath]: baseApi.reducer,
    });
    
    // 中间件集合
    const middlewareHandler = (getDefaultMiddleware: any) => {
      const middlewareList = [...getDefaultMiddleware()];
      return middlewareList;
    };
    //API slice会包含自动生成的redux reducer和一个自定义中间件
    export const rootStore = configureStore({
      reducer: rootReducer,
      middleware: (getDefaultMiddleware) =>
        middlewareHandler(getDefaultMiddleware),
    });
    
    export type RootState = ReturnType<typeof rootStore.getState>;
    
    setupListeners(rootStore.dispatch);
    
  • 7、在src/index.ts中使用store仓库

    import React from 'react';
    import ReactDOM from 'react-dom';
    import { Provider } from 'react-redux';
    import App from './App';
    import reportWebVitals from './reportWebVitals';
    import { rootStore } from './store';
    
    ReactDOM.render(
      <React.StrictMode>
        <Provider store={rootStore}>
          <App />
        </Provider>
      </React.StrictMode>,
      document.getElementById('root')
    );
    reportWebVitals();
    
  • 8、在app.tsx在组建中使用

    import { useEffect } from 'react';
    import {
      useGetPostsListQuery,
      useLazyGetPostsListQuery,
    } from './store/api/posts.service';
    import { useDispatch } from 'react-redux';
    import { postsSlice } from './store/slice/post.slice';
    // Test组件中依旧使用useGetPostsListQuery()方法,可以查看到两个组件中都成功获取到数据,但是发起请求只有一次
    import { Test } from './Test';
    
    function App() {
      // 主动拉取数据
      const { data: postList } = useGetPostsListQuery();
      console.log(postList, 'app组件组件中');
      // 惰性拉取数据
      const [trigger, { data }] = useLazyGetPostsListQuery();
      const postsListHandler = () => {
        trigger();
      };
      useEffect(() => {
        if (data) {
          console.log(data, '接收到的数据');
        }
        // eslint-disable-next-line
      }, [data]);
      return (
        <div className='App'>
          <header className='App-header'>
            <button onClick={postsListHandler}>点击按钮查询全部数据</button>
            <Test />
          </header>
        </div>
      );
    }
    
    export default App;
    
  • 9、测试这样就简单实现了通过代码拆分优化请求的方式来请求后端接口,细节的问题可以继续查阅文档

四、中间件的使用

日志中间件的使用

  • 1、日志中间件的使用,我们在开发环境的时候要使用日志中间件,便于观察redux状态的变动

    npm install redux-logger
    
  • 2、在src/store/store.ts中配置日志中间件

    import logger from 'redux-logger';
    
    ...
    // 中间件集合
    const middlewareHandler = (getDefaultMiddleware: any) => {
      const middlewareList = [
        ...getDefaultMiddleware(),
      ];
      if (process.env.NODE_ENV === 'development') {
        middlewareList.push(logger);
      }
      return middlewareList;
    };
    

错误中间件

  • 1、官网地址

  • 2、我们在中间件可以处理后端抛出的错误比如 403、500 等错误信息

  • 3、配置错误中间件

    import {
      MiddlewareAPI,
      isRejectedWithValue,
      Middleware,
    } from '@reduxjs/toolkit';
    
    // 错误中间件
    export const rtkQueryErrorLogger: Middleware =
      (api: MiddlewareAPI) => (next: Dispatch<AnyAction>) => (action: any) => {
        console.log(action, '中间件中非错误的时候', api);
        // 只能拦截不是200的时候
        if (isRejectedWithValue(action)) {
          console.log(action, '中间件');
          // console.log(action.error.data.message, '错误信息');
          console.warn(action.payload.status, '当前的状态');
          console.warn(action.payload.data?.message, '错误信息');
          console.warn('中间件拦截了');
          // TODO 自己实现错误提示给页面上
        }
        return next(action);
      };
    
    // 中间件集合
    const middlewareHandler = (getDefaultMiddleware: any) => {
      const middlewareList = [rtkQueryErrorLogger, ...getDefaultMiddleware()];
      if (process.env.NODE_ENV === 'development') {
        middlewareList.push(logger);
      }
      return middlewareList;
    };
    

五、拦截器的使用

上面的中间件是可以处理接口的错误请求,但是实际上常见的httpstatus并不能满足我们实际业务开发,后端开发也一般只要到了后端就返回httpstatus=200,然后自定义code的状态码来反馈错误信息,这时候拦截器就发挥他的作用了

  • 1、参考文档

  • 2、改造项目中的src/store/base.ts的文件,加入拦截器的方式

    import { QueryReturnValue } from '@reduxjs/toolkit/dist/query/baseQueryTypes';
    import {
      BaseQueryFn,
      createApi,
      FetchArgs,
      fetchBaseQuery,
      FetchBaseQueryError,
      FetchBaseQueryMeta,
    } from '@reduxjs/toolkit/query/react';
    
    // 定义拦截器
    const baseQuery = fetchBaseQuery({
      baseUrl: 'http://localhost:5000/',
    });
    const baseQueryWithIntercept: BaseQueryFn<
      string | FetchArgs,
      unknown,
      FetchBaseQueryError
    > = async (args, api, extraOptions) => {
      const result: QueryReturnValue<
        any,
        FetchBaseQueryError,
        FetchBaseQueryMeta
      > = await baseQuery(args, api, extraOptions);
      console.log(result, '拦截器');
      const { data, error } = result;
      // 如果遇到错误的时候
      if (error) {
        const { status } = error as FetchBaseQueryError;
        const { request } = meta as FetchBaseQueryMeta;
        const url: string = request.url;
        // 根据状态来处理错误
        printHttpError(Number(status), url);
        // TODO 自己处理错误信息提示给前端
      }
      if (Object.is(data?.code, 0)) {
        return result;
      }
      throw new Error(data.message);
    };
    
    export const baseApi = createApi({
      baseQuery: baseQueryWithIntercept, //fetchBaseQuery({ baseUrl: 'http://localhost:5000' }),
      reducerPath: 'baseApi',
      // 缓存时间,以秒为单位,默认是60秒
      keepUnusedDataFor: 2 * 60,
      // refetchOnMountOrArgChange: 30 * 60,
      endpoints: () => ({}),
    });
    
  • 3、printHttpError方法打印错httpStatus的错误信息,自己继续完善

    /**
     * 打印http请求错误的时候
     * @param httpStatus
     * @param path
     */
    export const printHttpError = (httpStatus: number, path: string): void => {
      switch (httpStatus) {
        case 400:
          console.log(`错误的请求:${path}`);
          break;
        // 401: 未登录
        // 未登录则跳转登录页面,并携带当前页面的路径
        case 401:
          console.log('你没有登录,请先登录');
          window.location.reload();
          break;
        // 跳转登录页面
        case 403:
          console.log('登录过期,请重新登录');
          // 清除全部的缓存数据
          window.localStorage.clear();
          window.location.reload();
          break;
        // 404请求不存在
        case 404:
          console.log('网络请求不存在');
          break;
        // 其他错误,直接抛出错误提示
        default:
          console.log('我也不知道是什么错误');
          break;
      }
    };
    
  • 4、处理后端返回httpStatus=200的时候根据code来判断异常的情况

    export const fetchWithIntercept: BaseQueryFn<
      string | FetchArgs,
      unknown,
      FetchBaseQueryError
    > = async (args, api, extraOptions) => {
      const result: QueryReturnValue<
        any,
        FetchBaseQueryError,
        FetchBaseQueryMeta
      > = await baseQuery(args, api, extraOptions);
      console.log(result, '拦截器');
      const { data, error, meta } = result;
      const { request } = meta as FetchBaseQueryMeta;
      const url: string = request.url;
      // 如果遇到httpStatus!=200-300错误的时候
      if (error) {
        const { status } = error as FetchBaseQueryError;
        // 根据状态来处理错误
        printHttpError(Number(status), url);
      }
      // 正确的时候,根据各自后端约定来写的
      if (Object.is(data?.code, 0)) {
        return result;
      } else {
        // TODO 打印提示信息
        printPanel({ method: request.method, url: request.url });
        // TODO 根据后端返回的错误提示到组件中,直接这里弹框提示也可以
        return Promise.reject('错误信息');
      }
    };
    
  • 5、注意点,使用了拦截器后中间件就失效,具体原因在文档上还没找到说明

六、结合数据持久化插件将请求的数据持久化到本地

  • 1、安装依赖包

    npm install redux-persist
    
  • 2、修改src/store/store.ts文件

    import { persistStore, persistReducer } from 'redux-persist';
    import storage from 'redux-persist/lib/storage';
    
    const persistConfig = {
      key: 'root',
      storage,
    };
    const rootReducer = combineReducers({
      [baseApi.reducerPath]: baseApi.reducer,
    });
    const persistedReducer = persistReducer(persistConfig, rootReducer);
    
    ...
    export const rootStore = configureStore({
      reducer: persistedReducer,
      middleware: (getDefaultMiddleware) => middlewareHandler(getDefaultMiddleware),
    });
    
    export const persistor = persistStore(rootStore);
    export type RootState = ReturnType<typeof rootStore.getState>;
    
  • 3、修改根目录下的index.tsx文件

    import { rootStore, persistor } from './store';
    
    ReactDOM.render(
      <React.StrictMode>
        <Provider store={rootStore}>
          <PersistGate persistor={persistor}>
            <App />
          </PersistGate>
        </Provider>
      </React.StrictMode>,
      document.getElementById('root')
    );
    
  • 4、刷新浏览器查看是否在本地存储中有数据

    image-20210915171330038.png

    在这里baseApi其实没一点用途的,如果要持久化数据还需要手动来创建切片,这时候就使用到了@reduxjs/toolkit的知识点

  • 5、一份完整的store.ts文件

    import { configureStore, combineReducers } from '@reduxjs/toolkit';
    import { persistStore, persistReducer } from 'redux-persist';
    import storage from 'redux-persist/lib/storage';
    import logger from 'redux-logger';
    import { setupListeners } from '@reduxjs/toolkit/dist/query/react';
    
    import { baseApi } from './api/base.service';
    import { postsSlice } from './slice/post.slice';
    
    const persistConfig = {
      key: 'root',
      storage,
    };
    const rootReducer = combineReducers({
      [baseApi.reducerPath]: baseApi.reducer,
    });
    const persistedReducer = persistReducer(persistConfig, rootReducer);
    
    // 中间件集合
    const middlewareHandler = (getDefaultMiddleware: any) => {
      const middlewareList = [
        ...getDefaultMiddleware({
          serializableCheck: {
            ignoredActions: ['persist/PERSIST'],
          },
        }),
      ];
      if (process.env.NODE_ENV === 'development') {
        middlewareList.push(logger);
      }
      return middlewareList;
    };
    //API slice会包含自动生成的redux reducer和一个自定义中间件
    export const rootStore = configureStore({
      reducer: persistedReducer,
      middleware: (getDefaultMiddleware) => middlewareHandler(getDefaultMiddleware),
    });
    
    export const persistor = persistStore(rootStore);
    export type RootState = ReturnType<typeof rootStore.getState>;
    
    setupListeners(rootStore.dispatch);
    

六、使用切片的方式来实现将请求的数据存储到本地中

  • 1、创建文件store/slice/posts.ts文件

    import { createSlice } from '@reduxjs/toolkit';
    import { IPostVo } from '../api/posts.service';
    
    interface PostsState {
      /**后端数据返回的 */
      postList: IPostVo[];
    }
    
    const initialState: PostsState = {
      postList: [],
    };
    
    export const postsSlice = createSlice({
      name: 'Posts',
      initialState,
      reducers: {
        clearPosts: (state: PostsState) => {
          state.postList = [];
        },
        setPosts: (state: PostsState, action) => {
          state.postList = action.payload;
        },
      },
      extraReducers: {},
    });
    
  • 2、在store.ts中配置切片

    import { postsSlice } from './slice/post.slice';
    
    ...
    const rootReducer = combineReducers({
      [baseApi.reducerPath]: baseApi.reducer,
      // 自定义要存储的数据
      posts: postsSlice.reducer,
    });
    
  • 3、在组件中将请求回来的数据存储到本地中

    import { useDispatch } from 'react-redux';
    
    const [trigger, { error, data }] = useLazyGetPostsListQuery();
    
    useEffect(() => {
      if (data) {
        console.log(data, '接收到的数据');
        dispatch(postsSlice.actions.setPosts(data));
      }
      // eslint-disable-next-line
    }, [data]);
    
  • 4、获取数据后重新查看浏览器

  • 5、如果是在别的组件中要使用持久化的数据直接使用

    import { RootState, useSelector } from 'src/store';
    const postsList: IPostVo[] =
        useSelector((state: RootState) => state.posts.postsList) ?? [];
    
  • 6、📢点,这里的useSelector要使用我们自定义的,在store/hooks.ts文件中

    import {
      useSelector as useReduxSelector,
      TypedUseSelectorHook,
    } from 'react-redux';
    import { RootState } from './store';
    
    export const useSelector: TypedUseSelectorHook<RootState> = useReduxSelector;
    

七、给请求添加请求头

  • 1、在base.ts中配置请求头

    const baseUrl: string = process.env.REACT_APP_BASE_API_URL as string;
    const baseQuery = fetchBaseQuery({
      baseUrl,
      prepareHeaders: (headers) => {
        headers.set('x-origin', 'admin-web');
        const token: string = storage.getItem(authToken);
        if (token) {
          headers.set(authToken, token);
        }
        return headers;
      },
    });
    

八、参考代码