cover_image

技术分析 | 前端异步请求下竞态问题的最佳实践

廖同学 明源云天际PaaS平台
2022年05月16日 11:00

图片

图片

导读:时间是程序里最复杂的因素,编写Web应用的时候,一般来说,我们大多时候处理的都是同步的、线性的业务逻辑。但是在我们的业务中,往往也会设计到很多的异步问题,比如最常见的ajax请求,如果代码中出现了多个异步的时候,这个时候我们就要考虑下竞态场景和竞态问题了。


一、背景

JavaScript语言的一大特点就是单线程,也就是说,同一个时间只能做一件事,Javascript为什么不能有多个线程?这和它的用途有关,作为浏览器脚本语言,Javascript的主要用途是与用户互动,以及操作DOM,这决定了它只能是单线程。单线程就意味着所有的任务需要排队,前一个任务结束,才会执行下一个任务。但是Javascript的任务分同步和异步任务,相比同步任务,异步避免了线程阻塞,提高了线程的可响应性。异步任务相比同步任务的复杂之处,主要在于返回结果的时机不可控,由此带来超时控制、顺序控制、竞态、最大并发等一系列问题。

引子
最近在做业务的时候,测试提了个bug,点击tab切换的时候,页面上展示的数据和查询条件不对应,如图所示:

图片

我们查询的是生产条件下的数据,可是查出来的数据的环境类型却是显示的是测试,遇到这个问题,第一反应是后端的问题,经过一系列排查后,发现后端返回的数据是正确的,是前端的渲染有问题,正常的数据显示应该是如下图所示:

图片

为什么会出现以上的情况了?

在以下的讲解中,用task1对应点击测试触发的异步任务,task2对应点击预发布对应的异步任务,task3对应点击发布触发的异步任务。


二、原因分析

用户每次点击tab切换都会触发一次网络请求(依次为task1 -> task2 -> task3),我们也期望的是以task1 -> task2 -> task3 的顺序响应给应用。但是因为是异步请求,由于网络状况的不可控和其他因素,接口的响应时间并不确定,也就是说。有可能后面触发的请求先于前面的请求得到了响应。

看如下的响应时间图:
图片

task1需要3s的时间响应,task2需要2s的时间响应,task1需要1s的时间响应,这时就出现了状态的竞争,因为你在task3响应时渲染的结果,很快就被task2的响应覆盖了,又很快被task1的响应覆盖了,此时接口响应的顺序为 task3->task2->task1,而此时你页面上最终需要展示的是task3的响应数据,但是task3的数据最终会被task1的响应的数据覆盖掉,这样就会导致页面数据错乱。

导致页面数据错乱的主要原因是获取数据是异步请求,异步请求的响应时间不确定,后面发出的请求有可能会把前面发出的请求返回快,出现了状态的竞态。

出现这样的问题改如何解决了?


三、解决方案

方案一:每个查询条件独立的存储数据,不共用数据源
  • 实现思路:不同的筛选条件存储不同的数据,不共用一份数据,设置一个对象存储数据,请求的参数作为key, 请求的结果作为value, 每次请求完后都将数据存入接收对象,使用时通过key去取数据。

  • 代码示例

