Ajax请求中断&避免发送重复请求

264 阅读7分钟

在与后端交互中,前端项目通常需要基于 fetch、axios、umi.request 等封装一个请求函数,这里以axios为例,力求封装一个功能相对完善、持续迭代的ajax拦截器。

本文讨论的重点,是中断请求及中断后可以继续发起请求。

一、请求拦截器

其实不管是fetch还是axios或者是其他请求库,拦截器组装逻辑大同小异,拦截器通常分为四部分:默认参数、请求拦截、响应拦截、异常处理。

简单描述下这四部分的实现:

1. 默认参数

这个比较常规,一般是以下配置,或参考默认配置

const initConfig = {
      baseURL: baseURL,
      timeout: 50000,
      retry: 3, // 设置全局重试请求次数(最多重试几次请求)
      withCredentials: true, 
}

2. 请求拦截

请求拦截主要做一些项目需要的比如页面loadingtokenblob等处理。参考如下:

// form 请求、文件流blob处理、不同平台的token拼接 ...
if (
  config.formData ||
  config.headers["Content-Type"] === "application/x-www-form-urlencoded"
) {
  config.data = qs.stringify(config.data);
}

// 请求页面显示 loading
if (config.loading) {
  Toast.loading({
    message: "加载中...",
    duration: 0, // 持续展示 toast
    forbidClick: true
  });
}

3. 响应拦截

响应拦截,可以基于前后端协定的数据结构或者一些通用处理,做一层响应包装

// 手动清除 Toast
Toast.clear();
// 前置处理:resolve => res.data
if (isFunction(config.resolve) || isBoolean(config.resolve)) {
  if (config.resolve === true) {
    // 包装处理函数
    return resolveSuccess(data);
  }
  // 自定义处理
  return config.resolve(data);
}

4. 异常处理

  1. 请求异常

    请求异常通常做的一般是取消请求拦截中的后遗症,比如取消上面的loading、异常抛出

    // 手动清除 laoding
    Toast.clear();
    Promise.reject(error);
    
  2. 响应异常

    a. 这个根据情况,在响应拦截器中可能也需要做处理,比如

    // 响应异常处理--在响应拦截器中使用
    export const resErrHandler = (res = { data: {}}) => {
      const { data = {}} = res
    
      // 兼容后端多团队及不规范返回错误码字段问题:code,status,rescode ...
      const { code, rescode, msg: __msg, result } = data || {}
      const errItem = errorCodes.find(err => err.code === rescode || err.code === code || err.code === data.status)
    
      if (errItem || __msg && result === 'fail') {
        console.error(data);
        errorMsg(__msg || errItem.msg)
      }
    }
    

    b. 以及响应错误中的异常处理

    // 响应错误中的异常处理
    // (error) => {  
    //   // 手动清除 Toast
    //   Toast.clear();
    //   reqErrHandler(error);
    //   Promise.reject(error);
    // }
    
    
    
    // 请求异常处理--HTTP[s]状态码
    export const reqErrHandler = (error = { response: {}}) => {
      // console.error(error);
      const { data } = error.response
      // HTTP[s]状态码
      const errItem = errorCodes.find(err => err.code === error.response.status)
      if (error.response.status) {
        errorMsg(data?.message || errItem?.msg || REQ_ERROR)
        // 产品崩溃 埋点
        if (window.zhuge) {
          window.zhuge.track('产品崩溃', {
            '错误标题': error.response.status,
            '错误信息': data?.message || errItem?.msg || REQ_ERROR })
        }
      } else {
        errorMsg(error.message || REQ_ERROR)
        // 产品崩溃 埋点
        if (window.zhuge) {
          window.zhuge.track('产品崩溃', {
            '错误标题': REQ_ERROR,
            '错误信息': error.message || REQ_ERROR })
        }
      }
    }
    

二、请求中断

场景是:比如第一个接口请求失败后,不希望继续进行后续的请求,或者基于某些场景,想要随时中断当前请求。

