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 相关文章推荐
转自Jquery官方 jQuery1.1.3发布,速度提升800%,体积保持20K
Aug 19 Javascript
JS中使用Array函数shift和pop创建可忽略参数的例子
May 28 Javascript
javascript中Object使用详解
Jan 26 Javascript
详解javascript中的事件处理
Nov 06 Javascript
超链接怎么正确调用javascript函数
May 23 Javascript
VueJs组件prop验证简单介绍
Sep 12 Javascript
JQueryDOM之样式操作
Mar 27 jQuery
vue-i18n结合Element-ui的配置方法
May 20 Javascript
vue实现绑定事件的方法实例代码详解
Jun 20 Javascript
解决layui调用自定义方法提示未定义的问题
Sep 14 Javascript
bootstrap table实现iview固定列的效果实例代码详解
Sep 30 Javascript
JS对象属性的检测与获取操作实例分析
Mar 17 Javascript
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
thinkphp备份数据库的方法分享
2015/01/04 PHP
Symfony2学习笔记之控制器用法详解
2016/03/17 PHP
PHP 7.1新特性的汇总介绍
2016/12/16 PHP
基于win2003虚拟机中apache服务器的访问
2017/08/01 PHP
Linux下安装Memcached服务器和客户端与PHP使用示例
2019/04/15 PHP
一个可以增加和删除行的table并可编辑表格中内容
2014/06/16 Javascript
javascript动画算法实例分析
2015/07/31 Javascript
jquery对复选框(checkbox)的操作汇总
2016/01/13 Javascript
jQuery实现微信长按识别二维码功能
2016/08/26 Javascript
更靠谱的H5横竖屏检测方法(js代码)
2016/09/13 Javascript
原生js实现弹出层登录拖拽功能
2016/12/05 Javascript
微信小程序 WebSocket详解及应用
2017/01/21 Javascript
解决VUEX刷新的时候出现数据消失
2017/07/03 Javascript
Grunt针对静态文件的压缩,版本控制打包的实例讲解
2017/09/29 Javascript
js自定义trim函数实现删除两端空格功能
2018/02/09 Javascript
vue中动态添加class类名的方法
2018/09/05 Javascript
使用js在layui中实现上传图片压缩
2019/06/18 Javascript
原生javascript实现类似vue的数据绑定功能示例【观察者模式】
2020/02/24 Javascript
python语言使用技巧分享
2016/05/31 Python
Python内置数据结构与操作符的练习题集锦
2016/07/01 Python
Python函数式编程
2017/07/20 Python
python实现两张图片拼接为一张图片并保存
2019/07/16 Python
Django 外键的使用方法详解
2019/07/19 Python
keras中的History对象用法
2020/06/19 Python
opencv 图像礼帽和图像黑帽的实现
2020/07/07 Python
python subprocess pipe 实时输出日志的操作
2020/12/05 Python
CSS中的字体大小设置属性总结
2016/05/24 HTML / CSS
HTML5实现桌面通知 提示功能
2017/10/11 HTML / CSS
找到您丢失的钥匙、钱包和手机:Tile
2017/05/19 全球购物
轻松制作精彩视频:Animoto
2018/09/19 全球购物
澳大利亚运动鞋零售商:The Athlete’s Foot
2018/11/04 全球购物
四风问题自查报告剖析材料
2014/02/08 职场文书
文案策划求职信
2014/04/14 职场文书
借钱欠条怎么写
2015/07/03 职场文书
Python使用openpyxl模块处理Excel文件
2022/06/05 Python
Python实现数据的序列化操作详解
2022/07/07 Python