工程化之Axios + Ts的二次封装

3,075 阅读13分钟

本文所搭建的模版可在github查看:Axios+Ts二次封装

实现功能

本文将通过Axios + Ts的封装来实现以下功能:

1、良好的输入和输出类型支持

2、创建Axios实例,使用拦截器来对请求和响应做处理

3、支持创建多个Axios实例,每个实例可以有不同的配置以及拦截器

目录结构

和api相关的所有代码都放在apis目录下,apis目录如下:

├─apis
|  ├─index.ts // 统一导出apis里的接口
|  ├─modules // 将不同业务的接口拆分到不同模块
|  |    ├─index.ts // 统一导出不同业务接口
|  |    ├─user // user业务
|  |    |  ├─index.ts // user业务接口
|  |    |  └types.ts // user业务接口类型定义
|  |    ├─shop // shop业务
|  |    |  ├─index.ts // shop业务接口
|  |    |  └types.ts // shop业务接口类型定义
|  ├─instances // Axios实例
|  |     ├─commonConfig.ts // 实例的通用配置
|  |     ├─create.ts // 创建实例逻辑
|  |     ├─index.ts // 导出实例
|  |     └types.ts // 实例相关的类型定义

安装

首先安装axios

pnpm install axios

简单的Axios封装

我们先实现一个简单的Axios封装,然后再找出其中的可以优化的地方封装一个加强版的。

创建实例

首先我们可以创建一个实例

import axios from 'axios';

const instance = axios.create({
  baseURL: '/',
  // 指定请求超时的毫秒数
  timeout: 3000,
  // 表示支持跨域请求携带Cookie,默认是false,表示不携带Cookie
  // 同时需要后台配合,返回需要有以下字段,
  // 如果该字段设置为true,但是后台没有返回以下两个字段的话浏览器是会报错的
  // Access-Control-Allow-Credentials: true
  // Access-Control-Allow-Origin: 当前页面的域名
  withCredentials: false,
});

这里我们创建了一个实例,指定了3个参数,其它更多的参数可以看下Axios官网,这里的baseURL和timeout都很好理解,比较容易疑惑的是withCredentials这个属性。

withCredentials

withCredentials这个属性在跨域的时候需要用到,默认值是fasle,当值为false的时候,跨域的请求不会携带Cookie,比如我们在京东买东西的时候,京东网站的域名是jd.com,可是我们请求用户信息的接口的域名是api.jd.com,这两个域名不一致(跨域),这时候当我们请求api.jd.com接口信息的时候是不会携带api.jd.com下的Cookie信息的,这样就无法获取登陆态等信息,所以我们需要让这个跨域请求可以携带Cookie信息,这时候需要把withCredentials这个请求头设置为true。

同时需要后台也进行对应的设置才行,也就是携带Cookie这个操作需要前后端都确认才能真正有效,后台需要在返回头中添加如下字段告诉浏览器同意在当前页面跨域请求这个接口,同时携带Cookie,设置如下:

Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: 'jd.com'

如果前端了设置了Access-Control-Allow-Credentials: true,后段返回没设置,那么浏览器是会报错的。

设置拦截器

创建好实例后我们需要来设置拦截器,我们的接口可能有一些公共操作,比如统一在请求头带上某个字段,或者统一对响应做一些相关处理等,这时候可以设置请求拦截器和响应拦截器,更具体的使用方法可以查看官网。

设置请求拦截器

在请求拦截器里我们可以对AxiosRequestConfig做一些更改,比如更改请求头,这个需要根据自己的业务来定,我们这边只是原样返回:

// 前置拦截器(发起请求之前的拦截)
instance.interceptors.request.use(
  (config: AxiosRequestConfig) => {
    /**
     * 在这里一般会携带前台的参数发送给后台,比如下面这段代码:
     * const token = getToken()
     * if (token) {
     *  config.headers.token = token
     * }
     */
    return config;
  },
  (error) => {
    const errorMsg = error?.message || 'Request Error';
    ElMessage({
      message: errorMsg,
      type: 'error',
    });
    return Promise.reject(error);
  }
);

