axios + React封装自己的useRequest

351 阅读4分钟

前言

  1. 借鉴了openapi-generator生成代码的思想

  2. 为什么封装自己的useRequest;

    原因如下:

    1. 在这ts横行的年代,我们大多爬虫api文档再用json-schema-to-typescript去解析后端给的api文档来生成我们想要的模板请求方法,自己封装useRequest去配合他。实现以一个类的形式去包裹某个api类型tag的create、findOne、findMany、createMany、updatedOne、deleteOne几种情况。这样我们就可以用useRquest里面的配置去配合使用new ClassAPi
    2. 在登陆失效情况下我们时常要跳转登录。我们用到react-dom-router,一切都是hooks的情况下。却为什么还要用着window.location。我曾经遇到过这种问题,在stackoverflow,作者也表示尽量不要使用window.location;虽然router原理是由context和window.history弄的。但是我们不要混用,既然用就用一个;
    3. 配合react组件每次卸载时,取消卸载组件的后续未完成的接口请求。单单靠axios虽然可以满足重复请求取消、跳转页面的取消上一个页面的请求。但是我要做的不仅仅是页面还有每个组件

实现配置类FetchAsync

// src/api/request/index
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
// import jwt_decode, { JwtPayload } from 'jwt-decode'
import { transformInterceptor } from './interceptors'
import { getLockr, removeLockr, setLockr } from '@utils/localStr'

export interface ConfigurationParameters {
  baseUrl: string

  updateTokenUrl?: string

  goLogin?: () => void
  //登录接口
  loginUrl: string

  logoutUrl: string

  errorNotify: (msg: string) => void

  instance?: AxiosInstance
}

export class FetchAsync {

  public baseUrl: string = ''

  public updateTokenUrl?: string = ''

  //请求报错提示方法
  public errorNotify: (msg: string) => void

  //跳转路由函数
  public goLogin?: () => void

  //登录路径
  public loginUrl: string = ''

  //登出路径
  public logoutUrl: string

  //设置跳转路由函数
  public setGoLogin: (() => void) | undefined

  public instance: AxiosInstance


  constructor(configuration: ConfigurationParameters) {
    this.errorNotify = configuration.errorNotify
    this.baseUrl = configuration.baseUrl;
    this.updateTokenUrl = configuration.updateTokenUrl
    this.loginUrl = configuration.loginUrl
    this.logoutUrl = configuration.logoutUrl
    this.instance = axios.create({
      timeout: 20000,
      responseType: 'json',
    })
    //拦截封装
    transformInterceptor(this.instance)
  }



  //设置跳转函数
  public async setGoLoginCallback(fn: () => void) {
    this.goLogin = fn;
  }

}

实现封装请求拦截器

//  src/api/request/interceptors.ts

import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, Canceler } from 'axios'
import { getLockr, removeLockr, setLockr } from '@utils/localStr';
// 取消请求操作
// const allPendingRequestsRecord: Canceler[] = [];
const pending: {
  [key in string]: Canceler
} = {};

// 取消同一个重复的ajax请求
const removePending = (key: string, isRequest: boolean = false) => {
  if (pending[key] && isRequest) {
    pending[key](key + ':取消重复请求');
  }
  delete pending[key];
};

function getContentType(headers: any) {
  const keys = Object.keys(headers)
  for (const k of keys) {
    if (k.toLocaleLowerCase() === 'content-type') {
      return headers[k]
    }
  }
  return ''
}

/** 针对某些接口修改参数和返回值 */
export function transformInterceptor(ax: AxiosInstance) {
  // 添加请求拦截器
  ax.interceptors.request.use(
    async (config: AxiosRequestConfig) => {

      let reqData: string = '';
      // 处理如url相同请求参数不同时上一个请求被屏蔽的情况
      if (config.method?.toLocaleLowerCase() === 'get') {
        reqData = config.url + config.method + JSON.stringify(config.params);
      } else if (config.method) {
        reqData = config!.url + config!.method + JSON.stringify(config.data);
      }

      // 如果用户连续点击某个按钮会发起多个相同的请求,可以在这里进行拦截请求并取消上一个重复的请求
      removePending(reqData, true);
      if (config.url!.indexOf('/auth/local') > -1) {
        setLockr('jwt', '')
      } else {
        let token: string = await getLockr('jwt')
        config.headers!.Authorization = `Bearer ${token}`;
      }


      config.cancelToken = new axios.CancelToken((c: any) => {
        pending[reqData] = c;
      });

      return config
    },
    (err: any) => {
      return Promise.reject(err)
    }
  )

  // 添加响应拦截器
  ax.interceptors.response.use(
    async (res: AxiosResponse) => {

      return res
    },
    (err: AxiosError) => {
      return Promise.reject(err)
    }
  )
}



