前端异步请求的状态竞争(请求竞态)问题

前端异步请求的状态竞争

问题:一个搜索框为什么会出现 Bug?

在前端项目中,会遇到这样的场景:

用户在搜索框中输入城市名称:

复制代码
北
北京
北京大
北京大学

如果每次输入都立即向服务器发送请求,那么用户输入 4 个字符,就可能产生 4 个请求。

会产生两个问题:

第一个问题:请求太多。

用户只是输入几个字,却向服务器发送了大量请求。

第二个问题:请求竞争。

我们不能保证请求返回的顺序和发送的顺序一致。

复制代码
请求 A:搜索"北"
请求 B:搜索"北京"
请求 C:搜索"北京大学"

我们发送请求的顺序是A → B → C

但是服务器返回的顺序可能是B → C → A

因为网络延迟、服务器处理时间等因素,每个请求所需要的时间可能不同。

如果程序简单地使用"谁最后返回就显示谁"的逻辑,那么请求 A 最后返回时,就可能把已经显示的"北京大学"结果覆盖掉。

这就是异步请求竞态问题。

首先解决一个问题:请求太频繁

既然搜索框每输入一个字符都会发送请求,那么第一个想法就是:

用户还没有输入完,就不要急着发送请求。

例如:

复制代码
用户输入:
北
北京
北京大
北京大学

我们希望程序变成:

复制代码
北
 ↓
等待

北京
 ↓
取消之前的等待
重新等待

北京大
 ↓
取消之前的等待
重新等待

北京大学
 ↓
取消之前的等待
重新等待

用户停止输入
 ↓
等待 300ms
 ↓
发送一次请求

这种技术叫做:

防抖

防抖的核心思想:

事件连续触发时,不立即执行,而是等事件停止触发一段时间后再执行。

手写一个防抖函数

防抖可以自己实现:

复制代码
function debounce(fn, delay) {
    let timer

    return function (...args) {
        clearTimeout(timer)

        timer = setTimeout(() => {
            fn.apply(this, args)
        }, delay)
    }
}

为什么需要用 apply 来改变 this 指向

防抖函数为了延迟执行 fn会把原本由事件触发时提供的 this 隔了一层,若业务函数依赖 this,就需要用 apply 把原来的 this 传给 fn,apply里传入的 this 是外层return function (...args) 的this

比如下面搜索框的例子,绑定事件之后,我们需要获取this.value,这里的 this 肯定就是要指向那个输入框了,但如果不加fn.apply(this, args),由于addEventListener实际调用的是debounce 的返回函数,但最终执行为fn,fn 为普通调用并不会继承返回函数的 this,其this 指向为window,所以就会出现问题。

复制代码
input 触发事件
      ↓
debounce 返回的 function
      ↓
setTimeout 延迟
      ↓
fn

把防抖应用到搜索框

例如我们有一个搜索函数:

复制代码
async function search(query) {
    const results = await lookupCities(query)

    console.log(results)
}

我们不直接使用它,而是:

复制代码
const searchDebounce = debounce(search, 300)

然后监听输入框:

复制代码
searchInput.addEventListener('input', function () {
    searchDebounce(this.value)
})

这样用户连续输入时,就不会每次都立即请求。

例如:

复制代码
北
北京
北京大学

最终可能只发送:

复制代码
搜索"北京大学"

但这只减少了请求量,没有解决请求竞争的问题。

方案一:requestId------只让最新请求修改页面

解决请求竞争,一个非常简单的方法就是:

给每一个请求一个编号。

searchRequestId为一个全局变量,每次请求发送就加一,用于标记最新的请求号码(理解为版本号)

复制代码
let searchRequestId = 0

每发送一次请求:

requestId为函数内的变量,其核心作用是给每次请求一个标记(当前请求的版本号)

复制代码
const requestId = ++searchRequestId

这样:

复制代码
第一个请求 → requestId = 1
第二个请求 → requestId = 2
第三个请求 → requestId = 3

然后请求返回以后检查:

复制代码
if (requestId !== searchRequestId) {
    return
}

若该请求的版本号和最新的版本号不相同,那就return 结束,不渲染该次请求的数据。

方案二:AbortController------直接取消旧请求

虽然 requestId 可以防止旧请求修改页面,但是有一个问题:

旧请求虽然不能修改页面,但是它还在执行,那就可以直接取消旧的请求。