设置响应拦截器

响应拦截器可以对返回数据和错误做统一处理,响应器返回的response结构是AxiosResponse,AxiosResponse的类型定义如下:

export interface AxiosResponse<T = any, D = any> {
  data: T;
  status: number;
  statusText: string;
  headers: RawAxiosResponseHeaders | AxiosResponseHeaders;
  config: AxiosRequestConfig<D>;
  request?: any;
}

我们每次要得到后台真正返回的数据都要用response.data来拿到,为了可以直接拿到数据,我们在响应拦截器里做处理,返回response.data,同时这里也可以根据自己的业务以及和后台的约定,对一些业务逻辑的错误做统一处理,比如约定后台接口返回码不是0的时候认为是业务错误,然后统一进行错误提示。

如果不是业务错误,是网络错误的话,我们可以在拦截器的第二个回调函数里统一处理。

// 后置拦截器(获取到响应时的拦截)
instance.interceptors.response.use(
  (response: AxiosResponse) => {
    // 这里我们将后台返回的数据解构出来返回,方便后续获取
    const { data } = response;
    return data;
    // 这里根据其它业务可以做其它特殊的拦截,比如根据后台返回的data有固定的格式,根据后台返回的code可以做一些统一处理,比如像下面这样
    // const { code, message, data } = response.data;

    // // 根据自定义错误码判断请求是否成功
    // if (code === 0) {
    //   // 将组件用的数据返回
    //   return data;
    // } else {
    //   // 处理业务错误。
    //   ElMessage({
    //     message: message,
    //     type: 'error',
    //   });
    //   return Promise.reject(new Error(message));
    // }
  },
  (error) => {
    const { response } = error;
    // 处理 HTTP 网络错误
    let message = '';
    // HTTP 状态码
    const status = response?.status;
    switch (status) {
      case 401:
        message = 'token 失效,请重新登录';
        // 这里可以触发退出的 action
        break;
      case 403:
        message = '拒绝访问';
        break;
      case 404:
        message = '请求地址错误';
        break;
      case 500:
        message = '服务器故障';
        break;
      default:
        message = '网络连接故障';
    }

    ElMessage({
      message: message,
      type: 'error',
    });
    return Promise.reject(error);
  }
);

封装请求函数

然后我们封装一些常用的请求方法,如下:

// 导出常用函数

/**
 * @param {string} url
 * @param {object} data
 * @param {object} params
 */
export function post(url: string, data = {}, params = {}) {
  return instance({
    method: 'post',
    url,
    data,
    params,
  });
}

/**
 * @param {string} url
 * @param {object} params
 */
export function get(url: string, params = {}) {
  return instance({
    method: 'get',
    url,
    params,
  });
}

/**
 * @param {string} url
 * @param {object} data
 * @param {object} params
 */
export function put(url: string, data = {}, params = {}) {
  return instance({
    method: 'put',
    url,
    params,
    data,
  });
}

/**
 * @param {string} url
 * @param {object} params
 */
export function _delete(url: string, params = {}) {
  return instance({
    method: 'delete',
    url,
    params,
  });
}

书写接口

这时候我们在apis下新建一个文件来写我们接口,比如上面的代码都写在instance.ts文件里面,这时候我们写接口的文件大概会像下面这样:

import {get, post} from './instance';

const login = (params) => {
  return post('/login', params);
};

const getUserInfo = (params) => {
  return get('/userinfo', { params });
};

export { login, getUserInfo };

使用接口

然后在我们的业务组件里引入这两个接口就可以了:

import {login, getUserInfo} from '@/api';

async function() {
    const loginResult = await login(params);
    
    if (!loginResult.success) return;
    
    const userInfo = await getUserInfo(params);
    
    // ...
}

到这里为止其实使用接口已经比较方便了,但是还有一些可以优化的地方,特别是当项目变得越来越复杂的时候。

优化

