前端请求的竞态问题

904 阅读3分钟

前言

竞态是什么意思,引用百度的解释

竞态(Race Condition)是多线程或并发系统中因无序访问共享资源导致的数据不一致或程序错误的现象

在前端项目中,多发于数据查询场景,同一个接口,短时间调用多次,因为响应时间的差异,可能导致最新的返回结果被旧的返回结果覆盖。可以考虑使用防抖,节流解决,如果考虑项目整体避免这个问题,有没有统一处理的方法呢?可以试试 AbortController

AbortController

引用官方解释

AbortController 接口的 abort() 方法会在一个异步操作完成之前中止它。它能够中止 fetch 请求、各种响应主体或者流的消耗

它支持在多个场景中断操作,我们只需要用到中止fetch请求,一般我们前端请求库都是用的axios,从v0.22.0开始,已经支持 AbortController 取消请求

接口请求改造

知道了要用AbortController实现,那么如何在项目中应用呢?我们可以借助 ai 工具,先大致了解实现思路,我直接在项目中对 cursor 提问,大致看一下它的回答

把需求,粗略解决方案告诉 cursor,它能大体给出解决方案

  1. 创建请求管理器RequestManager,管理需要中断的请求,管理器很简单,包含添加和移除请求
  2. 当响应返回或者异常,在管理器中移除请求
  3. 页面接口调用时候添加标识,判断多个相同请求是否走中断逻辑

请求管理器

请求管理器使用 Map 保存接口和 AbortController 的对应关系,实现添加请求和移除请求

在添加请求时,需要判断是否已经存在,已存在则消除

// 请求管理器 - 用于管理 AbortController
class RequestManager {
    constructor() {
        this.pendingRequests = new Map()
    }
​
    // 生成请求的唯一标识
    generateRequestKey(url, method) {
        return `${method}_${url}`
    }
​
    // 添加请求
    addRequest(url, method) {
        const requestKey = this.generateRequestKey(url, method)
​
        // 如果存在相同请求,先取消它
        if (this.pendingRequests.has(requestKey)) {
            const existingController = this.pendingRequests.get(requestKey)
            existingController.abort()
            this.pendingRequests.delete(requestKey)
        }
​
        // 创建新的 AbortController
        const controller = new AbortController()
        this.pendingRequests.set(requestKey, controller)
​
        return controller
    }
​
    // 移除已经完成的请求
    removeRequest(url, method) {
        const requestKey = this.generateRequestKey(url, method)
        this.pendingRequests.delete(requestKey)
    }
}
​
export default RequestManager

移除请求

在响应拦截器中, 需要判断当前请求是否存在中断标识,如果存在,当请求完成或者异常时,需要从队列中移除

service.interceptors.response.use(
    (response) => {
        if (response.config.enableAbort) {
            requestManager.removeRequest(response.config._requestKey)
        }
    
    // ....
    
        return Promise.resolve(response.data)
    },
    (error) => {
        if (error) {
            let status = null
            if (error.code === 'ERR_CANCELED') {
                status = 'ERR_CANCELED'
            } else {
                status = 503
                const description = errorCodeMap[status]
                notification.error({
                    message: '请求错误',
                    description
                })
      if (error.config?.signal) {
                requestManager.removeRequest(error.config._requestKey)
            }   
            }
            return Promise.reject(status)
        }
    }
)

请求封装和实际使用

在使用接口时,需要传递中断标识

import { baseRequest } from '@/utils/request'const requestBillManage = (url, ...arg) => baseRequest(`/api/webapp` + url, ...arg)
​
queryRechargeBillByMonth(data) {
  return requestBillManage('recharge/page', data, 'post', {
    enableAbort: true
  })
},

axios实例中处理接收到的参数,判断存在中断标识,就在请求参数中设置signal属性,值是AbortController对应的信号对象,设置_requestKey,值是请求方法和路径拼接

export const baseRequest = (url, value = {}, method = 'post', options = {}) => {
    let controller = null
    let enableAbort = options?.enableAbort || falseif (enableAbort) {
        controller = requestManager.addRequest(url, method)
        options = {
            ...options,
            signal: controller.signal,
            _requestKey: requestManager.generateRequestKey(url, method)
        }
    }
​
    if (method === 'post') {
        return service.post(url, value, options)
    } else if (method === 'get') {
        return service.get(url, { params: value, ...options })
    } else if (method === 'formdata') {
        // form-data表单提交的方式
        return service.post(url, qs.stringify(value), {
            headers: {
                'Content-Type': 'multipart/form-data'
            },
            ...options
        })
    } else if (method === 'upload') {
        return service.post(url, value, {
            headers: {
                'Content-Type': 'multipart/form-data'
            },
            ...options
        })
    } else {
        // 其他请求方式,例如:put、delete
        return service({
            method: method,
            url: url,
            data: value,
            ...options
        })
    }
}

总结一下执行顺序

  1. 添加中断标识
  2. axios 实例判断中断标识,添加请求到请求队列,添加请求时判断,如果队列已经存在,则中断请求,并从队列删除。添加信号对象和请求队列的标记参数
  3. 请求完成或者异常,判断如果存在中断标识,则从队列删除

演示

打开浏览器控制台,切换网络到 3G,快速点击重置按钮,触发请求,可以看到新触发的请求会将旧的请求中断