前端异步请求的状态竞争
问题:一个搜索框为什么会出现 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 等事件,而不是搜索框、表单输入这类事件。