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 使用手册(五)
Sep 23 Javascript
JavaScript QueryString解析类代码
Jan 17 Javascript
js中根据字数截取字符串,不能截断url
Jan 12 Javascript
JS代码判断IE6,IE7,IE8,IE9的函数代码
Aug 02 Javascript
简单实用的反馈表单无刷新提交带验证
Nov 15 Javascript
使用JS实现气泡跟随鼠标移动的动画效果
Sep 16 Javascript
解决vue-cli webpack打包后加载资源的路径问题
Sep 25 Javascript
详解js模板引擎art template数组渲染的方法
Oct 09 Javascript
vue中选项卡点击切换且能滑动切换功能的实现代码
Nov 25 Javascript
前后端常见的几种鉴权方式(小结)
Aug 04 Javascript
Javascript摸拟自由落体与上抛运动原理与实现方法详解
Apr 08 Javascript
vue实现给div绑定keyup的enter事件
Jul 31 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
在服务端进行目录建立、删除,文件上传、删除的过程的php代码
2008/09/10 PHP
php过滤html标记属性类用法实例
2014/09/23 PHP
PHP实现绘制3D扇形统计图及图片缩放实例
2014/10/01 PHP
jQuery+PHP发布的内容进行无刷新分页(Fckeditor)
2015/10/22 PHP
详解php设置session(过期、失效、有效期)
2015/11/12 PHP
利用PHP自动生成印有用户信息的名片
2016/08/01 PHP
PHP基于IMAP收取邮件的方法示例
2017/08/07 PHP
js判断变量是否未定义的代码
2020/03/28 Javascript
我的Node.js学习之路(四)--单元测试
2014/07/06 Javascript
原生js编写基于面向对象的分页组件
2016/12/05 Javascript
javascript实现table单元格点击展开隐藏效果(实例代码)
2017/04/10 Javascript
原生JS+Canvas实现五子棋游戏实例
2017/06/19 Javascript
用Vue.extend构建消息提示组件的方法实例
2017/08/08 Javascript
vue项目中mock.js的使用及基本用法
2019/05/22 Javascript
js实现拖动缓动效果
2020/01/13 Javascript
Vue组件为什么data必须是一个函数
2020/06/11 Javascript
Vue 电商后台管理项目阶段性总结(推荐)
2020/08/22 Javascript
[05:26]TI10典藏宝瓶套装外观展示
2020/07/03 DOTA
Python爬取国外天气预报网站的方法
2015/07/10 Python
python获取网页中所有图片并筛选指定分辨率的方法
2018/03/31 Python
Django admin禁用编辑链接和添加删除操作详解
2019/11/15 Python
Html5页面上如何禁止手机虚拟键盘弹出
2020/03/19 HTML / CSS
加拿大百叶窗和窗帘定制网站:Blinds
2017/01/30 全球购物
Clarks其乐鞋荷兰官网:Clarks荷兰
2019/07/05 全球购物
Java中会存在内存泄漏吗,请简单描述
2016/12/22 面试题
大学生职业生涯规划范文
2013/12/31 职场文书
感恩节活动策划方案
2014/05/16 职场文书
乡镇精神文明建设汇报材料
2014/08/15 职场文书
县政府班子个人对照检查材料
2014/10/05 职场文书
大学生实训报告总结
2014/11/05 职场文书
2015年圣诞节活动总结
2015/03/24 职场文书
2015年少先队活动总结
2015/03/25 职场文书
感恩教师节主题班会
2015/08/12 职场文书
教师学期述职自我鉴定
2019/08/16 职场文书
Java生成日期时间存入Mysql数据库的实现方法
2022/03/03 Java/Android
PYTHON 使用 Pandas 删除某列指定值所在的行
2022/04/28 Python