JavaScript 异步时序问题


Posted in Javascript onNovember 20, 2020

场景

死后我们必升天堂,因为活时我们已在地狱。

不知你是否遇到过,向后台发送了多次异步请求,结果最后显示的数据却并不正确 ? 是旧的数据。

具体情况:

  1. 用户触发事件,发送了第 1 次请求
  2. 用户触发事件,发送了第 2 次请求
  3. 第 2 次请求成功,更新页面上的数据
  4. 第 1 次请求成功,更新页面上的数据

嗯?是不是感觉到异常了?这便是多次异步请求时会遇到的异步回调顺序与调用顺序不同的问题。

思考

  • 为什么会出现这种问题?
  • 出现这种问题怎么解决?

为什么会出现这种问题?

JavaScript 随处可见异步,但实际上并不是那么好控制。用户与 UI 交互,触发事件及其对应的处理函数,函数执行异步操作(网络请求),异步操作得到结果的时间(顺序)是不确定的,所以响应到 UI 上的时间就不确定,如果触发事件的频率较高/异步操作的时间过长,就会造成前面的异步操作结果覆盖后面的异步操作结果。

关键点

  • 异步操作得到结果的时间(顺序)是不确定的
  • 如果触发事件的频率较高/异步操作的时间过长

出现这种问题怎么解决?

既然关键点由两个要素组成,那么,只要破坏了任意一个即可。

  • 手动控制异步返回结果的顺序
  • 降低触发频率并限制异步超时时间

手动控制返回结果的顺序

根据对异步操作结果处理情况的不同也有三种不同的思路

  1. 后面异步操作得到结果后等待前面的异步操作返回结果
  2. 后面异步操作得到结果后放弃前面的异步操作返回结果
  3. 依次处理每一个异步操作,等待上一个异步操作完成之后再执行下一个

这里先引入一个公共的 wait 函数

/**
 * 等待指定的时间/等待指定表达式成立
 * 如果未指定等待条件则立刻执行
 * 注: 此实现在 nodejs 10- 会存在宏任务与微任务的问题,切记 async-await 本质上还是 Promise 的语法糖,实际上并非真正的同步函数!!!即便在浏览器,也不要依赖于这种特性。
 * @param param 等待时间/等待条件
 * @returns Promise 对象
 */
function wait(param) {
 return new Promise(resolve => {
 if (typeof param === 'number') {
 setTimeout(resolve, param)
 } else if (typeof param === 'function') {
 const timer = setInterval(() => {
 if (param()) {
 clearInterval(timer)
 resolve()
 }
 }, 100)
 } else {
 resolve()
 }
 })
}

1. 后面异步操作得到结果后等待前面的异步操作返回结果

  1. 为每一次的异步调用都声称一个唯一 id
  2. 使用列表记录所有的异步 id
  3. 在真正调用异步操作后,添加一个唯一 id
  4. 判断上一个正在执行的异步操作是否完成
  5. 如果未完成等待上一个异步操作完成,否则直接跳过
  6. 从列表中删除掉当前的 id
  7. 最后等待异步操作然后返回结果
/**
 * 将一个异步函数包装为具有时序的异步函数
 * 注: 该函数会按照调用顺序依次返回结果,后面的调用的结果需要等待前面的,所以如果不关心过时的结果,请使用 {@link switchMap} 函数
 * @param fn 一个普通的异步函数
 * @returns 包装后的函数
 */
function mergeMap(fn) {
 // 当前执行的异步操作 id
 let id = 0
 // 所执行的异步操作 id 列表
 const ids = new Set()
 return new Proxy(fn, {
 async apply(_, _this, args) {
 const prom = Reflect.apply(_, _this, args)
 const temp = id
 ids.add(temp)
 id++
 await wait(() => !ids.has(temp - 1))
 ids.delete(temp)
 return await prom
 },
 })
}

测试一下

;(async () => {
 // 模拟一个异步请求,接受参数并返回它,然后等待指定的时间
 async function get(ms) {
 await wait(ms)
 return ms
 }
 const fn = mergeMap(get)
 let last = 0
 let sum = 0
 await Promise.all([
 fn(30).then(res => {
 last = res
 sum += res
 }),
 fn(20).then(res => {
 last = res
 sum += res
 }),
 fn(10).then(res => {
 last = res
 sum += res
 }),
 ])
 console.log(last)
 // 实际上确实执行了 3 次,结果也确实为 3 次调用参数之和
 console.log(sum)
})()