1、目前我们所有的接口都是用的一个配置,如果项目中有不同的业务需要不同的配置,比如shop相关的业务接口前缀都是/shop,且对返回的数据都需要做额外的校验,这时候只用一个实例就比较麻烦了,我们可以根据不同的业务创建多个实例。

2、我们通过响应拦截器更改了返回的格式,我们把data从response里取出来直接返回了,可是编辑器是不知道的,我们需要更改对应的返回类型让编辑器可以正确提示。

支持多实例创建

首先我们在apis目录下创建instances目录,该目录用来管理实例的相关逻辑。然后我们在instancs目录下创建create.ts文件,用来创建实例,create.ts文件的代码如下,下面会讲解该文件里的内容:

import axios, { AxiosRequestConfig } from 'axios';
import {
  commonRequestConfig,
  commonRequestInterceptors,
  commonResponseInterceptors,
} from './commonConfig';

import { RequestInterceptor, ResponseInterceptor } from './types';

// 创建请求实例,允许不同的实例设置不同的配置,这些配置会和默认配置合并
function createInstance(
  config: AxiosRequestConfig,
  requestInterceptors: RequestInterceptor[],
  responseInterceptors: ResponseInterceptor[]
) {
  const instance = axios.create({
    ...commonRequestConfig,
    ...config,
  });

  const allRequestInterceptors: RequestInterceptor[] = [
    ...commonRequestInterceptors,
    ...requestInterceptors,
  ];

  // 设置所有请求拦截器
  allRequestInterceptors.forEach((requestInterceptor) => {
    instance.interceptors.request.use(
      requestInterceptor.onFulfilled,
      requestInterceptor.onRejected
    );
  });

  const allResponseInterceptors: ResponseInterceptor[] = [
    ...commonResponseInterceptors,
    ...responseInterceptors,
  ];

  // 设置所有响应拦截器
  allResponseInterceptors.forEach((responseInterceptor) => {
    instance.interceptors.response.use(
      responseInterceptor.onFulfilled,
      responseInterceptor.onRejected
    );
  });

  // 导出常用函数

  /**
   * @param {string} url
   * @param {object} data
   * @param {object} params
   */
  function post<T>(url: string, data = {}, params = {}): Promise<T> {
    return instance({
      method: 'post',
      url,
      data,
      params,
    });
  }

  /**
   * @param {string} url
   * @param {object} params
   */
  function get<T>(url: string, params = {}): Promise<T> {
    return instance({
      method: 'get',
      url,
      params,
    });
  }

  /**
   * @param {string} url
   * @param {object} data
   * @param {object} params
   */
  function put<T>(url: string, data = {}, params = {}): Promise<T> {
    return instance({
      method: 'put',
      url,
      params,
      data,
    });
  }

  /**
   * @param {string} url
   * @param {object} params
   */
  function _delete<T>(url: string, params = {}): Promise<T> {
    return instance({
      method: 'delete',
      url,
      params,
    });
  }

  return {
    instance,
    post,
    get,
    put,
    _delete,
  };
}

export default createInstance;

首先我们导入通用的请求配置和拦截器配置:

import {
  commonRequestConfig,
  commonRequestInterceptors,
  commonResponseInterceptors,
} from './commonConfig';

然后定义createInstance函数:

// 创建请求实例,允许不同的实例设置不同的配置,这些配置会和默认配置合并
function createInstance(
  config: AxiosRequestConfig,
  requestInterceptors: RequestInterceptor[],
  responseInterceptors: ResponseInterceptor[]
){
// ...
}

该函数支持传入三个参数,第一个参数是请求了配置,传进来的配置会和通用配置合并,然后创建实例:

  const instance = axios.create({
    ...commonRequestConfig,
    ...config,
  });

然后第二个参数是请求拦截器列表,会添加到通用请求拦截器后面:

  const allRequestInterceptors: RequestInterceptor[] = [
    ...commonRequestInterceptors,
    ...requestInterceptors,
  ];

  // 设置所有请求拦截器
  allRequestInterceptors.forEach((requestInterceptor) => {
    instance.interceptors.request.use(
      requestInterceptor.onFulfilled,
      requestInterceptor.onRejected
    );
  });

