1. 首页
  2. React

Vite6+React18+Ts项目-03.集成文件上传,axios请求、拦截及响应体规范

Axios 是一个基于 Promise 的 HTTP 客户端,用于浏览器和 Node.js 环境。它具有以下主要特点:

支持 Promise API,拦截请求和响应,转换请求和响应数据,自动转换 JSON 数据,客户端支持防御 XSRF,取消请求,超时处理,支持并发请求等

接下来我们对axios进行封装,以便在项目中使用

1.配置环境变量

在封装请求工具前,我们需要配置及导出环境变量

在项目根目录下创建.env文件,内容如下:

# api url
VITE_API_URL=https://dev.api.example.com

创建 src\config\configure.ts 文件,将环境变量导出

export const apiUrl = import.meta.env.VITE_API_URL

关于 vite .env 文件的说明和使用,可查看其它文章

2.安装 Axios

npm install axios

3.Axios类库

我们讲axios的创建封装为一个类,这样的好处是我们可以根据不同的情况创建多个axios实例

src\server\http\request\axios.ts 目录下创建axios类

import axios from 'axios'
import type { AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig, AxiosResponse, CreateAxiosDefaults } from 'axios'


// 获取请求ID
function uuid(config: AxiosRequestConfig): string {
  if (config == undefined || config == null) {
    return ''
  }
  const { method, url, params, data } = config;
  return `${method}:${url}:${JSON.stringify(params)}:${JSON.stringify(data)}`;
}

// 请求拦截器对象
interface RequestInterceptors<T> {
  // 请求拦截
  requestInterceptors?: (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig
  requestInterceptorsCatch?: (err: any) => any
  // 响应拦截
  responseInterceptors?: (config: T) => T
  responseInterceptorsCatch?: (err: any) => any
}

// 创建实例传递的参数
interface CreateRequestConfig<T = AxiosResponse> extends CreateAxiosDefaults {
  interceptors?: RequestInterceptors<T>
}

/**
 * 请求类
 * 拦截器执行顺序 全局请求拦截 -> 实例请求拦截 -> 接口请求transformRequest处理 -> 接口响应transformResponse -> 实例响应 -> 全局响应
 */
class Request {

  // axios 实例
  instance: AxiosInstance
  // 取消请求控制器
  abortController: Map<string, AbortController>

  constructor(config: CreateRequestConfig) {
    this.instance = axios.create(config)
    // 初始化存放取消请求控制器Map
    this.abortController = new Map();
    // 请求拦截-实例拦截器
    this.instance.interceptors.request.use(
      config.interceptors?.requestInterceptors,
      config.interceptors?.requestInterceptorsCatch,
    )
    // 请求拦截-全局
    this.instance.interceptors.request.use(
      (req: InternalAxiosRequestConfig) => {
        const controller = new AbortController()
        req.signal = controller.signal
        this.abortController.set(uuid(req), controller)
        return req
      },
      (err: any) => {
        return Promise.reject(err);
      },
    )
    // 响应拦截-实例拦截器
    this.instance.interceptors.response.use(
      config.interceptors?.responseInterceptors,
      config.interceptors?.responseInterceptorsCatch,
    )
    // 响应拦截-全局响应拦截器,保证最后执行
    this.instance.interceptors.response.use(
      (res: AxiosResponse) => {
        this.abortController.delete(uuid(res.config))
        return res
      },
      (err: any) => {
        return Promise.reject(err);
      },
    )
  }

  /**
   * 取消指定的请求
   * @param url 待取消的请求URL
   */
  cancelRequest(config: AxiosRequestConfig) {
    const key = uuid(config);
    this.abortController.get(key)?.abort()
    this.abortController.delete(key)
  }