2. 后面异步操作得到结果后放弃前面的异步操作返回结果

  • 为每一次的异步调用都声称一个唯一 id
  • 记录最新得到异步操作结果的 id
  • 记录最新得到的异步操作结果
  • 执行并等待返回结果
  • 判断本次异步调用后面是否已经有调用出现结果了

                   是的话就直接返回后面的异步调用结果
                   否则将本地异步调用 id 及其结果最为[最后的]
                   返回这次的异步调用结果

/**
 * 将一个异步函数包装为具有时序的异步函数
 * 注: 该函数会丢弃过期的异步操作结果,这样的话性能会稍稍提高(主要是响应比较快的结果会立刻生效而不必等待前面的响应结果)
 * @param fn 一个普通的异步函数
 * @returns 包装后的函数
 */
function switchMap(fn) {
 // 当前执行的异步操作 id
 let id = 0
 // 最后一次异步操作的 id,小于这个的操作结果会被丢弃
 let last = 0
 // 缓存最后一次异步操作的结果
 let cache
 return new Proxy(fn, {
 async apply(_, _this, args) {
 const temp = id
 id++
 const res = await Reflect.apply(_, _this, args)
 if (temp < last) {
 return cache
 }
 cache = res
 last = temp
 return res
 },
 })
}

测试一下

;(async () => {
 // 模拟一个异步请求,接受参数并返回它,然后等待指定的时间
 async function get(ms) {
 await wait(ms)
 return ms
 }
 const fn = switchMap(get)
 let last = 0
 let sum = 0
 await Promise.all([
 fn(30).then(res => {
 last = res
 sum += res
 }),
 fn(20).then(res => {
 last = res
 sum += res
 }),
 fn(10).then(res => {
 last = res
 sum += res
 }),
 ])
 console.log(last)
 // 实际上确实执行了 3 次,然而结果并不是 3 次调用参数之和,因为前两次的结果均被抛弃,实际上返回了最后一次发送请求的结果
 console.log(sum)
})()

3. 依次处理每一个异步操作,等待上一个异步操作完成之后再执行下一个

  1. 为每一次的异步调用都声称一个唯一 id
  2. 使用列表记录所有的异步 id
  3. 向列表中添加一个唯一 id
  4. 判断上一个正在执行的异步操作是否完成
  5. 如果未完成等待上一个异步操作完成,否则直接跳过
  6. 真正调用异步操作
  7. 从列表中删除掉当前的 id
  8. 最后等待异步操作然后返回结果
/**
 * 将一个异步函数包装为具有时序的异步函数
 * 注: 该函数会按照调用顺序依次返回结果,后面的执行的调用(不是调用结果)需要等待前面的,此函数适用于异步函数的内里执行也必须保证顺序时使用,否则请使用 {@link mergeMap} 函数
 * 注: 该函数其实相当于调用 {@code asyncLimiting(fn, {limit: 1})} 函数
 * 例如即时保存文档到服务器,当然要等待上一次的请求结束才能请求下一次,不然数据库保存的数据就存在谬误了
 * @param fn 一个普通的异步函数
 * @returns 包装后的函数
 */
function concatMap(fn) {
 // 当前执行的异步操作 id
 let id = 0
 // 所执行的异步操作 id 列表
 const ids = new Set()
 return new Proxy(fn, {
 async apply(_, _this, args) {
 const temp = id
 ids.add(temp)
 id++
 await wait(() => !ids.has(temp - 1))
 const prom = Reflect.apply(_, _this, args)
 ids.delete(temp)
 return await prom
 },
 })
}

测试一下

;(async () => {
 // 模拟一个异步请求,接受参数并返回它,然后等待指定的时间
 async function get(ms) {
 await wait(ms)
 return ms
 }
 const fn = concatMap(get)
 let last = 0
 let sum = 0
 await Promise.all([
 fn(30).then(res => {
 last = res
 sum += res
 }),
 fn(20).then(res => {
 last = res
 sum += res
 }),
 fn(10).then(res => {
 last = res
 sum += res
 }),
 ])
 console.log(last)
 // 实际上确实执行了 3 次,然而结果并不是 3 次调用参数之和,因为前两次的结果均被抛弃,实际上返回了最后一次发送请求的结果
 console.log(sum)
})()