实现请求基类

// src/api/request/index
export class BaseAPI {

  //请求登陆后是否继续请求
  private isShowError: boolean = false

  protected signal?: AbortSignal

  private configuration: FetchAsync

  private token: string

  private tokenIsValid: boolean = false

  constructor(protected config: { configuration: FetchAsync, signal?: AbortSignal }) {
    this.configuration = config.configuration
    this.signal = config.signal
    this.token = getLockr('jwt')
  }



  // private getTokenValid() {
  //     this.token = getLockr('jwt')
  //     if (this.token !== '') {
  //         let decoded: JwtPayload;
  //         decoded = jwt_decode(this.token || '')
  //         let exp = decoded.exp as number
  //         let cur = Math.floor(Date.now() / 1000)
  //         let d = exp - cur
  //         return d
  //     } else {
  //         return -1
  //     }
  // }


  // 错误处理
  private async handleApiError(error: AxiosError) {
    const that = this;
    let code = 500
    let data: any;

    // if (axios.isCancel(error)) {
    //   return new Promise(() => { });
    // }

    if (error.response) {
      data = error.response.data
      code = error.response.status
    }
    if (code === 403) {
      that.configuration.errorNotify(data.error.message)
      return Promise.reject(error)
    }

    if ([401].includes(code)) {
      that.configuration.errorNotify('登录失效, 请重新登录!')
      if (this.isShowError) {
        return;
      }
      this.isShowError = true
      this.tokenIsValid = false
      this.token = ''
      that.configuration.goLogin && that.configuration.goLogin()
      setLockr('jwt', '')
      return Promise.reject(error)

    } else {
      let msg = '未知错误'
      const errorMsg = error.message || ''

      if (errorMsg.includes('Network Error')) {
        msg =
          '请检查网络' +
          (error.config && error.config.url
            ? error.config.url
            : JSON.stringify(error.request))
      } else if (errorMsg.includes('timeout') && errorMsg.includes('exceeded')) {
        msg = '请求超时'
      }

      if (data) {
        if (data.error.message === 'Invalid identifier or password') {
          msg = '无效的账号密码'
        } else {
          msg = data.error.message
        }
        // @ts-ignore
      }

      // @ts-ignore

      that.configuration.errorNotify(msg || '系统错误,请稍后重试')


      return Promise.reject(error)
    }
  }


  protected async request<T>(url: string, options: AxiosRequestConfig) {
    const that = this;
    let newUrl: string = '';
    if (url.indexOf('http') > -1) {
      newUrl = url
    } else {
      newUrl = this.configuration.baseUrl + url;
    }

    if (that.isShowError && url.indexOf(that.configuration.loginUrl) !== -1) {
      return null
    }
    // const d = await that.getTokenValid.call(that)

    //时间少于5分钟进行续签操作
    // if (d < 60 * 60 * 5 && d > 0 && that.configuration.updateTokenUrl) {
    //   const res = await that.request('updateTokenUrl' + `?token=${that.token}`, {}) as { jwt: string }
    //   await setLockr('jwt', '')
    //   await setLockr('jwt', res.jwt)
    //   that.token = await getLockr('jwt')
    // }

    const myInstance = that.configuration.instance || axios

    return myInstance.request<T>({
      url: newUrl,
      ...options,
      signal: this.signal,
      responseType: options.responseType || 'json'
    })
      .then((res: AxiosResponse) => {
        // @ts-ignore
        //登入
        if (url === that.configuration.loginUrl) {
          that.tokenIsValid = true
          setLockr('jwt', res.data.jwt)
          that.token = res.data.jwt
          that.isShowError = false
        }
        //登出
        if (url === that.configuration.logoutUrl) {
          removeLockr('jwt')
          that.token = ''
          that.tokenIsValid = false;
        }
        return res.data as T
      })
      .catch((err: AxiosError) => 
        that.handleApiError(err)
        throw err
      })
  }
}

使用

  1. 每个人或者团队根据API文档生成的代码风格各不一样。下面是我自动生成代码其中某个demo列子的风格
// @ts-nocheck

/**
 * ------------------------------------
 * !!! 不要修改,这是生成的代码 !!!
 * ------------------------------------
 */

import { BaseAPI, Options } from '../request'

export default class CustomerApi extends BaseAPI {
    /**
     *     *     */

    async getCustomers(
        params: Strapi.QueryGetCustomers,
        options: Options = {}
    ) {
        const headers = {}

        const url = `/customers`
        return await this.request<Strapi.ResGetCustomers>(url, {
            method: 'GET',
            params,
            ...options,
            headers: {
                ...headers,
                ...(options.headers || {}),
            },
        })
    }
    /**
     *     *     */

