关于axios封装的实践记录

近期正在做基于vue3+vite+TypeScript的工程模板,方方面面都要做充分的考量,与最佳实践的探索。正巧浏览到《一篇拒绝低级封装axios的文章》这篇文章(以下简称文章),其中有很多精彩的观点,结合自身长期以来的实践经验,对http请求的封装又有了新的想法,在此记录一番。

文章中所封装的axios具备的特性

  1. 不支持多实例创建,而是通过传入不同配置常量的方式调用axios的方法,来适应多种服务端接口;
  2. 通过往requestConfig里塞自定义属性,实现自定义拦截器是否执行;
  3. 继承AxiosRequestConfig,自定义请求类支持对params参数的类型推导;
  4. 使用try/catch包裹请求主体,格式化正常与异常的响应;
  5. 对于请求路径中存在参数的接口,支持对象形式的路径参数替换;
  6. 按照请求方法分文件存放请求函数,每个文件导出一个对象,对象中直接使用请求路径作为key,避免重复记录接口;
  7. 通过makeRequest函数,实现对包含路径参数在内的请求参数的类型推导,支持返回结果的类型推导;
  8. 由于makeRequest返回的是函数,其参数对应的配置信息会与真正触发网络请求时传入的参数合并。

自己的封装目标

根据实际情况,参考以上列出的特性,逐一探讨什么样的封装形式是实际需要的:

  1. 对于多实例,由于有工程正好需要调用不同来源的接口,历史原因也导致接口返回的数据结构不尽相同,因此需要支持多实例;
  2. 由于存在不同来源的接口,所需要的拦截器可能不同,因此实例化的过程中需要支持自定义拦截器;
  3. 类型推导直接吸纳进来;
  4. 实际编写业务代码的过程中,很容易忘记写catch,出现异常时会打断程序执行;即便写了catch,由于异步原因也无法在其中提前返回,停止执行后面的正常逻辑下才应该执行的代码;因此需要try/catch的封装;
  5. 路径参数替换直接采用;
  6. 由于后端未采用RESTful的风格来编写接口,且不建议用请求方法来划分文件,这里采取按照模块划分的方式(与后端的领域模型对应);为了避免相同地址但请求方法不同的接口无法同时放在这个文件,不使用请求路径作为请求函数的名称;
  7. 类型推导不再做讨论;对于存放请求函数的文件,为了tree shaking需要,不再export default一个对象,而是分别导出;
  8. 返回函数并合并配置的做法过于复杂,这里直接简化,使得这里可以更自由地执行一些自定义逻辑,对于入参的定义也更加灵活。

额外需要确定的问题

在真正动手封装之前,还有一些需要确定的问题:

  1. 对http请求的封装放哪里?

这个一直以来都没有约定俗成的标准,似乎大家都很清楚,然而这类问题从刚入行开始,对于我这种凡事都想要有明确方法论的人来说是个不小的困扰;后来接触了AngularJS,从中学到了service服务的概念,类似http请求之类的逻辑,都可以视为服务。serviceutil还不一样,util是一系列函数的集合,类似于一个工具箱,一般情况下搬到哪里都能直接使用;service用来存放一系列复杂的非孤立逻辑,一个服务可能包含有很多方法,用来解决某一类的问题。

  1. 对于接口baseURL之类的信息放哪里?

这不是一个简单的问题。首先最直接的方法就是放在创造请求实例的地方,但这必然导致开发和发版的过程中接口baseURL被反复修改,即便不考虑多环境部署的情况,总得区分开发和线上两种情况,毕竟开发过程中一般是在前端解决跨域问题。好在环境变量这一概念在前端早早普及了,个人习惯是把账号、域名之类的信息放在环境变量中,再在源码目录中建立config目录,里面的配置文件除了定义业务相关的配置项以外,还引用了环境变量中的配置;这种做法约束环境变量配置只在config等少量文件中被使用,业务代码中如果要读取任何配置,一定是从config中读取。

  1. 下载、上传文件算不算http服务的业务范围?

应该算。无非是创建网络请求实例时的配置不同、经过的拦截器不同。不同点在于会多一些其他的操作,比如生成blob,形成下载链接并下载文件。封装时可以用上面向对象的思想:定义一个基础类,在构造函数实例化出网络请求实例,定义http类继承基础类,包含http方法用于发起请求,定义上传/下载类继承基础类,包含相应的方法处理上传/下载。如此,只需要在http服务的出口文件实例化相应的类,并导出其方法即可。

预计达到的效果

  1. 接口函数的存放

在源码目录下建立api目录,里面以模块(对应后端的领域模型)划分文件,这里以user.ts为例,文件内的写法:

JAVASCRIPT 复制代码
import { http } from '@/services/http';
import type { User } from '@/types/api/user';
import type { SortAndLimit, ListResult } from '@/types/api/common';

export const getUserInfo = () =>
  http<User>({
    url: '/user/info',
    method: 'get'
  });