小结

虽然三个函数看似效果都差不多,但还是有所不同的。

  1. 是否允许异步操作并发?否: concatMap, 是: 到下一步
  2. 是否需要处理旧的的结果?否: switchMap, 是: mergeMap

降低触发频率并限制异步超时时间

思考一下第二种解决方式,本质上其实是 限流 + 自动超时,首先实现这两个函数。

  • 限流: 限制函数调用的频率,如果调用的频率过快则不会真正执行调用而是返回旧值
  • 自动超时: 如果到了超时时间,即便函数还未得到结果,也会自动超时并抛出错误

下面来分别实现它们

限流实现

具体实现思路可见: JavaScript 防抖和节流

/**
 * 函数节流
 * 节流 (throttle) 让一个函数不要执行的太频繁,减少执行过快的调用,叫节流
 * 类似于上面而又不同于上面的函数去抖, 包装后函数在上一次操作执行过去了最小间隔时间后会直接执行, 否则会忽略该次操作
 * 与上面函数去抖的明显区别在连续操作时会按照最小间隔时间循环执行操作, 而非仅执行最后一次操作
 * 注: 该函数第一次调用一定会执行,不需要担心第一次拿不到缓存值,后面的连续调用都会拿到上一次的缓存值
 * 注: 返回函数结果的高阶函数需要使用 {@link Proxy} 实现,以避免原函数原型链上的信息丢失
 *
 * @param {Number} delay 最小间隔时间,单位为 ms
 * @param {Function} action 真正需要执行的操作
 * @return {Function} 包装后有节流功能的函数。该函数是异步的,与需要包装的函数 {@link action} 是否异步没有太大关联
 */
const throttle = (delay, action) => {
 let last = 0
 let result
 return new Proxy(action, {
 apply(target, thisArg, args) {
 return new Promise(resolve => {
 const curr = Date.now()
 if (curr - last > delay) {
 result = Reflect.apply(target, thisArg, args)
 last = curr
 resolve(result)
 return
 }
 resolve(result)
 })
 },
 })
}

自动超时

注: asyncTimeout 函数实际上只是为了避免一种情况,异步请求时间超过节流函数最小间隔时间导致结果返回顺序错乱。

/**
 * 为异步函数添加自动超时功能
 * @param timeout 超时时间
 * @param action 异步函数
 * @returns 包装后的异步函数
 */
function asyncTimeout(timeout, action) {
 return new Proxy(action, {
 apply(_, _this, args) {
 return Promise.race([
 Reflect.apply(_, _this, args),
 wait(timeout).then(Promise.reject),
 ])
 },
 })
}

结合使用

测试一下

;(async () => {
 // 模拟一个异步请求,接受参数并返回它,然后等待指定的时间
 async function get(ms) {
 await wait(ms)
 return ms
 }
 const time = 100
 const fn = asyncTimeout(time, throttle(time, get))
 let last = 0
 let sum = 0
 await Promise.all([
 fn(30).then(res => {
 last = res
 sum += res
 }),
 fn(20).then(res => {
 last = res
 sum += res
 }),
 fn(10).then(res => {
 last = res
 sum += res
 }),
 ])
 // last 结果为 10,和 switchMap 的不同点在于会保留最小间隔期间的第一次,而抛弃掉后面的异步结果,和 switchMap 正好相反!
 console.log(last)
 // 实际上确实执行了 3 次,结果也确实为第一次次调用参数的 3 倍
 console.log(sum)
})()

起初吾辈因为好奇实现了这种方式,但原以为会和 concatMap 类似的函数却变成了现在这样 ? 更像倒置的 switchMap 了。不过由此看来这种方式的可行性并不大,毕竟,没人需要旧的数据。

总结

其实第一种实现方式属于 rxjs 早就已经走过的道路,目前被 Angular 大量采用(类比于 React 中的 Redux)。但 rxjs 实在太强大也太复杂了,对于吾辈而言,仅仅需要一只香蕉,而不需要拿着香蕉的大猩猩,以及其所处的整个森林(此处原本是被人吐槽面向对象编程的隐含环境,这里吾辈稍微藉此吐槽一下动不动就上库的开发者)。

可以看到吾辈在这里大量使用了 Proxy,那么,原因是什么呢?这个疑问就留到下次再说吧!