上面的拦截器其实已经满足绝大部分的项目,但如果有这里说到的场景,还是需要做个增强处理。

取消请求通常有两种方式,CancelToken已被弃用,这里用的是AbortControllerAbortSignal这里不做细述。

参考Axios官网可知,我们只需要实例化一个AbortController对象赋值给请求实例的signal

const controller = new AbortController();

const initConfig = {
      baseURL: baseURL,
      timeout: 50000,
      retry: 3, // 设置全局重试请求次数(最多重试几次请求)
      withCredentials: true, 
      signal: controller.signal
}

然后在请求失败的时候中断即可。

// 响应失败...
      (error) => {
        controller.abort();
        
        // 手动清除 Toast
        Toast.clear();
        reqErrHandler(error);
        Promise.reject(error);
      }

下面是请求成功/失败的前后比对 image.png 这样就不会继续下发后续的请求。 ima2ge.png

但这个其实有一个问题,就是中断后不能继续发起请求

三、中断后继续请求

1. 场景

举几个中断后想要继续请求的场景:

  • 比如请求的时候刚好后端在发版,请求当然是失败的,这时候中断请求没有关系,但5分钟后服务端正常后,我不想丢失当前的操作状态,想点击按钮重新发起提交请求;

  • 或者当前服务不正常,不停点击按钮不断触发同一个请求;

  • 或者把所有请求都放进请求队列中,队列中的请求想要根据不同情况需要按需阻断或按需重新发起请求等等。

​ 如果是上面的代码,是无法继续发起请求的。原因是调用abort方法后,AbortController中的aborted属性由false --> true,并始终保持为true,因此我们后面的请求也再也发不了了。

2. 实现

​ 解决办法就是需要用的时候实例化AbortController,不需要的时候置空。

​ 这里举例实现请求失败后不断触发同一个请求的处理:

  1. 初始化一个请求状态signal及上一个的请求url
  2. 当前请求失败后,把signal置为true
  3. 再次请求接口,signaltrue并且url相同时,取消请求。
  4. 否则重置状态及controller
// 上次的url与本次url一致,并且已经报错了,取消本次[同名]请求
if (this.signal && this.url === config.url) {
  this.abort();
} else {
  // 更新url
  this.url = config.url;
  if (!this.signal) {
    // 重置状态
    this.signal = false;
  }
  if (!this.controller) {
    // 重置controller
    this.controller = new AbortController();
  }
}

image.png 实现以上逻辑后,就可以避免不断发起同名无效请求。

四、迭代源码

根据不同业务需求,拦截器可以有不同的定制,这就是拦截器的意义所在。

以下是上面实现的迭代源码

拦截器

// axios.js

import axios from "axios";
import qs from "qs";
import { Toast } from "vant";

import { isFunction, isBoolean } from "@/utils/pattren.js";
import { baseURL, apiPrefix } from "./baseUrl.js";

// 异常处理
import { reqErrHandler, resErrHandler } from "./errorHandler";
import { resolveSuccess } from "./successHandler";

import { cusHeaders } from "@/utils/tools.js";