然后第三个参数是响应拦截器列表,会添加到通用响应拦截器后面:

  const allResponseInterceptors: ResponseInterceptor[] = [
    ...commonResponseInterceptors,
    ...responseInterceptors,
  ];

  // 设置所有响应拦截器
  allResponseInterceptors.forEach((responseInterceptor) => {
    instance.interceptors.response.use(
      responseInterceptor.onFulfilled,
      responseInterceptor.onRejected
    );
  });

这样我们的配置和拦截器就都可以根据不同的实例进行特殊的配置了。

然后我们定义常用的一些请求方法:

  /**
   * @param {string} url
   * @param {object} data
   * @param {object} params
   */
  function post<T>(url: string, data = {}, params = {}): Promise<T> {
    return instance({
      method: 'post',
      url,
      data,
      params,
    });
  }

  /**
   * @param {string} url
   * @param {object} params
   */
  function get<T>(url: string, params = {}): Promise<T> {
    return instance({
      method: 'get',
      url,
      params,
    });
  }
  
    /**
   * @param {string} url
   * @param {object} data
   * @param {object} params
   */
  function put<T>(url: string, data = {}, params = {}): Promise<T> {
    return instance({
      method: 'put',
      url,
      params,
      data,
    });
  }

  /**
   * @param {string} url
   * @param {object} params
   */
  function _delete<T>(url: string, params = {}): Promise<T> {
    return instance({
      method: 'delete',
      url,
      params,
    });
  }

这里需要注意的是我们加了一个泛型T,然后我们函数返回的是Promise<T>,如果不加这个,那么根据Axios的定义,返回的结果类型是Promise<AxiosResponse>

我们来看下Axios里的类型定义:

export class Axios {
  constructor(config?: AxiosRequestConfig);
  defaults: AxiosDefaults;
  interceptors: {
    request: AxiosInterceptorManager<AxiosRequestConfig>;
    response: AxiosInterceptorManager<AxiosResponse>;
  };
  getUri(config?: AxiosRequestConfig): string;
  request<T = any, R = AxiosResponse<T>, D = any>(config: AxiosRequestConfig<D>): Promise<R>;
  get<T = any, R = AxiosResponse<T>, D = any>(url: string, config?: AxiosRequestConfig<D>): Promise<R>;
  delete<T = any, R = AxiosResponse<T>, D = any>(url: string, config?: AxiosRequestConfig<D>): Promise<R>;
  head<T = any, R = AxiosResponse<T>, D = any>(url: string, config?: AxiosRequestConfig<D>): Promise<R>;
  options<T = any, R = AxiosResponse<T>, D = any>(url: string, config?: AxiosRequestConfig<D>): Promise<R>;
  post<T = any, R = AxiosResponse<T>, D = any>(url: string, data?: D, config?: AxiosRequestConfig<D>): Promise<R>;
  put<T = any, R = AxiosResponse<T>, D = any>(url: string, data?: D, config?: AxiosRequestConfig<D>): Promise<R>;
  patch<T = any, R = AxiosResponse<T>, D = any>(url: string, data?: D, config?: AxiosRequestConfig<D>): Promise<R>;
  postForm<T = any, R = AxiosResponse<T>, D = any>(url: string, data?: D, config?: AxiosRequestConfig<D>): Promise<R>;
  putForm<T = any, R = AxiosResponse<T>, D = any>(url: string, data?: D, config?: AxiosRequestConfig<D>): Promise<R>;
  patchForm<T = any, R = AxiosResponse<T>, D = any>(url: string, data?: D, config?: AxiosRequestConfig<D>): Promise<R>;
}

可以看到这些方法放回的类型是Promise<R>,而R又是AxiosResponse<T>AxiosResponse的类型定义如下:

