前端开发日常之竞态问题的解决方案

写在最前

看官们好,我叫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

  1. 创建一个XMLHttpRequest对象:const xhr = new XMLHttpRequest();
  2. 打开请求的方式和URL:xhr.open(method, url);
  3. 发送请求:xhr.send();
  4. 要取消请求,调用xhr的abort方法:xhr.abort();

FETCH

  1. 创建一个AbortController对象:const controller = new AbortController();
  2. 从controller中获取AbortSignal对象:const signal = controller.signal;
  3. 在fetch请求中传入signal作为options的一个属性:fetch(url, { signal })
  4. 要取消请求,调用AbortController的abort方法:controller.abort();

具体实践方案

axios

如果你使用了axios,那么使用方法跟上面Fetch API 很像,它的原理是通过cancel里执行reject将promise转为fullfilled状态以及xhr.abort来取消请求

  1. 导入axios和CancelToken:import axios, { CancelToken } from 'axios';
  2. 创建一个CancelToken.source对象:const source = CancelToken.source();
  3. 在发送请求时,传递cancelToken参数:axios.get(url, { cancelToken: source.token })
  4. 要取消请求,调用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等内层基础封装来使用

具体见react query cancellation

swr

如果你使用的是swr,你可以使用mutateuseSWRMutation 来避免 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种分别是 「取消」 或者 「忽略」 过期请求,实际业务当中可以根据具体使用的请求库来具体实操。

写在最后

因本人才疏学浅,难免有错漏,欢迎大家在评论区指正~

如果你觉得本文对你有所帮助,不妨给我一个点赞和收藏,这将是对我最大的鼓励

相关推荐
小小竹子6 分钟前
前端vue-实现富文本组件
前端·vue.js·富文本
万物得其道者成14 分钟前
React Zustand状态管理库的使用
开发语言·javascript·ecmascript
小白小白从不日白14 分钟前
react hooks--useReducer
前端·javascript·react.js
下雪天的夏风27 分钟前
TS - tsconfig.json 和 tsconfig.node.json 的关系,如何在TS 中使用 JS 不报错
前端·javascript·typescript
diygwcom38 分钟前
electron-updater实现electron全量版本更新
前端·javascript·electron
volodyan41 分钟前
electron react离线使用monaco-editor
javascript·react.js·electron
^^为欢几何^^1 小时前
lodash中_.difference如何过滤数组
javascript·数据结构·算法
Hello-Mr.Wang1 小时前
vue3中开发引导页的方法
开发语言·前端·javascript
程序员凡尘1 小时前
完美解决 Array 方法 (map/filter/reduce) 不按预期工作 的正确解决方法,亲测有效!!!
前端·javascript·vue.js
编程零零七5 小时前
Python数据分析工具(三):pymssql的用法
开发语言·前端·数据库·python·oracle·数据分析·pymssql