const HttpRequest = {
  // 上一个请求url
  url: "",
  // 请求状态
  signal: false,
  // 请求控制器
  controller: new AbortController(),
  /**
   * 接口请求基础配置
   * @param params
   * @return {{baseURL: (string|*), data, withCredentials: boolean, params, timeout: number}}
   */
  getInsideConfig(params) {
    return {
      baseURL: baseURL + apiPrefix,
      timeout: 50000,
      retry: 3, // 设置全局重试请求次数(最多重试几次请求)
      withCredentials: true,
      data: params,
      params: params,
      signal: this.controller ? this.controller.signal : null
    };
  },

  /**
   * 判断当前的请求是否在缓存中
   * 如果是同一个请求就直接返回
   * @param instance
   */
  interceptors(instance) {
    /**
     * 每次请求,先置空上一次的控制器。
     * 只有把控制器设置为动态,才可以abort之后继续发起非同名请求。
     */
    if (this.controller) {
      this.controller = null;
    }

    // 请求拦截器
    instance.interceptors.request.use(
      (config) => {
        if (config.method === "post") {
          config.params = {};
        }
        // form 请求
        if (
          config.formData ||
          config.headers["Content-Type"] === "application/x-www-form-urlencoded"
        ) {
          config.data = qs.stringify(config.data);
        }
        if (config.loading) {
          Toast.loading({
            message: "加载中...",
            duration: 0, // 持续展示 toast
            forbidClick: true
          });
        }

        config.headers = {
          ...config.headers,
          ...cusHeaders()
        };

        // 上次的url与本次url一致,并且已经报错了,取消本次请求
        this.abortSameErrRequest(config);
        return config;
      },
      (error) => {
        console.error(error);
        // 手动清除 Toast
        Toast.clear();
        Promise.reject(error);
      }
    );
    // 响应拦截器
    instance.interceptors.response.use(
      (res) => {
        const { config, data } = res;
        // 错误处理:不阻断res向下流通
        resErrHandler(res);

        // 手动清除 Toast
        Toast.clear();
        // 前置处理:resolve => res.data
        if (isFunction(config.resolve) || isBoolean(config.resolve)) {
          if (config.resolve === true) {
            return resolveSuccess(data);
          }
          return config.resolve(data);
        }
        // 接口数据缓存
        return data;
      },
      (error) => {
        // 请求失败,把signal置为true
        this.signal = true;

        // 手动清除 Toast
        Toast.clear();
        reqErrHandler(error);
        Promise.reject(error);
      }
    );
  },

  // 发起请求
  request(options, params) {
    const instance = axios.create();
    options = Object.assign(this.getInsideConfig(params), options);
    this.interceptors(instance);
    return instance(options);
  },

  // 取消请求
  abort() {
    this.controller.abort();
  },

  /**
   * 请求失败时,阻断同个请求继续发送,否则重置请求状态及控制器
   * 场景:①多次点击触发请求;②滚动加载;
   */
  abortSameErrRequest(config) {
    // 上次的url与本次url一致,并且已经报错了,取消本次[同名]请求
    if (this.signal && this.url === config.url) {
      this.abort();
    } else {
      // 更新url
      this.url = config.url;
      if (this.signal) {
        // 重置状态
        this.signal = false;
      }
      if (!this.controller) {
        // 重置controller
        this.controller = new AbortController();
      }
    }
  }
};

export default HttpRequest;

状态码

// status.js
const errorCodes = [
  // 3xx - 重定向,
  {
    code: 301 || "301",
    msg: "缓冲的文档还可以继续使用"
  },
  // 4xx - 客户端错误,
  {
    code: 400 || "400",
    msg: "无效参数"
  },
  {
    code: 401 || "401",
    msg: "访问被拒绝"
  },
  {
    code: 403 || "403",
    msg: "资源不可用。服务器拒绝处理"
  },
  {
    code: 404 || "404",
    msg: "请求资源不存在"
  },
  {
    code: 405 || "405",
    msg: "请求方法错误"
  },
  {
    code: 406 || "406",
    msg: "请求类型不支持"
  },
  {
    code: 407 || "407",
    msg: "请先对请求进行授权"
  },
  {
    code: 415 || "415",
    msg: "不支持的媒体类型"
  },
  {
    code: 417 || "417",
    msg: "执行失败"
  },
  // 5xx - 服务器错误
  {
    code: 500 || "500",
    msg: "服务端错误"
  },
  {
    code: 501 || "501",
    msg: "服务器不支持该请求方法"
  },
  {
    code: 502 || "502",
    msg: "服务器无响应"
  },
  {
    code: 503 || "503",
    msg: "服务器无服务"
  },
  {
    code: 504 || "504",
    msg: "请求超时"
  },
  {
    code: 505 || "505",
    msg: "服务器不支持请求中所指明的HTTP版本"
  }
];

export default errorCodes;