import React,  {useState, useEffect} from 'react'
const Demo = () => { const [clientList, setClientList] = useState({}) // 列表数据 const [envTag, setEnvTag] = useState('') // 筛选条件 // 获取列表数据 const getClientList = () => { fetch(`xxxxxxx?envTag={envTag}`).then(res => { // 请求后端接口 setClientList({ ...clientList, [envTag]: data.items || [] // 通过key去存数据 }) }) } return ( <div> <div> {['全部', '测试', '预发布', '生产'].map((i, index) => ( <span onClick={getClientList}>{i}</span> ))} <div> <table dataSource={clientList[envTag]}></table> // 通过key去取数据渲染 </div> )}
    • 优点

    • 简单方便,适用于一些数量可控的tab切换和select下拉框一些数据量比较少的tab切换比较适用

    • 缺点

    • 需要提前设置好数据格式,需要一个唯一的key来标识不同的数据

    • 如果数据量比较多,会导致对象里面的属性比较多,不好维护


方案二:React hooks 和闭包,控制时序
  • 实现思路:请求之前设置一个时序,请求完之后判断当前时序是否与存储的时序一致,利用闭包保存上一次的变量值,请求之后判断当前时序是否与存储的时序是否一致。如果不一致直接return,不做任何处理
  • 代码示例
const Demo = () => {    const [key, setKey] = useState('')     const [clientList, setClientList] = useState({}) // 列表数据        useEffect(() => {        let = fetchId;        const currentId = fetchId;        // 发起一个网络请求        fetch({          key        }).then((res) => {          if (fetchId !== currentId) {            console.error('时序不一致不做任何处理');          } else {             console.log('正常请求')             setClientList(res.data)          }        });        // return出一个闭包        return () => {          fetchId += 1;        };  }, [key]);     return (        <div>            <div>                {['全部', '测试', '预发布', '生产'].map((i, index) => (                    <span onClick={getClientList}>{i}</span>                 ))}            <div>            <table dataSource={clientList}></table> // 通过key去取数据渲染        </div>        )}
  • 缺点:需要定义两个变量,需要对比两个变量

方案三:取消请求

在发起下一个请求时,将之前正在 pending 的请求的取消掉,只取最新的一个请求。

理想的结果如图所示:

图片

当task3请求发起时,task1、task2的请求状态还在pending中,那么取消掉之前的请求,只保留task3的请求。

  • 实现思路(以fetch请求为例子):
    fetch取消请求是利用了AbortController构造器,允许中止一个或多个fetch请求,
  • fetch取消请求的过程
    • 创建一个AbortController
    • 实列具有signal,将 signal 传递给 fetch option 的 signal
    • 调用 AbortController 的 abort 属性来取消所有使用该信号的 fetch。
  • 使用示例
  const Demo = () => {        const [clientList, setClientList] = useState([]) // 列表数据        const [envTag, setEnvTag] = useState('') // 筛选条件                 // 获取列表数据        let controller        const getClientList = () => {             if (controller) controller.abort()             controller = new AbortController()             fetch(`xxxxxxx?envTag={envTag}`, {signal: controller.signal }).then(res => {                  setClientList(items || [])                    controller = null             })                  }                return (              <div>             <div>                {['全部', '测试', '预发布', '生产'].map((i, index) => (                    <span onClick={getClientList}>{i}</span>                 ))}            <div>            <table dataSource={clientList}></table> // 通过key去取数据渲染        </div>        )    }
  • 在原生的 XHR 对象里方法为
XMLHttpRequest.abort()
  • 在 axios 里

cancelToken 的 API 提供完成

  • 缺点:取消请求不适用于需要每一个请求都对服务器产生效果,比如 POST
    这里的取消并不是真正意义上的中断请求,请求还是会正常发出,只是前端不会处理其响应而已。

每种方案的优缺点和适用场景比较
方案用key独立纯粹数据React Hook + 闭包取消请求
优点
简单方便
简单方便
适用场景广,不需要定义额外的变量
缺点
1.需要提前设置好数据格式,2.需要一个唯一的key来标识不同的数据,3.适用场景少
1.只能在react hook中使用 2. 需要维护两个变量
不同的请求方式需要用不同的取消方式
适用场景
适用于一些数量可控的tab切换和select下拉框一些数据量比较少的tab切换比较适用
在react技术栈中 所有场景都适用
不适用于需要每一个请求都对服务器产生效果,比如 POST


四、 项目实践/成果

结合我们具体项目情况,目前我们项目主要采用的是取消请求和react hooks 结合闭包去解决,取消请求效果如下图所示:

图片

react hook + 闭包解决方案效果图:
图片


五、总结

总结起来就是只要你多次频繁触发同一个动作,多次调用了同一个接口,这个时候就要考虑状态的竞态问题。解决竞态问题的主要思路时手动控制异步结果的顺序,来保证页面渲染的数据正常除了以上例子中的竞态问题,还有如下场景需要考虑竞态问题:

  • 页面上个按钮,随着用户的不断点击会不断的发出异步请求
  • 页面上有个下拉框,当用户频繁点击选择不同的下拉选项时,会不断的发出异步请求
  • 页面上有个input输入框,随着用户的输入会不断的异步发出请求

遇到以上的这些竞态问题时,我们也可以用防抖和节流来控制触发异步请求的频率,增强用户体验。关于异步请求的竞态问题,除了上述提到的一些方法外,我们还可以寻找更好更优雅的方式去处理这类情况,比如响应式编程、亦或者函数式编程中的 IO functor 等。对异步的掌控也许还需要我们了解 JavaScript 事件循环、任务队列等知识。


------ END ------
作者简介
同学: 开发工程师,目前负责容器云平台的研发工作。


也许您还想看:
技术分享|Java SDK动态数据源和上下文机制
技术分享|NodeJS分布式链路追踪实现

技术分享 | Java SDK 元数据驱动的事件通信架构

技术分享|Hangfire深度实践


更多明源云·天际开放平台场景案例与开发小知识,可以关注明源云天际PaaS公众号:
天际优秀开发者故事 | 第4期-严兆坤:撸起袖子加油干
明源云·天际建模平台V4.0产品发布会

👇关注👇

👇长按二维码进天际官方交流群👇

解锁

1.行业优质技术内容
2.行业技术经验交流
3.行业成功客户案例

图片


明源云·天际PaaS平台
加速不动产生态链数字化升级
点击左下角“阅读原文”免费咨询
open.mingyuanyun.com
天际技术实践 · 目录
上一篇技术实践 | 流程中心的AppCode到底是个啥?下一篇技术分享 | 集成开放平台API网关基于JsonSchema的入参校验
继续滑动看下一个
明源云天际PaaS平台
向上滑动看下一个