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

前端异步请求的状态竞争

问题:一个搜索框为什么会出现 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 等事件,而不是搜索框、表单输入这类事件。

相关推荐
是立不是利1 小时前
前端交互基石:深入剖析JavaScript三级联动背后的设计哲学
开发语言·前端·javascript
动恰客流统计1 小时前
传统红外对射客流统计为何逐步淡出主流?准确率与场景限制深度分析
大数据·前端·人工智能
老刘的望远镜1 小时前
AgentScope Java 从零(03):Agent 的记性默认全开,我在第 50 轮翻了车
java·开发语言·javascript
Rooting++1 小时前
小程序{{ }}和vue{{ }}语法的区别
前端·vue.js·小程序
前端精髓2 小时前
React 更新 state 中的对象
javascript·react.js·ecmascript
豆约翰2 小时前
css自制图书封面效果
前端·css
积硅步致千里2 小时前
Fyne 兼容性:报错还能救,透明窗才要命
前端·后端
可乐鸡翅yeah_3 小时前
hls.js 播放质量埋点实战,采集卡顿、起播、错误指标定位线上用户问题
开发语言·前端·javascript·ecmascript·音视频·m3u8·音视频在线播放
tedcloud1233 小时前
Wand-Enhancer:如何搭建一套远程开发与测试环境
前端·人工智能·macos·开源·流程图