我们可以使用AbortController, 它是一个请求控制器。

创建:const controller = new AbortController()后,把controller.signal 交给请求:

js 复制代码
controller
     ↓
   signal
     ↓
  axios请求

调用controller.abort()就可以取消请求

AbortController 删除旧请求

js 复制代码
let controller = null

async function search(keyword) {
    // 如果存在上一次请求,先取消
    if (controller) {
        controller.abort()
    }

    // 为这一次请求创建新的控制器
    controller = new AbortController()

    try {
        const res = await axios.get('/api/search', {
            params: {
                keyword
            },
            signal: controller.signal
        })

        console.log(res.data)
    } catch (err) {
        // 请求被主动取消,不需要当成普通错误处理
        if (err.name === 'CanceledError') {
            return
        }

        console.error(err)
    }
}

还需要用 requestId?

取消请求和防止旧结果污染状态,是两个不同层面的保护,我们不应该只依赖这一层。

因为abort不能保证旧请求一定被取消,如果旧请求已经快要返回了,此时再取消有可能不成功。

所以可以再加:

js 复制代码
if (
    requestId !== searchRequestId ||
    searchInput.value.trim() !== query
) {
    return
}

这里实际上进行了两次检查。

第一层:requestId

复制代码
requestId !== searchRequestId

若已经过时,就不渲染页面。

第二层:检查搜索框当前内容

复制代码
searchInput.value.trim() !== query

检查当前用户的输入框和发送请求的内容一样与否。

防抖 + requestId + AbortController

到这里,解决状态竞争问题,我们可以设计三层防护。

第一层:防抖

让用户停止输入一段时间后再发送请求。

第二层:AbortController

取消已经没有意义的旧请求。

第三层:requestId

即使旧请求最终返回,也不污染最新状态。

复制代码
用户输入
   ↓
防抖
   ↓
减少请求数量
   ↓
发送请求
   ↓
保存 requestId
   ↓
创建 AbortController
   ↓
用户继续输入?
   ↓
是
   ↓
取消旧请求
   ↓
发送新请求
   ↓
旧请求即使返回
   ↓
检查 requestId
   ↓
不是最新请求
   ↓
丢弃结果

天气请求和搜索请求为什么使用不同方案?

天气数据请求主要使用的是weatherRequestId,来防止旧的请求污染页面。

用户在切换不同城市时产生的旧请求数量也不多,因此可以只用weatherRequestId。

而搜索框不一样,因为用户会不断输入,产生大量无用的旧请求。

因此搜索框可以进一步使用:

复制代码
防抖
+
AbortController
+
requestId
+
query检查

可以用节流吗

节流是不适用该场景的,节流虽然也能减少请求数量,但是它和防抖不一样。

防抖是停止请求一定时间后才发送,可以实现"只发送最终搜索的内容"。

节流是按照固定时间间隔执行,它更适用于滚动、mousemove、resize 等事件,而不是搜索框、表单输入这类事件。

相关推荐
小蜗 strong7 小时前
和电脑猜拳(随机程序应用)
服务器·前端·python
下家9 小时前
为了脱离前端鄙视链,于是自己写个框架 - React 党看完沉默了
前端·前端框架
特立独行的猫A9 小时前
仓颉版 Tauri:基于轻量化线程与纯血鸿蒙架构的下一代 Web 混合开发利器
javascript
zhiyouTech9 小时前
从“被看见“到“被信任“:关于信源评级机制的一点观察
大数据·前端·人工智能
Hashan9 小时前
Vibe Coding 下前后端怎么对接接口?后端不给力的兜底方案
前端·后端·vibecoding
李游Leo9 小时前
HarmonyOS 7 ArkTS + ohpm:三方 JS 库副作用隔离与 API 26 升级回归【鸿蒙心迹】
javascript·回归·harmonyos
你挚爱的强哥9 小时前
【sgKeyboard】自定义组件:虚拟键盘
前端·javascript·计算机外设
锅里游的鱼吖10 小时前
echarts自定义折线图
前端·javascript·数据库
hunteritself10 小时前
卷卷卷!GPT-6 Sol、Luna 正式发布,OpenAI 开始卷价格了
大数据·前端·人工智能·深度学习·transformer
何何____10 小时前
Vue 全局事件总线详解
前端·javascript·vue.js