什么是网络请求封装的最佳实践

前言

众所周知,自从 web 端经历过刀耕火种的年代后,ajax 技术的出现给动态页面的大量普及踢上了临门一脚,前后端真正脱离了模板开始分离,属于 web 端自己的视图层框架也如雨后春笋般出现。在此其中,对于网络请求的封装成为了一个不大不小的、人人都要掌握的入门技能,毕竟对于互联网应用来说,数据的交互是实现各种业务的基础。

近期浏览了不少关于网络请求封装的文章,其中有很多精彩的观点,也不乏引起争议的地方;结合自身长期以来的实践经验,对网络请求的封装又有了新的想法。在此就以不同的需求层次为出发点,循序渐进地讨论,不同需求情况下需要对网络请求做怎样程度的封装。

axios.create 出发

axios想必不用多做介绍,我们直接以axios为例进行网络请求的封装;另外这里对于环境变量的配置与读取、相关构建配置全都基于vite,其他项目换种写法就行。以下人物、情节、需求均为虚构。

极简情况

组长张三:

封装一个网络请求服务,后端已经做了星号的跨域处理,我们的业务也比较头铁只有一个环境就是生产环境,接口不需要鉴权,不需要提示请求结果。

需求分析:

这和没封装也区别不大了。

封装过程:

创建文件/src/services/request.ts

ts 复制代码
import axios from 'axios';

const instance = axios.create({
  baseURL:'https://service.prod/api/',
  timeout: 120000
});

export default instance;

功能完成,开始使用:

ts 复制代码
import request from '@/services/request';

const getUser = async () => {  
  try {  
    const res = await request.get('/user/info?id=12345');  
    console.log(res);  
  } catch (err) {  
    console.error(err);  
  }  
}

节目效果,请勿模仿。不要把域名、账号、密码等重要信息硬编码在源码里。

对请求与响应需要做些什么

组长李四:

我接替张三的工作,你把request服务改改,我们要加上鉴权,请求如果不成功也需要提示出来。对于业务逻辑层的错误,会通过响应体的code字段体现,200为成功,其余值表示失败;对于协议层的错误,会通过http status code体现;其中表示未鉴权的错误将在协议层返回,值为401

需求分析:

重点在于弄清楚什么是业务逻辑层的错误(指http status code200),以及协议层的错误(http协议的错误,http status code不为200)。

封装过程:

编辑文件/src/services/request.ts

ts 复制代码
// ...

// 判断业务逻辑层是否请求失败
const requestFailed = (response: any) => {
  const { code, message } = response['data'];
  if (code !== 200) {
    return [true, message];
  }

  return [false];
};

// 判断是否可以提示异常
const canTip = (request: any) => {
  return request.noErrorTip !== true;
};

// 提示异常
const tip = (msg: string) => {
  // 自行使用相应技术栈的提示组件
  ElMessage.error({
    message: msg,
    duration: 3000
  });
};

instance.interceptors.request.use((request: any) => {
  const hasToken: boolean = /* 自己判断 */;

  // 有token,且没有阻止发送token时,带上token
  if (hasToken && !request.noSendingToken) {
    const token: string = /* 自己判断 */;
    request.headers['Authorization'] = `Bearer ${token}`;
  }

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

instance.interceptors.response.use((response: any) => {
  const [isFailed, msg] = requestFailed(response);

  // 业务逻辑层请求失败
  if (isFailed) {
    // 如果可以提示异常
    if (canTip(response.config)) {
      // 提示异常
      tip(msg);
    }
    return Promise.reject({ code: response.data.code, message: msg });
  }

  return response;
}, (err: any) => {
  // 请求不通
  if (!err.response) {
    return Promise.reject(err);
  }

  // 协议层请求失败
  const code = err.response.status;
  // 如果可以提示异常
  if (canTip(err.response.config)) {
    // 分情况提示异常
    switch (code) {
      case 401:
        tip('登录过期,请重新登录');
        break;
      // ...
      case 500:
        tip('内部服务器错误,请稍后再试');
        break;
      default:
        tip(err.message || '网络不佳,请刷新后重试');
        break;
    }
  }

  // 处理鉴权过期的问题
  if (code === 401) {
    // ...
  }

  return Promise.reject(err);
});

export default instance;

功能完成,使用方式无变化。

区分环境

组长王五:

李四回家去了,没有测试环境还真是不行,你把request服务改改,要加上对开发环境、测试环境、生产环境的支持。

需求分析:

无非就是请求的baseURL根据环境改变而改变,另外在开发模式下,baseURL有可能是任何环境(走代理)以方便调试问题;对于环境变量一般使用.env文件管理,再用应用层面的配置config来引用,应用逻辑层只应该接触config引用的配置,不建议直接使用环境变量。

封装过程:

新建目录/src/services/request,并将/src/services/request.ts移入,重命名为service.ts

编辑文件.env.dev, .env.test, .env.prod

shell 复制代码
# 此处以.env.dev为例,填写的是dev,以此类推
VITE_ENV=dev

VITE_DOMAIN_DEV=https://service.dev/
VITE_DOMAIN_TEST=https://service.test/
VITE_DOMAIN_PROD=https://service.prod/

编辑文件vite.config.ts

ts 复制代码
// ...
proxy: {
  '/dev': {
    target: env.VITE_DOMAIN_DEV,
    changeOrigin: true,
    rewrite: (path: string) => path.replace(/^\/dev/, '')
  },
  '/test': {
    target: env.VITE_DOMAIN_TEST,
    changeOrigin: true,
    rewrite: (path: string) => path.replace(/^\/test/, '')
  },
  '/prod': {
    target: env.VITE_DOMAIN_PROD,
    changeOrigin: true,
    rewrite: (path: string) => path.replace(/^\/prod/, '')
  }
};
// ...

编辑文件/src/config/index.ts

ts 复制代码
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
  },
  prod: {
    domain: import.meta.env.VITE_DOMAIN_PROD
  }
};

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