    async postCustomers(data: Strapi.ReqPostCustomers, options: Options = {}) {
        const headers = { 'content-type': 'application/json' }

        const url = '/customers'
        return await this.request<Strapi.ResPostCustomers>(url, {
            method: 'POST',
            data,
            ...options,
            headers: {
                ...headers,
                ...(options.headers || {}),
            },
        })
    }
    /**
     *     *     */

    async getCustomersId(
        paths: Strapi.ParamGetCustomersId,
        options: Options = {}
    ) {
        const headers = {}

        const url = '/customers/{id}'.replace(
            '{id}',
            String(paths['id'])
        )
        return await this.request<Strapi.ResGetCustomersId>(url, {
            method: 'GET',
            apiPath: '/customers/{id}',
            ...options,
            headers: {
                ...headers,
                ...(options.headers || {}),
            },
        })
    }
    /**
     *     *     */

    async putCustomersId(
        paths: Strapi.ParamPutCustomersId,
        data: Strapi.ReqPutCustomersId,
        options: Options = {}
    ) {
        const headers = { 'content-type': 'application/json' }

        const url = '/customers/{id}'.replace(
            '{id}',
            String(paths['id'])
        )
        return await this.request<Strapi.ResPutCustomersId>(url, {
            method: 'PUT',
            data,
            apiPath: '/customers/{id}',
            ...options,
            headers: {
                ...headers,
                ...(options.headers || {}),
            },
        })
    }
    /**
     *     *     */

    async deleteCustomersId(
        paths: Strapi.ParamDeleteCustomersId,
        options: Options = {}
    ) {
        const headers = {}

        const url = '/customers/{id}'.replace(
            '{id}',
            String(paths['id'])
        )
        return await this.request<Strapi.ResDeleteCustomersId>(url, {
            method: 'DELETE',
            apiPath: '/customers/{id}',
            ...options,
            headers: {
                ...headers,
                ...(options.headers || {}),
            },
        })
    }
}

useRequest封装

/*
 * @Author: 伍东京 tokyoalex55@gmail.com
 * @Date: 2022-11-01 23:09:13
 * @LastEditors: 伍东京 tokyoalex55@gmail.com
 * @LastEditTime: 2022-11-17 18:43:16
 * @FilePath: \tuzhi-bill2\src\hooks\useRequest.ts
 * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
 */

import { message } from 'antd';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { ConfigurationParameters, FetchAsync } from '@api/request'
const BaseUrl = process.env.BASE_URL

const debounce = (fn: any) => {
    let timer: NodeJS.Timeout | null = null;
    return (msg: string) => {
        if (timer === null) {
            fn(msg)
            timer = setTimeout(function () {
                timer && clearTimeout(timer);
                timer = null
            }, 2000);
        }
    }
}

const defaultConfig: ConfigurationParameters = {
    baseUrl: BaseUrl + '/api',
    loginUrl: '/auth/local',
    logoutUrl: '/auth',
    errorNotify: debounce(message.error),
}

let configuration = new FetchAsync(defaultConfig)

export default function useRequest<T>(apiClasses: any[],config?: ConfigurationParameters): T {

    const navigate = useNavigate()
    const controller = new AbortController();
    if (config) configuration = new FetchAsync(Object.assign({}, defaultConfig, config))
    useEffect(() => {
        if (!configuration.goLogin)
            configuration.setGoLoginCallback(() => { navigate('/login') })
        return () => {
            controller.abort()
        }
    }, []);

    const apiConfig: any = {
       configuration,
    }
    
    //因为开发模式严格模式下useEffect会执行两次,会导致提前取消请求,所以在生产环境使用即可
    if(process.env.NODE_EN === 'production') {
      apiConfig.signal = controller.signal
    }

    const apis = apiClasses.map((ApiClass: any) => {
      return new ApiClass(apiConfig)
    }) as T
    return apis
}

组件中使用

import React, { useEffect } from 'react';
import { useRequest } from 'src/hooks';
import { CustomerApi, ModelApi, OrderHeaderApi } from '@api/strapi';

type IProps = {};

const Login = (props: IProps) => {
  const [orderHeaderApi, customerApi] = useRequest<
    [OrderHeaderApi, CustomerApi]
  >([OrderHeaderApi, ModelApi]);

  useEffect(() => {
    (async () => {
      const response = await customerApi.getCustomersId({ id: 7 });
    })();
  },[]);
  return <div>Login</div>;
};

export default Login;



截屏2022-12-08 17.13.43.png

项目源码

  1. 我基于create-react-app搭了个自己用的架子代码都在里面