以上就是JavaScript 异步时序问题的详细内容,更多关于JavaScript 异步时序的资料请关注三水点靠木其它相关文章!

Javascript 相关文章推荐
Javascript 网页黑白效果实现代码(兼容IE/FF等)
Apr 23 Javascript
javaScript(JS)替换节点实现思路介绍
Apr 17 Javascript
jquery进行数组遍历如何跳出当前的each循环
Jun 05 Javascript
jQuery使用fadeout实现元素渐隐效果的方法
Mar 27 Javascript
JavaScript计划任务后台运行的方法
Dec 18 Javascript
jQuery实现的分子运动小球碰撞效果
Jan 27 Javascript
jQuery之动画效果大全
Nov 09 Javascript
教大家轻松制作Bootstrap漂亮表格(table)
Dec 13 Javascript
Bootstrap 模态框(Modal)插件代码解析
Dec 21 Javascript
基于jQuery实现的Ajax 验证用户名唯一性实例代码
Jun 28 jQuery
angular动态表单制作
Feb 23 Javascript
基于vue-simple-uploader封装文件分片上传、秒传及断点续传的全局上传插件功能
Feb 23 Vue.js
JavaScript实现音乐导航效果
Nov 19 #Javascript
JavaScript实现无限轮播效果
Nov 19 #Javascript
微信小程序实现分页加载效果
Nov 19 #Javascript
vue-drawer-layout实现手势滑出菜单栏
Nov 19 #Vue.js
H5 js点击按钮复制文本到粘贴板
Nov 19 #Javascript
JS数据类型分类及常用判断方法
Nov 19 #Javascript
JavaScript构造函数原理及实现流程解析
Nov 19 #Javascript
You might like
yii2使用ajax返回json的实现方法
2016/05/14 PHP
javascript 拖放效果实现代码
2010/01/22 Javascript
Visual Studio中的jQuery智能提示设置方法
2010/03/27 Javascript
jQuery使用一个按钮控制图片的伸缩实现思路
2013/04/19 Javascript
artDialog双击会关闭对话框的修改过程分享
2013/08/05 Javascript
jquery的trigger和triggerHandler的区别示例介绍
2014/04/20 Javascript
js取整数、取余数的方法
2014/05/11 Javascript
javascript学习指南之回调问题
2016/04/23 Javascript
AngularJS入门教程之服务(Service)
2016/07/27 Javascript
AngularGauge 属性解析详解
2016/09/06 Javascript
javascript中json基础知识详解
2017/01/19 Javascript
原生js封装自定义滚动条
2017/03/24 Javascript
TypeScript入门-基本数据类型
2017/03/28 Javascript
纯js实现隔行变色效果
2017/11/29 Javascript
Angular4 组件通讯方法大全(推荐)
2018/07/12 Javascript
小程序视频或音频自定义可拖拽进度条的示例代码
2018/09/30 Javascript
Vue路由之JWT身份认证的实现方法
2019/08/26 Javascript
vue项目中引入Sass实例方法
2019/08/27 Javascript
Node.js实现简单管理系统
2019/09/23 Javascript
JSONP 的原理、理解 与 实例分析
2020/05/16 Javascript
vue深度监听(监听对象和数组的改变)与立即执行监听实例
2020/09/04 Javascript
javascript实现左右缓动动画函数
2020/11/25 Javascript
python删除某个字符
2018/03/19 Python
Python3.遍历某文件夹提取特定文件名的实例
2018/04/26 Python
详解pandas数据合并与重塑(pd.concat篇)
2019/07/09 Python
python Tensor和Array对比分析
2020/01/08 Python
详解python UDP 编程
2020/08/24 Python
HTML5中input[type='date']自定义样式与日历校验功能的实现代码
2017/07/11 HTML / CSS
ROSEFIELD手表荷兰官方网上商店:北欧极简设计女士腕表品牌
2018/01/24 全球购物
英国奢侈品在线精品店:Hervia
2020/09/03 全球购物
星空联盟C# .net笔试题
2014/12/05 面试题
妇女儿童发展规划实施方案
2014/03/16 职场文书
民族团结演讲稿范文
2014/08/27 职场文书
会议简报格式范文
2015/07/20 职场文书
2015年秋季校长开学典礼致辞
2015/07/29 职场文书
《合作意向书》怎么写?
2019/08/20 职场文书