还不会用 TS 封装 Axios?我教你啊

13,550 阅读12分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第19天,点击查看活动详情

前言

Axios 的二次封装是一项基础工作,主要目的就是将一些常用的功能进行封装,简化后续网络请求的发送。

JS 版本的封装大家都已经非常熟悉了,可以信手拈来。但是使用 TypeScript 对 Axios 进行封装,稍微就复杂了些。主要是由于 TS 引入了类型系统,带来了一些类型的束缚。对于 TS 不太熟悉的小伙伴就容易绕晕。

因此本文适合的阅读对象:熟悉 axios 封装,但在 TypeScript 中不知该如何下手

明确我们的封装目标:能用上TypeScript 带来的好处,类型检查(安全性)语法提示(便捷性)

本文将从泛型入手,然后了解 Axios 中的部分类型,延续 JS 版本的极简风,教你封装出一个可用的清爽版 Axios。

初始化项目

使用 create-vue 脚手架初始化项目:

npm create vue ts-axios
// 或者
pnpm create vue ts-axios

选择添加 TypeScript :

image-20221015234134695

之后进入项目目录并安装依赖:

npm install
// 或者
pnpm install

安装其他依赖

示例会用到一些组件,所以安装下组件库:

pnpm add axios
pnpm add ant-design-vue

接口 mock

使用插件 mock 一个登录接口和一个用户信息接口,方便测试请求。

上篇文章《一个登录案例包学会 Pinia》介绍了在 vite 中 mock 数据的简单用法,本文不再赘述。

安装插件并进行配置:

pnpm add vite-plugin-mock mockjs -D
// vite.config.tsimport vue from '@vitejs/plugin-vue'
import { viteMockServe } from 'vite-plugin-mock'export default defineConfig({
  plugins: [
    vue(),
    viteMockServe()
  ],
​
})

建立 mock 文件,一个登录接口返回 token,一个用户信息接口返回用户名等信息,还有一个模拟出现业务错误的接口。

// mock/user.ts
​
​
export default [
  // 用户登录
  {
    url: "/api/user/login",
    method: "post",
    response: (res) => {
      return {
        code: 0,
        message: 'success',
        data: {
          token: "Token"
        }
      }
    }
  },
  // 获取用户信息
  {
    url: "/api/user/info",
    method: "get",
    response: (res) => {
      return {
        code: 0,
        message: 'success',
        data: {
          id: "2467751560226270",
          username: "昆吾kw",
          avatar: "https://p3-passport.byteimg.com/img/user-avatar/3745b7eb198f2357155cd88eb7930f35~180x180.awebp",
          description: "前端开发",
        }
      }
    }
  },
​
  // 一个失败的请求
  {
    url: "/api/error",
    method: "get",
    response: (res) => {
      return {
        code: 1,
        message: '密码错误',
        data: null
      }
    }
  }
]

泛型

泛型的英文是Generic,意思是“通用的”,“一般的“。

在程序设计,泛型中可以理解为 “泛指的类型”,不确定的类型,或者通用的类型

理解泛型

泛型的概念很像函数中的参数。为了更方便的理解什么是泛型,可以用函数参数做一个类比。

函数在定义时,我们并不知道参数的值到底是多少。只有当函数调用时,才能确定。

function print(a) {
    console.log(a)
}
​
print('hello')
print('world')

泛型的含义也是如此。只不过,参数不确定的是值,泛型不确定的是类型

泛型的使用场景有很多种,比如泛型函数,泛型类,泛型接口。我们只看泛型函数。

如何定义泛型函数呢?如下:

function add<T>(a:T, b:T): T {
    return a + b
}

这段代码定义了一个 add 函数,在函数名后来使用了一对尖括号来声明泛型使用 T(Type 的缩写)来表示一个泛型。之后参数的类型,就不再具体指定是 string 还是 number ,亦或是其他具体的类型,而是用 T 表示。函数的返回值类型同样使用 T 表示。

这样就声明了一个泛型函数。

接着就是泛型函数的调用:

add<number>(1, 2)
​
add<string>('a', 'b')

和普通函数的调用一样,只不过在函数名后面再次使用一对尖括号,来传递了具体的类型。

通过和参数的类比,详细大家能体会到泛型的含义了。所谓泛型,就是在定义时不确定,而在使用时确定的一种类型。泛型的好处就是提高了代码的复用性,减少了代码的冗余

有了对泛型的初步认识后,下面就可以去进入正题,去看看 TypeScript 如何封装 Axios 了。

本文的思路

我们不再按照创建实例、配置参数、设置拦截器、封装公共请求方法、封装 API 的顺序去实现封装代码。而是打破常规,从类型入手

Axios 中的重要类型

查看第三方库的类型声明

Axios 提供了完备的类型声明。声明文件可以直接去看 node_modules/axios/index.d.ts 这个文件:

image-20221015222400669

也可以在编辑器中,按 ctrl,同时鼠标点击 axios 模块跳转过去:

image-20221015222331509

image-20221015222440898

要封装好 axios,需要先明白这几个类型是干啥用的。

从一个请求方法入手,看常用的类型

TypeScript 中 class 不仅是类,还可以是类类型。作为前者,它是一个值。作为后者,它是一个类型

下面的 Axios 就是一个类类型,它声明了一些成员的类型。我们看其中的核心请求方法,requestgetpost

export class Axios {
  // ......
  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>;
    
  post<T = any, R = AxiosResponse<T>, D = any>(url: string, data?: D, config?: AxiosRequestConfig<D>): Promise<R>;
  
  // ......
}

熟悉 axios 的朋友都知道,axios.getaxios.post 这些方法其实是对 axios.request 的一层封装。所以我们就看 request 方法的声明。

request 方法有三个泛型,TRD,接收一个 AxiosRequestConfig 类型的参数作为配置对象,返回值的类型是一个接收泛型R 的 Promise 类型

request<T = any, R = AxiosResponse<T>, D = any>(config: AxiosRequestConfig<D>): Promise<R>;

这三个泛型,T 的含义是什么?要去看 RR 的含义是什么?要去看它的默认类型 AxiosResponse

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

看到这个结构,我们知道了,原来 AxiosResponse 就是在设置响应拦截器中用到的那个 response 对象的类型

同时也就知道了泛型 T 就是服务器返回的数据的类型。因为服务器究竟返回什么类型,代码是不知道的,所以它的默认类型是 any。

回到头来再来看 request 方法的定义,现在就能明白了,它接收的第一个泛型 T 就是将来服务器返回数据的类型R 就是这个数据经过 axios 包装一层得到的 response 对象的类型,而 request 方法的返回值是一个 Promise,其值就是成功态的 R,也就是 response对象

第三个泛型 D,这里就不再说了。你可以用同样的方法,找出它又是谁的类型,然后发在评论区。

AxiosRequestConfig 类型

先看下它的类型声明:

export interface AxiosRequestConfig<D = any> {
  url?: string;
  method?: Method | string;
  baseURL?: string;
  headers?: RawAxiosRequestHeaders;
  // .....
}

相信大家能看出来,它其实就是 axios 的配置对象的类型。在设置请求拦截器和封装公共请求方法时会用到。

AxiosInstance

该类型为 axios.create 方法创建出的实例的类型。后面直接用到。

AxiosError

该类型为请求发送过程中出现错误产生的错误对象的类型:

export class AxiosError<T = unknown, D = any> extends Error {
  constructor(
      message?: string,
      code?: string,
      config?: AxiosRequestConfig<D>,
      request?: any,
      response?: AxiosResponse<T, D>
  );
​
  config?: AxiosRequestConfig<D>;
  code?: string;
  request?: any;
  response?: AxiosResponse<T, D>;
  isAxiosError: boolean;
  status?: number;
  toJSON: () => object;
  cause?: Error;
  // ......
}

我们会用到其中的 response 属性,它表示响应对象,需要根据它的 HTTP 状态码做一些处理。