export interface AxiosResponse<T = any, D = any> {
  data: T;
  status: number;
  statusText: string;
  headers: RawAxiosResponseHeaders | AxiosResponseHeaders;
  config: AxiosRequestConfig<D>;
  request?: any;
}

我们在拦截器里的response的类型就是AxiosResponse,然后我们通过拦截器更改了返回,直接返回了里面的data。

所以我们通过这样定义类型后:

  /**
   * @param {string} url
   * @param {object} params
   */
  function get<T>(url: string, params = {}): Promise<T> {
    return instance({
      method: 'get',
      url,
      params,
    });
  }

就可以在定义接口的时候正确设置和返回类型了,比如像下面这样设置:

import { get } from '@/apis';
import { UserInfoParams, UserInfoRes } from './types';

const getUserInfo = (params: UserInfoParams) => {
  return get<UserInfoRes>('/userinfo', { params });
};

再回到上面的createInstance函数,当函数里的实例以及相关方法都创建好之后,再把实例和相关方法返回:

  return {
    instance,
    post,
    get,
    put,
    _delete,
  };

通用配置

在createInstance函数里使用到的通用配置如下:

// commonCoonfig.ts
import { ElMessage } from 'element-plus';
import type { AxiosRequestConfig, AxiosResponse } from 'axios';
import { RequestInterceptor, ResponseInterceptor } from './types';

// 通用请求配置
const commonRequestConfig: AxiosRequestConfig = {
  baseURL: '/',
  // 指定请求超时的毫秒数
  timeout: 3000,
  // 表示支持跨域请求携带Cookie,默认是false,表示不携带Cookie
  // 同时需要后台配合,返回需要有以下字段,
  // 如果该字段设置为true,但是后台没有返回以下两个字段的话浏览器是会报错的
  // Access-Control-Allow-Credentials: true
  // Access-Control-Allow-Origin: 当前页面的域名
  withCredentials: false,
};

// 通用的请求拦截器
const commonRequestInterceptors: RequestInterceptor[] = [
  {
    onFulfilled: (config: AxiosRequestConfig) => {
      /**
       * 在这里一般会携带前台的参数发送给后台,比如下面这段代码:
       * const token = getToken()
       * if (token) {
       *  config.headers.token = token
       * }
       */
      return config;
    },
    onRejected: (error) => {
      const errorMsg = error?.message || 'Request Error';
      ElMessage({
        message: errorMsg,
        type: 'error',
      });
      return Promise.reject(error);
    },
  },
];

// 通用的响应拦截器
const commonResponseInterceptors: ResponseInterceptor[] = [
  {
    onFulfilled: (response: AxiosResponse) => {
      // 这里我们将后台返回的数据解构出来返回,方便后续获取
      const { data } = response;
      return data;
      // 这里根据其它业务可以做其它特殊的拦截,比如根据后台返回的data有固定的格式,根据后台返回的code可以做一些统一处理,比如像下面这样
      // const { code, message, data } = response.data;

      // // 根据自定义错误码判断请求是否成功
      // if (code === 0) {
      //   // 将组件用的数据返回
      //   return data;
      // } else {
      //   // 处理业务错误。
      //   ElMessage({
      //     message: message,
      //     type: 'error',
      //   });
      //   return Promise.reject(new Error(message));
      // }
    },
    onRejected: (error) => {
      const { response } = error;
      // 处理 HTTP 网络错误
      let message = '';
      // HTTP 状态码
      const status = response?.status;
      switch (status) {
        case 401:
          message = 'token 失效,请重新登录';
          // 这里可以触发退出的 action
          break;
        case 403:
          message = '拒绝访问';
          break;
        case 404:
          message = '请求地址错误';
          break;
        case 500:
          message = '服务器故障';
          break;
        default:
          message = '网络连接故障';
      }

      ElMessage({
        message: message,
        type: 'error',
      });
      return Promise.reject(error);
    },
  },
];

export {
  commonRequestConfig,
  commonRequestInterceptors,
  commonResponseInterceptors,
};

类型定义