export const getUserList = (params: SortAndLimit) =>
  http<ListResult<User>, undefined, SortAndLimit>({
    url: '/user/list',
    method: 'get',
    params
  });

此种封装方式可自行决定请求函数的参数形式,如用户信息接口本身并不需要参数,那么其请求函数参数就可以置空,用户列表接口只需要params参数,其请求函数参数就只需要传params;另外如果有必要,箭头函数内可以执行任意逻辑,提供了足够的自由空间

  1. 类型的定义

在源码目录下建立types/api目录,里面的文件与api目录下的文件一一对应,同样以user.ts为例,文件内的写法:

JAVASCRIPT 复制代码
export interface User {
  id: number;
  nickName: string;
  avatar: string;
  mobile: string;
  // ...
}

有些接口需要传data类型的请求参数(body json),也可以在此定义相应接口的请求dto类型,响应同理

另外,还需要在此目录下建立common.ts文件,用于存放一些通用类型,如列表类型,内容如下:

JAVASCRIPT 复制代码
export interface Limit {
  index: number;
  size: number;
}

interface Pagination extends Limit {
  total: number;
}

export interface Sort {
  direction?: number;
  field?: string | null;
}

export type SortAndLimit = Sort & Limit;

export interface ListResult<T> {
  items?: T[] | null;
  pagination?: Pagination;
  sort?: Sort;
}

实现过程

下面开始具体实现:

  1. 环境变量编写

.env.local

ini 复制代码
VITE_ENV=dev

VITE_DOMAIN_DEV=https://dev.xxx.com/
VITE_DOMAIN_TEST=https://test.xxx.com/
VITE_DOMAIN_PRE=https://pre.xxx.com/
VITE_DOMAIN_PROD=https://api.xxx.com/
  1. 配置文件编写

src/config/index.ts

JAVASCRIPT 复制代码
const env = import.meta.env.VITE_ENV as keyof typeof configMap;

const baseConfig = {
  api: {
    prefix: 'api', // 接口前缀
    timeout: 120000, // 默认120秒超时
    commonHeaders: {
      version: '1.0'
    }, // 公共请求头
  },
  env,
};

const configMap = {
  dev: {
    domain: import.meta.env.VITE_DOMAIN_DEV
  },
  test: {
    domain: import.meta.env.VITE_DOMAIN_TEST
  },
  pre: {
    domain: import.meta.env.VITE_DOMAIN_PRE
  },
  prod: {
    domain: import.meta.env.VITE_DOMAIN_PROD
  }
};


const config = { ...baseConfig, ...configMap[env] };

export default config;
  1. 动态baseURL

在源码目录下新建services目录,里面新建http文件夹,里面新建host.ts文件,内容如下:

src/services/http/host.ts

JAVASCRIPT 复制代码
import config from '@/config/index';

let baseUrl = '/';
if (import.meta.env.DEV) {
  // 开发模式下,始终走代理
  baseUrl = `/${config.env}/`;
} else {
  // 非开发模式直接使用域名
  baseUrl = config.domain;
}

export default baseUrl;

不同的环境变量文件可以设定不同的VITE_ENV的值,将自动使用对应环境下的域名

  1. 类型约束

src/services/http/type.ts

JAVASCRIPT 复制代码
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';

// 定义返回结果的数据类型
export interface AppResponse<T = any> {
  data: T | null;
  err: AxiosError | null;
  response: AxiosResponse<T> | null;
}

// 重新定义AppRequestConfig,在AxiosRequestConfig基础上再加args等数据
export interface AppRequestConfig extends AxiosRequestConfig {
  args?: Record<string, any>; // endpoint上的参数对象
}

// http服务的http方法的类型
export interface Http {
  <Payload = any>(requestConfig?: Partial<AppRequestConfig>): Promise<AppResponse<Payload>>;

  <Payload, Data>(requestConfig: Partial<Omit<AppRequestConfig, 'data'>> & { data: Data }): Promise<AppResponse<Payload>>;

  <Payload, Data, Params>(
    requestConfig: Partial<Omit<AppRequestConfig, 'data' | 'params'>> &
      (Data extends undefined ? { data?: undefined } : { data: Data }) & {
        params: Params;
      }
  ): Promise<AppResponse<Payload>>;

  <Payload, Data, Params, Args>(
    requestConfig: Partial<Omit<AppRequestConfig, 'data' | 'params' | 'args'>> &
      (Data extends undefined ? { data?: undefined } : { data: Data }) &
      (Params extends undefined ? { params?: undefined } : { params: Params }) & {
        args: Args;
      }
  ): Promise<AppResponse<Payload>>;
}
  1. 拦截器

http目录下建立interceptors文件夹,里面维护各种拦截器;路径参数替换拦截器的内容如下:

src/services/http/interceptors/url-args.ts

JAVASCRIPT 复制代码
import type { AxiosRequestConfig } from 'axios';
import type { AppRequestConfig } from '../type';