export default config;

新建文件/src/services/request/host.ts

ts 复制代码
import config from '@/config';

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

export default baseUrl;

编辑文件/src/services/request/service.ts

ts 复制代码
// ...
import baseUrl from './host';
import config from '@/config';

const instance = axios.create({
  baseURL: `${baseUrl}${config.api.prefix}`,
  timeout: config.api.timeout,
  withCredentials: false,
  headers: config.api.commonHeaders
});
// ...

编辑文件package.json

json 复制代码
{
  "scripts": {
    "dev": "vite --mode dev",
    "test": "vite build --mode test",
    "prod": "vite build --mode prod"
  }
}

功能完成,使用方式无变化。但在非开发环境下,各环境都能自动使用各自的域名;在开发环境下,可以通过修改VITE_ENV的值快速切换环境以便调试。

按模块管理api

组长赵六:

王五被优化了,虽然现在request服务基本可用,但还是不太方便,比如对于同样的接口,每次都需要重新组织参数与填写url,最好能和后端的模块对齐,一类api封装一为一个文件,方便复用。

需求分析:

api目录管理接口,每个文件的名称与后端模块对应,此处以用户类接口为例。

封装过程:

新建目录/src/api

新建文件/src/api/user.ts

ts 复制代码
import request from '@/services/request';

const group = '/user';

export const getUserInfo = (params) =>
  request({
    url: `${group}/info`,
    method: 'get'.
    params
  });

功能完成,开始使用:

ts 复制代码
import { getUserInfo } from '@/api/user';

const getUser = async () => {  
  try {  
    const res = await getUserInfo({ id: 12345 });  
    console.log(res);  
  } catch (err) {  
    console.error(err);  
  }  
}

处理请求地址本身包含的参数

组长孙七:

赵六身体不适回家调养,现在request服务几乎可以满足所有场景了,就是有一个场景使用还有问题------请求地址本身包含参数的时候,尤其是包含多个参数时,写起来就比较麻烦,而且有可能每个人的写法都不一样,最好能用一个统一的方式去处理。

需求分析:

处理的是类似/api/user/1/12345这种情况,最好能和data, params一样,直接传一个args对象就能解决问题。

封装过程:

新建目录/src/services/request/interceptors

新建文件/src/services/request/interceptors/url-args.ts

ts 复制代码
const urlArgsHandler = {
  request: {
    onFulfilled: (request) => {
      const { url, args } = request;
      // 检查request中是否有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 { ...request, url: replacedUrl };
      }
      return request;
    }
  }
};

export default urlArgsHandler;

编辑文件/src/services/request/service.ts

ts 复制代码
import urlArgs from './interceptors/url-args';

// ...

instance.interceptors.request.use(urlArgs.request.onFulfilled as any, undefined);

// ...

编辑文件/src/api/user.ts

ts 复制代码
// ...

export const getUserDetail = (args) =>
  request({
    url: `${group}/{agentId}/{uid}`,
    method: 'get',
    args
  });

功能完成,开始使用:

ts 复制代码
import { getUserDetail } from '@/api/user';

const getUser = async () => {  
  try {  
    const res = await getUserDetail({ agentId: 1, uid: 12345 });  
    console.log(res);  
  } catch (err) {  
    console.error(err);  
  }  
}

多实例支持

组长周八:

由于需求调整,孙七不再负责这一块的工作内容,现在我们的另外两个子平台需要整合进这个工程中,对应的接口地址不一样,需要携带的请求头不一样,响应体的数据结构也不一样,所以request服务需要进行一个重构,以满足需求。

需求分析:

显然需要用面向对象的方式来解决,对于每一套接口,都有各自的实例。

封装过程:

新建文件/src/services/request/interceptors/format.ts,将/src/services/request/service.ts中的拦截器相关代码搬运过来

ts 复制代码
// ...

const formatHandler = {
  request: {
    onFulfilled: /* 搬运过来的onRequest */
    onRejected: /* 搬运过来的onRequestError */
  },
  response: {
    onFulfilled: /* 搬运过来的onResponse */
    onRejected: /* 搬运过来的onResponseError */
  }
};

export default formatHandler;

编辑文件/src/services/request/service.ts

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

class RequestService {
  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, interceptor.request.onRejected ?? undefined);
        }
        if (Reflect.has(interceptor, 'response')) {
          this.instance.interceptors.request.use(interceptor.response.onFulfilled, interceptor.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);
  }

  async request(request) {
    const res = await this.instance.request(request);
    if (res) {
      return this.responseHandler(res);
    }
  };
}

export default RequestService;

新建文件/src/services/request/index.ts

ts 复制代码
import RequestService from './service';
import baseUrl from './host';
import config from '@/config';

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

const responseHandler = (response) => {
  const { data } = response;
  return data;
};

export const { request } = new RequestService(options, responseHandler);

编辑文件/src/api/user.ts

ts 复制代码
// 此时已经不是直接导入axios的实例,而是RequestService实例的request方法
import { request } from '@/services/request';

// ...

功能完成,使用方式无变化。只不过当需要新一套接口时,只需要在request服务的入口文件实例化并导出一个实例的request方法即可。当某一套接口需要特殊请求头时,实例化request服务时可以传入任意拦截器,通过自定义request参数控制是否生效。

统一处理catch

组长吴九:

这套封装还有说小不小的问题,比如完全依赖使用者用try/catch包裹调用代码,不然就有可能出现Unhandled promise rejection异常;即便在catch代码块中提前return,也无法终止后续在正常逻辑下才应该执行的代码。周八没有意识到这个问题,现在大家的业务代码都玩儿花了,希望你能尽早修改。

需求分析:

参考await-to-js库的思想。

封装过程:

编辑文件/src/services/request/service.ts

ts 复制代码
// ...

async request(request) {
  try {
    const res = await this.instance.request(request);
    return this.responseHandler(res);
  } catch (err: any) {
    return { err, data: null, response: null };
  }
};

编辑文件/src/services/request/index.ts

ts 复制代码
// ...

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

功能完成,开始使用:

ts 复制代码
import { getUserDetail } from '@/api/user';

const getUser = async () => {
  const { err, data } = await getUserDetail({ agentId: 1, uid: 12345 });
  if (err) {
    return;
  }

  // 正常读取data的代码逻辑
}

类型推导

组长郑十:

已经很长时间没有出问题了,我建议咱们做一次代码优化。这不是使上vue3了吗,但是咱们一直用的是AnyScript,我建议从request服务开始,把静态类型检查用上,各种类型体操都做起来。这样至少在写业务代码的时候,可以享受到类型提示的好处,重构的时候也下得去手。

需求分析:

封装过程:

新建文件/src/services/request/type.ts

ts 复制代码
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上的参数对象
  noErrorTip?: boolean; // 是否不提示报错信息
  noSendingToken?: boolean; // 是否不发送令牌
}

// 发起请求方法的类型
// 泛型设计:支持传入响应数据类型、request body数据类型、query string数据类型、url args数据类型
// 请求参数设计:根据传入的泛型情况,依次从AppRequestConfig类型中剔除data, params, args字段,加上泛型中对相应字段的定义,最后用可选值类型包裹
// 响应数据设计:最内层泛型为传入的响应数据类型
export interface CustomRequest {
  <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>>;
}

编辑文件/src/services/request/service.ts

ts 复制代码
// ...
import type { AxiosResponse } from 'axios';
import type { AppRequestConfig, CustomRequest } from './type';

// ...

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

export default RequestService;

新建目录/src/types/api

新建文件/src/types/api/common.ts

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

interface Pagination extends Limit {
  total: number;
}

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

export type SortAndLimit = Sort & Limit;

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

新建文件/src/types/api/user.ts

ts 复制代码
export interface User {
  id: number;
  name: string;
  mobile?: string;
}

编辑文件/src/api/user.ts

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

// ...

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

功能完成,开始使用:

ts 复制代码
import { getUserList } from '@/api/user';

const getUsers = async () => {
  const { err, data } = await getUserList({
    index: 1,
    size: 20
  });
  if (err) {
    return;
  }

  // 正常读取data的代码逻辑
}

融入下载逻辑

老板:

业务新增了下载功能,需要加上。

需求分析:

下载接口如果和常规接口是同一套的话,一般鉴权方式、公共请求头什么的也都一样,无非是返回的响应类型不同;因此可以把下载和接口请求都视为某种功能类,他们有各自的处理信息方法,前者返回文件流,后者返回解析并格式化后的JSON数据;同时,他们也基于某一个基类,去鉴权、去设定请求头、去处理响应等等。另外,上传文件就不考虑了,一般情况把文件上传到第三方存储时是可以不通过自家后端中转的,也就是前端直接上传,因此不值得封到一起。

封装过程:

新建目录/src/services/request/core,并将/src/services/request/service.ts移入,重命名为base-service.ts

编辑文件/src/services/request/core/base-service.ts

ts 复制代码
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, interceptor.request.onRejected ?? undefined);
        }
        if (Reflect.has(interceptor, 'response')) {
          this.instance.interceptors.request.use(interceptor.response.onFulfilled, interceptor.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;

新建文件/src/services/request/core/http-service.ts

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

class HttpService extends BaseService {
  request: CustomRequest = async <T>(request?: Partial<AppRequestConfig>) => {
    try {
      const res: AxiosResponse<T, AppRequestConfig> = await this.instance.request<T>(request);
      return this.responseHandler(res);
    } catch (err: any) {
      return { err, data: null, response: null };
    }
  };
}

export default HttpService;

新建文件/src/services/request/core/download-service.ts

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

class DownloadService extends BaseService {
  download = async (request?: Partial<AppRequestConfig>) => {
    try {
      const res: AxiosResponse<Blob, AppRequestConfig> = await this.instance.request<Blob>(request);
      return this.responseHandler(res, request.extraInfo ?? {} as any);
    } catch (err: any) {
      return { err, data: null, response: null };
    }
  };
}

export default DownloadService;

编辑文件/src/services/request/index.ts

ts 复制代码
import HttpService from './core/http-service';
import DownloadService from './core/download-service';
import baseUrl from './host';
import config from '@/config';

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

const httpResponseHandler = (response) => {
  const { data } = response;
  return { err: null, data, response };
};

const downloadOptions = {
  ...httpOptions,
  responseType: 'blob'
};

const downloadResponseHandler = (response, extraInfo: any) => {
  const { data } = response;

  let fileName = '';
  if (extraInfo && extraInfo.fileName) {
    fileName = extraInfo.fileName;
  } else {
    fileName = response.headers['content-disposition']
      ? decodeURI(
        response.headers['content-disposition'].split(';')[1].split('=')[1]
      )
      : '';
  }

  try {
    const type = response.headers['content-type'];
    let blob: Blob;
    if (type) {
      blob = new Blob([data], {
        type
      });
    } else {
      blob = new Blob([data]);
    }

    if ('msSaveBlob' in window.navigator) {
      (window.navigator as any).msSaveBlob(blob);
    } else {
      const url = window.URL.createObjectURL(blob);
      const link = document.createElement('a');
      link.href = url;
      link.setAttribute('download', fileName);
      link.style.display = 'none';
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
      // 释放内存
      window.URL.revokeObjectURL(link.href);
    }
  } catch (err) {
    console.error(err);
  }
};

export const { http } = new HttpService(httpOptions, httpResponseHandler);
export const { download } = new DownloadService(downloadOptions, downloadResponseHandler);

编辑文件/src/api/user.ts

ts 复制代码
// 注意此时的RequestService实例导出的是http方法
import { http } from '@/services/request';

// ...

功能完成。

后记

对网络请求的封装,做到这个程度应该已经是极致(毕竟全组只剩萧十一了);再往后应该可以往swagger自动生成api模型方向考虑,本文不做讨论。

相关推荐
小二·26 分钟前
layui树形组件点击树节点后高亮的解决方案
前端·javascript·layui
Minions_Fatman28 分钟前
【layui】table的switch、edit修改
前端·javascript·layui
槑带可乐34 分钟前
[可乐的随手记 - 6] 并发控制上传大量的文件 (worker + yield )
前端·javascript
小孙姐44 分钟前
4——单页面应用程序,vue-cli脚手架
前端·javascript·vue.js
生椰拿铁You1 小时前
15 —— Webpack中的优化——前端项目使用CDN技术
前端·webpack
生椰拿铁You1 小时前
13 —— 开发环境调错-source map
前端
知野小兔1 小时前
【Angular】async详解
前端·javascript·angular.js
来啦来啦~1 小时前
vue项目实现动效交互---lottie动画库
前端·vue.js·交互
没了对象省了流量ii1 小时前
11.9K Star!强大的 Web 爬虫工具 FireCrawl:为 AI 训练与数据提取提供全面支持
前端·人工智能·爬虫
我的div丢了肿么办1 小时前
vue项目中如何加载markdown作为组件
前端·javascript·vue.js