所使用的拦截器相关类型定义如下:

import type { AxiosRequestConfig, AxiosResponse } from 'axios';

export type OnFulfilled<V> = ((value: V) => V | Promise<V>) | null;
export type OnRejected = ((error: any) => any) | null;

export type RequestInterceptor = {
  onFulfilled: OnFulfilled<AxiosRequestConfig>;
  onRejected?: OnRejected;
};

export type ResponseInterceptor = {
  onFulfilled: OnFulfilled<AxiosResponse>;
  onRejected?: OnRejected;
};

创建实例

然后我们在instances/index.ts里创建和导出实例:

import createInstance from './create';

// 基础的Axios实例,请求配置以及拦截器都是使用的通用的配置
const baseInstance = createInstance({}, [], []);

// Shop业务可能前缀都是/shop,可以在这里统一处理,
// 如果需要对请求和返回做特殊处理,也可以在这里加拦截器
const shopInstance = createInstance(
  {
    baseURL: '/shop',
  },
  [],
  []
);

export { baseInstance, shopInstance };

拆分接口

我们需要将不同的业务接口拆分到不同的模块里面,我们在apis下创建modules目录,目录下的每个文件夹对应不同的业务,每个文件的功能如下:

├─apis
|  ├─index.ts // 统一导出apis里的接口
|  ├─modules // 将不同业务的接口拆分到不同模块
|  |    ├─index.ts // 统一导出不同业务接口
|  |    ├─user // user业务
|  |    |  ├─index.ts // user业务接口
|  |    |  └types.ts // user业务接口类型定义
|  |    ├─shop // shop业务
|  |    |  ├─index.ts // shop业务接口
|  |    |  └types.ts // shop业务接口类型定义

然后我们看下每个文件的代码:

apis/modules/user/index.ts:

import { baseInstance } from '@/apis';
import { LoginParams, LoginRes, UserInfoParams, UserInfoRes } from './types';

const { post: basePost, get: baseGet } = baseInstance;

const login = (params: LoginParams) => {
  return basePost<LoginRes>('/login', params);
};

const getUserInfo = (params: UserInfoParams) => {
  return baseGet<UserInfoRes>('/userinfo', { params });
};

export { login, getUserInfo };

apis/modules/user/type.ts:

export interface LoginParams {
  name: string;
  password: string;
}

export interface LoginRes {
  success: boolean;
}

export interface UserInfoParams {
  userId: string;
}

export interface UserInfoRes {
  userId: string;
  usename: string;
  age: number;
}

apis/modules/shop/index.ts:

import { shopInstance } from '@/apis';
import {
  AddShopParams,
  AddShopRes,
  GetShopDetailParams,
  GetShopDetailRes,
} from './types';

const { post: shopPost, get: shopGet } = shopInstance;

const addShop = (params: AddShopParams) => {
  return shopPost<AddShopRes>('/addShop', params);
};

const getShopDetail = (params: GetShopDetailParams) => {
  return shopGet<GetShopDetailRes>('/shopDetail', { params });
};

export { addShop, getShopDetail };

apis/modules/shop/type.ts:

export interface AddShopParams {
  shopId: string;
}

export interface AddShopRes {
  success: boolean;
}

export interface GetShopDetailParams {
  shopId: string;
}

export interface GetShopDetailRes {
  shopId: string;
  fans: number;
  logo: string;
}

apis/modules/index.ts:

import * as shopApi from './shop';
import * as userApi from './user';

export { shopApi, userApi };

这里也可以使用:

export * from './shop';
export * from './user';

不过这样有不同的业务里定义了相同名字的接口的风险,两种方式可以根据自己喜欢的方式选择。

apis/index.ts:

export * from './instances';
export * from './modules';

在业务中使用

在业务中我们可以根据如下方式使用:

import { getUserInfo } from '@/apis/modules/user';
const userInfo = await getUserInfo({ userId: '111' });

或者:

import { userApi } from '@/apis';
const userInfo = await userApi.getUserInfo({ userId: '111' });