likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 40

写在最前

什么是竞态问题?

竞态问题(Race condition)是指在并发编程中,多个线程或进程以不可预知的方式相互干扰或竞争资源的现象。

在如今前后端分离的大背景下,ajax请求几乎是不可或缺的,复习下定义

Ajax(Asynchronous JavaScript and XML)是一种用于在客户端和服务器之间进行异步数据交互的技术。它允许通过JavaScript在不刷新整个页面的情况下,向服务器发送HTTP请求并接收响应。

ps: Ajax不仅限于使用XML作为数据交换格式,也可以使用其他格式,如JSON。

就拿b站的搜索页就是特别典型的例子,上方条件框的改变对应新的请求条件,请求条件改变触发请求获取对应的数据

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

通常我们实现上面这个搜索功能的逻辑是 :

触发请求 --> 等待资源返回 --> 处理返回的资源 --> 更新页面状态

拿代码来说就是这样


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('请求已被取消');

换成代码即是

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方法来取消一个或多个请求

import { useQuery, useQueryClient } from 'react-query';

function App() {
 const queryClient useQueryClient();

 const { data, isLoading, isError } = useQuery({
    queryKey: ['todos'],
    queryFnasync ({ 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 只是作为外层封装,可以配合XHR、GraphQL、Fetch、axios等内层基础封装来使用

具体见react query cancellation

swr

如果你使用的是swr,你可以使用mutateuseSWRMutation 来避免 useSWR 之间的竞态条件

function Profile() {
 // 获取用户
 const { data } = useSWR('/api/user', getUser, { revalidateInterval3000 })
 // 更新用户
 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,来决定是否采纳当前请求的返回

let searchID = 0
const search = async ()=>{
// 更新全局id
searchID +=1
// 更新局部id
const thisFetchID = searchID
     const res = await qryList(params)
     // 如果当前请求id和全局最新的id匹配不上,则忽略
     if(thisFetchID !== searchID) return 
}

当然也可以进一步封装


function resolveLast (){
const current = {
   currentID:0
}
 const wrappedFn = (fn) =>{
   current.currentID +=1
   const thisFetchId = current.currentID
   fn.call(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方法

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

import { useRequest } from 'ahooks';
const { loading,  cancel } = useRequest(editUsername);
vue3

如果你是在vue3当中通过watch来监听从而发请求的

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 实现对请求的取消或者忽略

watchEffect(async (onCleanup) => {
 const { response, cancel } = doAsyncWork(id.value)
 // `cancel` 会在 `id` 更改时调用
 // 以便取消之前
 // 未完成的请求
 onCleanup(cancel)
 data.value = await response
})

react

在React 当中,可以利用useEffect的清除机制封装成hooks

比如下文的例子当中,可以让你在依赖发生变化时候,通过闭包变量 didCancel去 忽略上一次的请求

这个例子的应用场景是 articleId 改变,需要根据articleId重新请求最新的文章信息,为了保证信息是根据最新的articleId 来的,可以通过标志位didCancel去决定是否要忽略当前请求。

useEffect(() => {
 // 设定一个标志位
 let didCancel = false;
 setIsLoading(true);
 // 发请求 
 fetch(`https://get.a.article.com/articles/${articleId}`)
   .then((response) => {
     if (response.ok) {
       return response.json();
     }
     return Promise.reject();
   })
   .then((fetchedArticle: Article) => {
     // 在这里判断是否这个请求需要被忽略
     if (!didCancel) {
       setArticle(fetchedArticle);
     }
   })
   .finally(() => {
     setIsLoading(false);
   });

 return () => {
   // 当依赖变化时,会执行此回调,使得你有机会操作 上文 闭包引用的didCancel变量
   didCancel = true;
 }
}, [articleId]);

总结

竞态问题本质上是因为js的异步机制叠加网络的不确定性,导致处理响应处理的时机不确定,这和我们的预期:先发出的请求先处理响应不符。

通常来说解决此类问题,思路有2种分别是 「取消」 或者 「忽略」 过期请求,实际业务当中可以根据具体使用的请求库来具体实操。

写在最后

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

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

转载自:https://juejin.cn/post/7264006773467676709
评论
请登录