likes
comments
collection
share

如何处理前端开发中的竞态请求

作者站长头像
站长
· 阅读数 12

前言

竞态条件(Race Conditions)在前端开发中是一种常见的问题,特别是在多个异步操作同时竞争资源或执行时。这可能导致意外的结果,如数据不一致、重复请求和UI错误。

举个🌰

一个最常见的场景就是选项卡切换,用户快速切换Tab,由于网络延迟等原因,很可能最后停留的Tab所展示的内容,并不是用户想要看到的内容

如何处理前端开发中的竞态请求

出现这种情况是因为,用户最先点击的Tab请求把最后点击的Tab请求覆盖了(由于网络原因,最先发出的请求最后到达):

如何处理前端开发中的竞态请求

解决方案

取消请求

通常我们可以在新的请求发起之前,将旧的、未到达的请求给取消掉,这样旧的请求就不会覆盖新的请求了

XMLHttpRequest 取消请求

  • XMLHttpRequest(XHR)是一个内建的浏览器对象,它允许使用 JavaScript 发送 HTTP 请求。
  • 如果请求已被发出,可以使用 abort() 方法立刻中止请求。
const xhr= new XMLHttpRequest();

xhr.open('GET', 'https://xxx');
xhr.send();
// 取消请求
xhr.abort();

Fetch API 取消请求

  • fetch 号称是 AJAX 的替代品,出现于 ES6,它也可以发出类似 XMLHttpRequest 的网络请求。
  • 主要的区别在于 fetch 使用了 Promise,要中止 fetch 发出的请求,需要使用 AbortController。
const controller = new AbortController();
const signal = controller.signal;

fetch('/xxx', {
  signal,
}).then(function(response) {
  //...
});
// 取消请求
controller.abort();

Axios cancel Token 取消请求

  • 相比原生 API,大多项目都会选择 axios 进行请求。
import Axios from 'axios'

const CancelToken = Axios.CancelToken
let cancel

export const getSomeResource = (params: GetSomeResourceReq) => {
  if (cancel) {
    cancel()
  }
  const res = Axios.post('/xxx', params)
  return res.catch((err) => { // 取消了axios请求会走到异常处理,我们需要对这种错误进行过滤
    if (Axios.isCancel(err)) {
      return {} as GetSomeResourceRsp
    }
    throw err
  })
}

忽略请求

相较于取消请求,忽略请求更为通用。我们只需要关注我们最后一次请求的结果,如果某次请求的返回结果并不是最新的,那么我们就忽略掉这个请求。忽略请求的一个精髓在于终止Promise的响应,这也是字节面试官经常问到的一个问题,我们需要做的就是返回一个Pending的Promise,从而做到终止的效果

使用锁标记

这里的锁只的是某个唯一标识,通过这个标识来对比当前请求是否过期,如下文的prevTimestamp

我们通过闭包,实现对prevTimestamp的缓存,从而做到对每次请求的返回进行对比的效果

/**
 * 处理竞态请求
 *
 * @export
 * @param {(...params: any[]) => Promise<any>} fn
 * @return {*}
 */
export default function useFetch<T, P>(fn: (...params: [P, ...any[]]) => Promise<T>) {
  let prevTimestamp
  return function (params: P, ...rest: any[]): Promise<T> {
    return new Promise((resolve, reject) => {
      const curTimestamp = prevTimestamp = Date.now()
      const context = this
      fn.call(context, params, ...rest)
        .then(res => {
          // 只处理最新请求的返回
          if (curTimestamp === prevTimestamp) {
            resolve(res)
          }
        }).catch(err => {
          // 只处理最后一次请求的异常
          if (curTimestamp === prevTimestamp) {
            reject(err)
          }
        })
    })
  }
}

在使用的时候,我们就可以用这个方法把真正要发出的请求包一下(针对某一个具体的原子请求,从而做到多请求皆可竟态处理)

import Axios from 'axios'

const _getSomeResource = (params: GetSomeResourceReq) => {
  return Axios.post('/xxx', params)
}

export getSomeResource = useFetch(_getSomeResource)

使用队列

与使用锁类似,我们把每个请求都放进队列里,对每次返回的请求进行判断,如果这个请求不是最新的请求,那么就忽略掉

/**
 * 处理竞态请求
 *
 * @export
 * @param {(...params: any[]) => Promise<any>} fn
 * @return {*}
 */
export default function useFetch<T, P>(fn: (...params: [P, ...any[]]) => Promise<T>) {
  const queue = []
  return function (params: P, ...rest: any[]): Promise<T> {
    const p = new Promise((resolve, reject) => {
      const context = this
      fn.call(context, params, ...rest)
        .then(res => {
          const isLatest = queue[queue.length - 1] === p
          // 只处理最新请求的返回
          if (isLatest) {
            resolve(res)
          }
        }).catch(err => {
          const isLatest = queue[queue.length - 1] === p
          // 只处理最后一次请求的异常
          if (isLatest) {
            reject(err)
          }
        }).finally(() => {
           const idx = queue.findIndex(item => item === p)
           queue.splice(idx, 1)
        })
    })
    queue.push(p)
    return p
  }
}

总结

  • 为了解决前端开发中遇到的竟态请求问题,我们提供了两种解决方案:取消请求 & 忽略请求
  • 这两种方案都有一定的优劣,取消请求会导致客户端主动断开连接,可能对后台异常监控带来影响;忽略请求可能导致前端请求过于频繁,增加后台服务器压力,可以结合截流/防抖机制加以优化

参考