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 轻松支持函数重载 (Part 1 - 设计)
Aug 04 Javascript
在新窗口打开超链接的方法小结
Apr 14 Javascript
jquery操作复选框(checkbox)的12个小技巧总结
Feb 04 Javascript
JS图片无缝、平滑滚动代码
Mar 11 Javascript
学习使用grunt来打包JavaScript和CSS程序的教程
Jan 04 Javascript
省市联动效果的简单实现代码(推荐)
Jun 06 Javascript
JS操作JSON方法总结(推荐)
Jun 14 Javascript
Bootstrap中的表单验证插件bootstrapValidator使用方法整理(推荐)
Jun 21 Javascript
Bootstrap的modal拖动效果
Dec 25 Javascript
vue内置指令详解
Apr 03 Javascript
深入解析koa之异步回调处理
Jun 17 Javascript
JavaScript Window窗口对象属性和使用方法
Jan 19 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
3
2006/10/09 PHP
php中使用parse_url()对网址进行解析的实现代码(parse_url详解)
2012/01/03 PHP
解析PHP高效率写法(详解原因)
2013/06/20 PHP
PHP自动识别字符集并完成转码详解
2013/08/02 PHP
php使用ftp实现文件上传与下载功能
2017/07/21 PHP
javascript 拖放效果实现代码
2010/01/22 Javascript
jQuery对于显示和隐藏等常用状态的判断方法
2014/12/13 Javascript
jQuery源码分析之jQuery.fn.each与jQuery.each用法
2015/01/23 Javascript
JQuery的ON()方法支持的所有事件罗列
2015/02/28 Javascript
原生js实现弹出层登录拖拽功能
2016/12/05 Javascript
easyUI实现类似搜索框关键词自动提示功能示例代码
2016/12/27 Javascript
JS实现键值对遍历json数组功能示例
2018/05/30 Javascript
原生JS封装_new函数实现new关键字的功能
2018/08/12 Javascript
茶余饭后聊聊Vue3.0响应式数据那些事儿
2019/10/30 Javascript
Js代码中的span拼接问题解决
2019/11/22 Javascript
小程序跨页面交互的作用与方法详解
2020/01/07 Javascript
Vue + Element-ui的下拉框el-select获取额外参数详解
2020/08/14 Javascript
Python 命令行参数sys.argv
2008/09/06 Python
Python编程中对文件和存储器的读写示例
2016/01/25 Python
Python中字符串的处理技巧分享
2016/09/17 Python
Python实现获取命令行输出结果的方法
2017/06/10 Python
使用Python的Django和layim实现即时通讯的方法
2018/05/25 Python
python3 深浅copy对比详解
2019/08/12 Python
基于plt.title无法显示中文的快速解决
2020/05/16 Python
Python爬虫开发与项目实战
2020/12/16 Python
老生常谈CSS中的长度单位
2016/06/27 HTML / CSS
俄罗斯最大的在线珠宝大卖场:Nebo
2019/12/08 全球购物
C语言如何决定使用那种整数类型
2016/11/26 面试题
介绍一下OSI七层模型
2012/07/03 面试题
设计部经理的岗位职责
2013/11/16 职场文书
实习心得体会
2014/01/02 职场文书
党员干部反四风对照检查材料思想汇报
2014/09/14 职场文书
2014年煤矿工作总结
2014/11/24 职场文书
团委工作总结2015
2015/04/02 职场文书
学校清洁工岗位职责
2015/04/15 职场文书
小学2016年第十八届推普周活动总结
2016/04/05 职场文书