  /**
   * 取消全部请求
   */
  cancelAllRequest() {
    for (const [, controller] of this.abortController) {
      controller.abort()
    }
    this.abortController.clear()
  }

}

export { Request }
export type { RequestInterceptors, CreateRequestConfig }

4.基于xhr的自定义Upload方法

在路径 src\server\http\request\upload.ts 下导出上传文件方法,该上传方法是基于xhr的,只适合小文件上传

// 上传策略
export interface UploadRequestPolicy {
  // 服务类型,file:本地存储,aliyun:阿里云存储,tencent:腾讯云存储
  service: string,
  // 文件上传地址
  uploadUrl: string,
  // 前缀
  prefix: string,
  // 文件名称键值
  key: string,
  // 文件大小限制
  maxSize: number,
  // 隐藏文件后缀
  hideExtension: number,
  // 默认后缀处理参数
  defaultExtensionProcessParam: string,
  // 文件域名
  accessFileDomain: string,
  // 允许的文件后缀
  allowFileExtension: FileExtension[]
}
// 上传文件请求配置
export interface UploadRequestConfig {
  policy: UploadRequestPolicy,
  onProgress: (percent: string, file: Blob | File, data: any, e: ProgressEvent<EventTarget>) => any,
  fail: (code: string, data ? :any) => any,
  success: (data: any) => any,
}
// 文件后缀
export interface FileExtension {
  code: string,
  mime: string,
  extension: string
}
// 上传结果
export interface UploadResult {
  ext: string | undefined,
  key: string,
  url: string
}

// 错误代码
export const UploadErrorCode = {
  FileIsNullOrUndefined: 'FileIsNullOrUndefined',
  FileSizeExceedsLimit: 'FileSizeExceedsLimit',
  UnSupportedFileExtensionType: 'UnSupportedFileExtensionType',
  CancelUpload: 'CancelUpload',
  RequestTimeout: 'RequestTimeout',
  UploadError: 'UploadError',
  UploadFailedWithUnknownError: 'UploadFailedWithUnknownError',
  UploadFailedAndReturnedContentIsEmpty: 'UploadFailedAndReturnedContentIsEmpty',
}

// 格式化文件大小
export function formatStorage(size: number) {
  if (0 === size) {
    return '0 B'
  };
  const k = 1024;
  const unit = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
  const i = Math.floor(Math.log(size) / Math.log(k));
  return (size / Math.pow(k, i)).toPrecision(3) + ' ' + unit[i];
}

function empty(v : any): boolean {
  if (v == undefined || v == null || v === '') {
    return true;
  }
  try {
    const json = JSON.stringify(v);
    if (json === '{}' || json === '[]') {
      return true;
    }
  } catch (e) {}
  return false;
}

function random(count : number): string {
  return count + '';
}

// 文件上传方法
export function upload(file: Blob | File, conf: UploadRequestConfig) : XMLHttpRequest | undefined {
  const config = {... {
    policy: {
        key: 'key',
        service: '',
        uploadUrl: '',
        accessFileDomain: ''
      },
      onProgress: function() {},
      fail: function() {},
      success: function() {}
    },
    ... {
      ...conf,
      policy: {
        ...conf.policy
      }
    }
  }

  // 判断文件是否为空
  if (file == null || file == undefined) {
    config.fail(UploadErrorCode.FileIsNullOrUndefined);
    return;
  }

  // 判断大小
  if (file.size > config.policy.maxSize) {
    config.fail(UploadErrorCode.FileSizeExceedsLimit, formatStorage(config.policy.maxSize));
    return;
  }

  // 判断后缀,先判断扩展名,如果扩展名为空,则判断文件mime类型
  let ext: string | undefined;
  let fileExtension!: FileExtension;
  if (file instanceof File && !empty(file.name)) {
    ext = file.name.split('.').pop()?.toLowerCase();
    for (let i = 0; i < config.policy.allowFileExtension.length; i++) {
      const item = config.policy.allowFileExtension[i];
      if (ext == item.extension) {
        fileExtension = item;
        break;
      }
    }
  } else {
    if (!empty(file.type)) {
      for (let i = 0; i < config.policy.allowFileExtension.length; i++) {
        const item = config.policy.allowFileExtension[i];
        if (file['type'] == item['mime']) {
          ext = item['extension'];
          fileExtension = item;
          break;
        }
      }
    }
  }
  if (empty(ext) || fileExtension == undefined) {
    config.fail(UploadErrorCode.UnSupportedFileExtensionType);
    return;
  }

  // 新建表单
  const formData = new FormData();

  // 追加请求参数
  formData.append('service', config.policy.service);
  formData.append('key', config.policy.key);
  formData.append('maxSize', `${config.policy.maxSize}`);
  formData.append('accessFileDomain', config.policy.accessFileDomain);

  // 重新命名文件
  let suffix = '';
  const mimeTypeCode = fileExtension.code;
  if (1 != config.policy.hideExtension) {
    suffix = '.' + ext;
  }

  const filePath = config.policy.prefix + mimeTypeCode + random(12) + suffix;
  formData.append('k', filePath);
  formData.append(config.policy.key, filePath);

  // 追加上传文件
  formData.append('file', file);

  const xhr = new XMLHttpRequest();
  xhr.open('POST', config.policy.uploadUrl, true);

  // 监听上传进度
  xhr.upload.onprogress = function(e) {
    if (e.lengthComputable) {
      const percent = ((e.loaded / e.total) * 100).toFixed(2);
      config.onProgress(percent, file, formData, e);
    }
  };

  // 监听请求结果
  xhr.onload = function() {
    // 需要优化,根据不同的服务平台,分别判断
    const res:UploadResult = {
      ext: ext,
      key: filePath,
      url: config.policy.accessFileDomain + filePath + ((1 == config.policy.hideExtension) ? ('-' + config.policy.defaultExtensionProcessParam + '.' + ext) : ''),
    };
    const data = xhr.responseText;
    // 返回data为空
    if (empty(data)) {
      if ('aliyun' == config.policy.service || 'tencent' == config.policy.service) {
        config.success(res);
      } else {
        config.fail(UploadErrorCode.UploadFailedAndReturnedContentIsEmpty);
      }
    } else {
      let parse = undefined;
      try {
        parse = JSON.parse(data)
      } catch(e) {
        console.log('Parse upload result json error:' + e);
      }
      if (typeof(parse) == 'undefined') {
        config.fail(UploadErrorCode.UploadFailedWithUnknownError);
      } else {
        if (1 == parse.code) {
          // 如果仓库类型为私有仓库,则必须使用服务器返回的url路径
          config.success({
            ...res,
            ...parse.data.ext
          });
        } else {
          config.fail(UploadErrorCode.UploadFailedWithUnknownError, empty(parse.info) ? "": parse.info);
        }
      }
    }
  };

  // 取消上传
  xhr.onabort = function() {
    config.fail(UploadErrorCode.CancelUpload);
  }
  // 发生错误
  xhr.onerror = function() {
    config.fail(UploadErrorCode.UploadError);
  }
  // 请求超时
  xhr.ontimeout = function() {
    config.fail(UploadErrorCode.RequestTimeout);
  }
  // 发送请求
  xhr.send(formData);

  return xhr;
}

5.创建 Axios 实例,导出api请求方法

创建 src\server\http\index.ts 文件,在该文件内定义响应体规范响应分页规范

import { Request } from './request/axios'
import { AxiosRequestConfig, InternalAxiosRequestConfig, AxiosResponse } from 'axios'

import { apiUrl } from '@/config/configure'
import { upload, UploadRequestConfig } from './request/upload'

// 返回实体规范,参考ant design pro
export interface ResponseEntity<T> {
  success: boolean;
  data: T;
  errorCode?: string; // code for errorType
  errorMessage?: string; // message display to user
  showType?: number; // error display type: 0 silent; 1 message.warn; 2 message.error; 4 notification; 9 page
  host?: string; // onvenient for backend Troubleshooting: host of current access server
  traceId?: string; // Convenient for back-end Troubleshooting: unique request ID
}

// 返回分页规范
export interface ResponsePageEntity<T> {
  list: T[];
  total?: number;  // 数据总数
  current?: number;  // 当前页码
  pageSize?: number;  // 每页数量
}

// 实例化类
const server = new Request({
  baseURL: apiUrl,
  timeout: 1000 * 60 * 5,
  interceptors: {
    // 请求拦截器
    requestInterceptors: (req: InternalAxiosRequestConfig) => {
      const token = localStorage.getItem('token') || 'token123456';
      // 添加 token
      req.headers = req.headers || {};
      req.headers['x-access-token'] = token;
      return req;
    },
  },
})

// 请求封装
const request = <D = any, T = any>(config: AxiosRequestConfig<D>): Promise<ResponseEntity<T>> => {
  return new Promise((resolve, reject) => {
    try {
      return server.instance.request<ResponseEntity<T>>(config)
        .then(res => {
          // 因为我们接口的数据都在res.data下,所以我们直接返回res.data
          resolve(res.data)
        })
        .catch((err) => {
          reject(err)
        })
    } catch (err) {
      return reject(err)
    }
  })
}

/* 导出封装的请求方法 */
const http = {
  request: request,
  get<D = any, T = any>(url: string, params?: D, config?: AxiosRequestConfig<D>): Promise<ResponseEntity<T>> {
    return request<D, T>({ url: url, method: 'GET', params: params, ...config })
  },
  post<D = any, T = any>(url: string, data?: D, config?: AxiosRequestConfig<D>): Promise<ResponseEntity<T>> {
    return request<D, T>({ url: url, method: 'POST', data: data, ...config })
  },
  put<D = any, T = any>(url: string, data?: D, config?: AxiosRequestConfig<D>): Promise<ResponseEntity<T>> {
    return request<D, T>({ url: url, method: 'PUT', data: data, ...config })
  },
  delete<D = any, T = any>(url: string, data?: D, config?: AxiosRequestConfig<D>): Promise<ResponseEntity<T>> {
    return request<D, T>({ url: url, method: 'DELETE', data: data, ...config })
  },
  upload(file: Blob | File, config: UploadRequestConfig): XMLHttpRequest | undefined {
    return upload(file, config);
  },
  cancelRequest(config: AxiosRequestConfig) {
    return server.cancelRequest(config)
  },
  cancelAllRequest() {
    return server.cancelAllRequest()
  },
}
export { server, http };

6. 在组件中使用

创建api文件,在 src\apis\demo.ts 创建请求api接口

import { ResponseEntity, http } from "@/server/http";

// 请求类型
export interface GetUserInfoReq {
  id: string
}

// 响应类型
export interface UserInfo {
  id: number,
  name: string,
  subject: Subject[]
}
export interface Subject {
  id: number,
  name: string
}

// 返回类型 Promise<ResponseEntity<fetchOkResp>> 可以省略,因为可以自动推断出来
export const getUserInfo = async (req: string) => {
  return http.get<{id: string}, UserInfo>(
    "http://demo.com/api.php",
    {
      id: req
    }
  );
};

// 完整示例,增加transform拦截
export const getUserInfoWithTransform = async (
  req: GetUserInfoReq
): Promise<ResponseEntity<UserInfo>> => {
  return http.get<GetUserInfoReq, UserInfo>(
    "http://demo.com/api.php?sleep=0",
    req,
    {
      transformRequest: [
        (data, headers) => {
          data = data || {};
          // 修改请求数据
          data.age = 30; // 修改 age 字段
          headers["token"] = "tokentokentokentoken"; // 修改请求头
          headers["custom-lang"] = "zh-s"; // 修改请求头
          return data; // 将对象转换为 JSON 字符串
        },
      ],
      transformResponse: [
        (data) => {
          // 修改响应数据
          const parsedData = JSON.parse(data); // 将 JSON 字符串转换为对象
          parsedData.message = "Hello, World!"; // 添加 message 字段
          return parsedData;
        },
      ],
    }
  );
};

在app组件中使用

import { RouterProvider } from 'react-router-dom';
import router from './routes';
import { useEffect, useState } from 'react';
import { getUserInfo, getUserInfoWithTransform, UserInfo } from './apis/demo';

function App() {

  const [user, setUser] = useState<UserInfo|undefined>(undefined);
  const [userWithTransform, setUserWithTransform] = useState<UserInfo|undefined>(undefined);

  useEffect(() => {
    getUserInfo('1').then((res) => {
      console.log(res.data.name)
      setUser(res.data)
    })
    getUserInfoWithTransform({
      id: "1"
    }).then((res) => {
      console.log(res.data.name)
      setUserWithTransform(res.data)
    })
  }, []);
  
  return (
    <>
      <h1>Vite + React</h1>
      <p>user: {JSON.stringify(user)}</p>
      <p>userWithTransform: {JSON.stringify(userWithTransform)}</p>
      <RouterProvider router={router} />
    </>
  );
}

export default App;

TOP