const urlArgsHandler = {
  request: {
    onFulfilled: (config: AxiosRequestConfig) => {
      const { url, args } = config as AppRequestConfig;
      // 检查config中是否有args属性,没有则跳过以下代码逻辑
      if (args) {
        const lostParams: string[] = [];
        // 使用String.prototype.replace和正则表达式进行匹配替换
        const replacedUrl = (url as string).replace(/\{([^}]+)\}/g, (res, arg: string) => {
          if (!args[arg]) {
            lostParams.push(arg);
          }
          return args[arg] as string;
        });
        // 如果url存在未替换的路径参数,则会直接报错
        if (lostParams.length) {
          return Promise.reject(new Error('在args中找不到对应的路径参数'));
        }
        return { ...config, url: replacedUrl };
      }
      return config;
    }
  }
};

export default urlArgsHandler;

关于鉴权登录和错误码映射等涉及业务的逻辑,统统封装在拦截器format.ts中,此处略

  1. 基础服务类

http目录下建立core文件夹,里面新建base-service.ts文件,内容如下:

src/services/http/core/base-service.ts

JAVASCRIPT 复制代码
import axios from 'axios';
import urlArgs from '../interceptors/url-args';
import format from '../interceptors/format';

class BaseService {
  protected instance;
  protected responseHandler;

  constructor(options: Object, responseHandler: Function, interceptors?: any[]) {
    this.instance = axios.create(options);
    this.responseHandler = responseHandler;

    // 实现路径参数替换
    this.instance.interceptors.request.use(urlArgs.request.onFulfilled as any, undefined);

    // 自定义拦截器
    if (interceptors?.length) {
      interceptors.forEach((interceptor) => {
        if (Reflect.has(interceptor, 'request')) {
          this.instance.interceptors.request.use(interceptor.request.onFulfilled, format.request.onRejected ?? undefined);
        }
        if (Reflect.has(interceptor, 'response')) {
          this.instance.interceptors.request.use(interceptor.response.onFulfilled, format.response.onRejected ?? undefined);
        }
      });
    }

    // 请求拦截
    this.instance.interceptors.request.use(format.request.onFulfilled, format.request.onRejected);

    // 响应拦截
    this.instance.interceptors.response.use(format.response.onFulfilled, format.response.onRejected);
  }
}

export default BaseService;
  1. http服务类

src/services/http/core/http-service.ts

JAVASCRIPT 复制代码
import type { AxiosResponse } from 'axios';
import type { AppRequestConfig, Http } from '../type';
import BaseService from './base-service';

class HttpService extends BaseService {
  http: Http = async <T>(requestConfig?: Partial<AppRequestConfig>) => {
    const mergedConfig: AppRequestConfig = { ...requestConfig };

    try {
      const response: AxiosResponse<T, AppRequestConfig> = await this.instance.request<T>(mergedConfig);
      return this.responseHandler(response);
    } catch (err: any) {
      return { err, data: null, response: null };
    }
  };
}

export default HttpService;
  1. 入口文件

src/services/http/index.ts

JAVASCRIPT 复制代码
import type { AxiosResponse } from 'axios';
import type { AppRequestConfig } from './type';
import HttpService from './core/http-service';
import baseUrl from './host';
import config from '@/config';

const defaultOptions = {
  baseURL: `${baseUrl}${config.api.prefix}`,
  timeout: config.api.timeout,
  withCredentials: false,
  headers: config.api.commonHeaders
};

const responseHandler = (response: AxiosResponse<any, AppRequestConfig>) => {
  const { data } = response;
  return { err: null, data, response };
};

export const { http } = new HttpService(defaultOptions, responseHandler);

此处只导出一个http方法,根据实际情况,若有多个服务端,只需依样实例化服务并导出即可

总结

以上即为本次对于axios封装的全记录,受限于篇幅关于文件上传/下载的部分没有放进来,但封装方式已经明确,依样填充即可。欢迎交流。

相关推荐
刷帅耍帅2 分钟前
设计模式-命令模式
设计模式·命令模式
码龄3年 审核中13 分钟前
设计模式、系统设计 record part03
设计模式
刷帅耍帅18 分钟前
设计模式-外观模式
设计模式·外观模式
刷帅耍帅1 小时前
设计模式-迭代器模式
设计模式·迭代器模式
liu_chunhai1 小时前
设计模式(3)builder
java·开发语言·设计模式
刷帅耍帅1 小时前
设计模式-策略模式
设计模式·策略模式
刷帅耍帅6 小时前
设计模式-享元模式
设计模式·享元模式
刷帅耍帅6 小时前
设计模式-模版方法模式
设计模式
刷帅耍帅8 小时前
设计模式-桥接模式
设计模式·桥接模式
MinBadGuy9 小时前
【GeekBand】C++设计模式笔记5_Observer_观察者模式
c++·设计模式