知道了上面这几个类型,接下来就可以着手封装 Axios 了。

开始封装

先提前打个预防针,因为封装的很简单,没有用到类,没有过多的处理业务逻辑。估计看完后你会感觉索然无味。

毕竟我们最初的目的就是用上 TypeScript 的类型能力:类型检查代码提示

而且我相信,太复杂的封装,可能会阻碍对类型系统的学习,反而得不偿失。

新建一个 src/utils/request.ts 文件,在这个文件中对 Axios 进行封装。

Result 类型

从服务器返回的数据的类型通常长这样子:

{
   code: 0,
   message: 'ok',
   data: {} 
}

定义一个接口 Result 来表示它,其中 data 的类型不确定,所以就要用到泛型了:

/* 服务器返回数据的的类型,根据接口文档确定 */
export interface Result<T=any> {
  code: number,
  message: string,
  data: T
}

这是一个泛型接口。和泛型函数差不多,在定义时声明一个泛型,用这个泛型去规范成员的类型。将来使用该接口的时候,再去确定泛型的具体类型。

接下来就可以写代码了。

创建实例

这个大家都非常熟悉了,不再赘述。

import axios from 'axios'
import type { AxiosInstance } from 'axios'const service: AxiosInstance = axios.create({
  baseURL: '/api',
  timeout: 30000
})

请求拦截器

这个大家也很熟悉,主要在这里处理请求发送前的一些工作,比如给 HTTP Header 添加 token ,开启 Loading 效果,设置取消请求等。

import type { AxiosError, AxiosRequestConfig } from 'axios'
import { message as Message } from 'ant-design-vue'/* 请求拦截器 */
service.interceptors.request.use((config: AxiosRequestConfig) => {
  //  伪代码
  // if (token) {
  //   config.headers.Authorization = `Bearer ${token}`;
  // }
  return config
}, (error: AxiosError) => {
  Message.error(error.message);
  return Promise.reject(error)
})

响应拦截器

响应拦截器大家也很熟悉了。简单说一下做了哪些事情:

  • 根据自定义错误码判断请求是否成功,然后只将组件会用到的数据,也就是上面的 Result 中的 data 返回
  • 如果错误码判断请求失败,此时为业务错误,比如用户名不存在等,在这里进行提示
  • 如果网络错误,则进入第二个回调函数中,根据不同的状态码设置不同的提示消息进行提示
import type { AxiosError, AxiosResponse } from 'axios'
import { message as Message } from 'ant-design-vue'/* 响应拦截器 */
service.interceptors.response.use((response: AxiosResponse) => {
  const { code, message, data } = response.data
​
  // 根据自定义错误码判断请求是否成功
  if (code === 0) {
    // 将组件用的数据返回
    return data
  } else {
    // 处理业务错误。
    Message.error(message)
    return Promise.reject(new Error(message))
  }
}, (error: AxiosError) => {
  // 处理 HTTP 网络错误
  let message = ''
  // HTTP 状态码
  const status = error.response?.status
  switch (status) {
    case 401:
      message = 'token 失效,请重新登录'
      // 这里可以触发退出的 action
      break;
    case 403:
      message = '拒绝访问'
      break;
    case 404:
      message = '请求地址错误'
      break;
    case 500:
      message = '服务器故障'
      break;
    default:
      message = '网络连接故障'
  }
  
  Message.error(message) 
  return Promise.reject(error)
})

公共请求方法

这里是本文的重点,也是 TS 封装 Axios 的重点。使用 TS 无非要获得良好的代码提示,我们在调用接口时编辑器能提示出该接口的返回值有哪些。

/* 导出封装的请求方法 */
export const http = {
  get<T=any>(url: string, config?: AxiosRequestConfig) : Promise<T> {
    return service.get(url, config)
  },
​
  post<T=any>(url: string, data?: object, config?: AxiosRequestConfig) :Promise<T> {
    return service.post(url, data, config)
  },
​
  put<T=any>(url: string, data?: object, config?: AxiosRequestConfig) :Promise<T> {
    return service.put(url, data, config)
  },
​
  delete<T=any>(url: string, config?: AxiosRequestConfig) : Promise<T> {
    return service.delete(url, config)
  }
}

