写在最前
看官们好,我叫JetTsang,之前都是在掘金潜水来着,现在偶尔做一些内容输出吧。
什么是竞态问题?
竞态问题(Race condition)是指在并发编程中,多个线程或进程以不可预知的方式相互干扰或竞争资源的现象。
在如今前后端分离的大背景下,ajax请求几乎是不可或缺的,复习下定义
Ajax(Asynchronous JavaScript and XML)是一种用于在客户端和服务器之间进行异步数据交互的技术。它允许通过JavaScript在不刷新整个页面的情况下,向服务器发送HTTP请求并接收响应。
ps: Ajax不仅限于使用XML作为数据交换格式,也可以使用其他格式,如JSON。
就拿b站的搜索页就是特别典型的例子,上方条件框的改变对应新的请求条件,请求条件改变触发请求获取对应的数据
通常我们实现上面这个搜索功能的逻辑是 :
触发请求 --> 等待资源返回 --> 处理返回的资源 --> 更新页面状态
拿代码来说就是这样
scss
const search = async ()=>{
// 1.请求并等待
const res = await qryList(params)
// 2.处理资源并更新页面状态
// vue 通过修改响应式数据去更新视图
dataList.value = res.slice()
// rect 通过setState去更新视图
setDataList(res.slice())
}
因为javascript的异步机制,处理返回的资源这一步是异步操作,由于网络的不确定性,当我们连续快速的触发同一段逻辑时,就会发生竞态问题了。
假设你在上图b站搜索栏这里快速点了不同的条件,此时就会发送一系列的请求,我们的预期是:页面的状态要对得上最后发出的请求返回的内容。但是由于请求是不确定的,可能会出现较早发出的请求,比较晚发出的请求响应更慢,此时的页面状态就有可能是较早请求响应的状态。
如上所述就是典型的竞态问题,该问题核心点就是连续触发不确定的异步操作,在前端常见的场景有:搜索🔍,选项卡切换,列表分页切换等等
那么如何解决呢?
解决思路及方案
取消请求
一个自然的思路便是:当我连续发出相同的请求时,取消当前还未响应的请求。
恰巧XMLHttpRequest (XHR) 和Fetch API 都提供了取消请求的API
XHR
- 创建一个XMLHttpRequest对象:
const xhr = new XMLHttpRequest();
- 打开请求的方式和URL:
xhr.open(method, url);
- 发送请求:
xhr.send();
- 要取消请求,调用xhr的abort方法:
xhr.abort();
FETCH
- 创建一个AbortController对象:
const controller = new AbortController();
- 从controller中获取AbortSignal对象:
const signal = controller.signal;
- 在fetch请求中传入signal作为options的一个属性:
fetch(url, { signal })
- 要取消请求,调用AbortController的abort方法:
controller.abort();
具体实践方案
axios
如果你使用了axios,那么使用方法跟上面Fetch API 很像,它的原理是通过cancel里执行reject将promise转为fullfilled状态以及xhr.abort来取消请求
- 导入axios和CancelToken:
import axios, { CancelToken } from 'axios';
- 创建一个CancelToken.source对象:
const source = CancelToken.source();
- 在发送请求时,传递cancelToken参数:
axios.get(url, { cancelToken: source.token })
- 要取消请求,调用source对象的cancel方法:
source.cancel('请求已被取消');
换成代码即是
javascript
import axios, { CancelToken } from 'axios';
const source = CancelToken.source();
axios.get(url, { cancelToken: source.token })
.then(response => {
// 请求成功处理
})
.catch(error => {
// 这里利用isCancel可以判断是否是取消
if (axios.isCancel(error)) {
console.log('请求已被取消', error.message);
} else {
// 其他错误处理
}
});
// 要取消请求,调用source对象的cancel方法
source.cancel('请求已被取消');
当然还有new CancelToken通过传递executor获取cancel的用法,具体可以参考axios的使用文档
vueuse
可以使用useFetch这个hook来取消请求,里面提供了abort和timeout选项,会自动忽略请求
React Query
可以使用useQueryClient
hook中的cancelQueries
方法来取消一个或多个请求
php
import { useQuery, useQueryClient } from 'react-query';
function App() {
const queryClient = useQueryClient();
const { data, isLoading, isError } = useQuery({
queryKey: ['todos'],
queryFn: async ({ signal }) => {
const resp = await fetch('/todos', { signal })
return resp.json()
},
});
const handleCancelClick = () => {
// 取消key为"todos"的请求
queryClient.cancelQueries({queryKey:['todos']});
};
return (
<div>
{/* 渲染数据 */}
{isLoading ? 'Loading...' : null}
{isError ? 'Error: ' + isError.message : null}
{data ? <div>{data}</div> : null}
{/* 渲染取消按钮 */}
<button onClick={handleCancelClick}>Cancel</button>
</div>
);
}
React Query 只是作为外层封装,可以配合XML、GraphQL、Fetch、axios等内层基础封装来使用
swr
如果你使用的是swr,你可以使用mutate
和 useSWRMutation
来避免 useSWR
之间的竞态条件
javascript
function Profile() {
// 获取用户
const { data } = useSWR('/api/user', getUser, { revalidateInterval: 3000 })
// 更新用户
const { trigger } = useSWRMutation('/api/user', updateUser)
return <>
{data ? data.username : null}
<button onClick={() => trigger()}>Update User</button>
</>
}
正常情况下 useSWR
hook 可能会因为聚焦,轮询或者其他条件在任何时间刷新,这使得展示的 username 尽可能是最新的。然而,由于我们在useSWR
的刷新过程中几乎同时发生了一个数据更改,可能会出现 getUser
请求更早开始,但是花的时间比 updateUser
更长,导致竞态情况。
幸运的是 useSWRMutation
可以为你自动处理这种情况。在数据更改后,它会告诉 useSWR
放弃正在进行的请求和重新验证,所以旧的数据永远不会被显示。
忽略过期请求
顾名思义就是需要辨认出是当前的请求是否过期,过期则忽略。这样做相对于取消请求的缺点是没有减轻服务端的压力
具体实践方案
ID标识
可以为每一个请求配置一个id,通过判断id是否是当前最新的id,来决定是否采纳当前请求的返回
csharp
let searchID = 0
const search = async ()=>{
// 更新全局id
searchID +=1
// 更新局部id
const thisFetchID = searchID
const res = await qryList(params)
// 如果当前请求id和全局最新的id匹配不上,则忽略
if(thisFetchID !== searchID) return
}
当然也可以进一步封装
javascript
function resolveLast (){
const current = {
currentID:0
}
const wrappedFn = (fn) =>{
current.currentID +=1
const thisFetchId = current.currentID
fn.apply(this,current,thisFetchId)
}
return wrappedFn
}
// 或者
function resolveLast() {
let currentID = 0;
const getCurrentId = () => currentID;
const wrappedFn = (fn) => {
currentID += 1;
const thisFetchId = currentID;
fn.call(this, getCurrentId, thisFetchId);
};
return wrappedFn;
}
const wrapper = resolveLast()
wrapper(async (current,thisFetchId)=>{
// 等待请求
await fetch()
// 过期就忽略
if(current.currentID !==thisFetchId) return
// 处理逻辑
})
ahooks
在ahooks当中可以利用useRequest的cancel方法
javascript
import { useRequest } from 'ahooks';
const { loading, cancel } = useRequest(editUsername);
vue3
如果你是在vue3当中通过watch来监听从而发请求的
javascript
watch(obj, async (newValue, oldValue, onCleanup) => {
// 定义一个标志,代表当前副作用函数是否过期,默认为 false,代表没有过期
let expired = false
// 调用 onCleanup() 函数注册一个过期回调
onCleanup(() => {
// 当过期时,将 expired 设置为 true
expired = true
})
// 发送网络请求
const res = await fetch('/path/to/request')
// 只有当该副作用函数的执行没有过期时,才会执行后续操作。
if (!expired) {
finalData.value = res
}
})
或者watchEffect,可以配合相关的cancel 实现对请求的取消或者忽略
scss
watchEffect(async (onCleanup) => {
const { response, cancel } = doAsyncWork(id.value)
// `cancel` 会在 `id` 更改时调用
// 以便取消之前
// 未完成的请求
onCleanup(cancel)
data.value = await response
})
总结
竞态问题本质上是因为js的异步机制叠加网络的不确定性,导致处理响应处理的时机不确定,这和我们的预期:先发出的请求先处理响应不符。
通常来说解决此类问题,思路有2种分别是 「取消」 或者 「忽略」 过期请求,实际业务当中可以根据具体使用的请求库来具体实操。
写在最后
因本人才疏学浅,难免有错漏,欢迎大家在评论区指正~
如果你觉得本文对你有所帮助,不妨给我一个点赞和收藏,这将是对我最大的鼓励