我们以 get 方法为例,看下为什么这么封装?

get<T>(url: string, config?: AxiosRequestConfig) : Promise<T> {
    return service.get(url, config)
}

上文说过,axios 的 requet,get 这些方法,返回的都是一个 AxiosResponse 类型的数据。

默认的响应拦截器返回的是完整的 response 对象,类型为 AxiosResponse

经过我们的修改,现在响应拦截器只返回 response.data.data,对应的类型就是 AxiosResponse<Result> 中的 T。所以这里的 service.get 方法,其实际返回类型应为 T,但是编辑器并不知道,它仍然认为它返回的是:

image-20221016134207024

所以我们要手动帮助编辑器“修正”类型提示,也就是不依靠 TS 的类型推导,而是主动设置返回类型为 Promise

get<T>(url: string, config?: AxiosRequestConfig) : Promise<T> {
    return service.get(url, config)
}

这样编辑器就准确识别类型了:

image-20221016134438155

这里的泛型 T 表示的服务器返回数据的类型,具体返回什么类型,要到接口调用时才知道。所以下面来到 API 层,去封装请求接口。

为什么要封装公共请求方法

之所以封装这一层公共请求方法,主要目的就是为了帮编译器正确识别类型。方法就是手动设置返回类型。如果直接使用 axios 实例的请求方法,就需要在每次调用时都指定一遍,当接口有几十上百个时,多少会在增加一些工作量。所以就在这里多加一层,提前将类型制定好。

API 层

准备两个文件,一个写类型,一个写接口。

image-20221016135035587

api/user/types.ts 文件中,写接口需要的参数的类型,和接口返回数据的类型。这里的类型,要根据接口文档而定。

/* 登录接口参数类型 */
export interface LoginData {
  username: string,
  password: string,
}
​
/* 登录接口返回值类型 */
export interface LoginRes {
  token: string
}
​
/* 用户信息接口返回值类型 */
export interface UserInfoRes {
  id: string,
  username: string,
  avatar: string,
  description: string,
}

api/user/index.ts 文件中,封装组件用到的接口。

import request, { http } from '@/utils/request'import type { LoginData, LoginRes, UserInfoRes} from './types'/**
 * 登录
 */
export function login(data: LoginData) {
  return http.post<LoginRes>('/user/login', data);
}
​
/**
 * 获取登录用户信息
 */
export function getUserInfo() {
  return http.get<UserInfoRes>('/user/info')
}

以 login 方法为例进行说明。它接收 LoginData 类型的参数作为请求参数。在调用接口时,通过泛型指定了该接口的返回值类型:

image-20221016135528697

测试

Axios 代码提示测试

App.vue 组件中有一个登录表单,在这里测试登录接口是否正常。

方法login 可以推导出当前返回值的类型:

image-20221016002008786

返回值 res 的类型也能推导出:

image-20221016002133845

也可以正确使用类型提示:

image-20221016002233899

然后再测试下调用请求用户信息的接口:

image-20221016002441196

如我们所愿,现在 Axios 经过 TypeScript 的封装,可以给出友好的类型提示,这对于开发体验和效率都有很好的提升。

响应拦截器提示测试

测试业务处理错误的请求,假设此次请求用户密码输入错误:

image-20221016132121313

关闭开发服务,测试无网络连接下的请求发送:

image-20221016132156024

小结

本文示例代码已上传到仓库

开始只是想说说 TypeScript 封装 Axios 的一个简单实现版本。但没想到最后说了不老少内容,包括:

  • TypeScript 泛型的概念
  • 第三方库如何看类型声明
  • 如何利用类型封装 Axios

其中封装 Axios 的过程个人感觉有点啰嗦了,主要是理清类型的作用,不太容易用文字说明,希望大家能理解。

如果有帮助,还请一键三连,感谢支持~