JavaScript 异步时序问题

JavaScript 异步时序问题

吾辈的博客原文: https://blog.rxliuli.com/p/de...

场景

死后咱们必升天堂,由于活时咱们已在地狱。

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

具体状况:git

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

嗯?是否是感受到异常了?这即是屡次异步请求时会遇到的异步回调顺序与调用顺序不一样的问题。github

思考

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

为何会出现这种问题?

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. 后面异步操做获得结果后等待前面的异步操做返回结果

/**
 * 将一个异步函数包装为具备时序的异步函数
 * 注: 该函数会按照调用顺序依次返回结果,后面的调用的结果须要等待前面的,因此若是不关心过期的结果,请使用 {@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. 后面异步操做获得结果后放弃前面的异步操做返回结果

/**
 * 将一个异步函数包装为具备时序的异步函数
 * 注: 该函数会丢弃过时的异步操做结果,这样的话性能会稍稍提升(主要是响应比较快的结果会马上生效而没必要等待前面的响应结果)
 * @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. 依次处理每个异步操做,等待上一个异步操做完成以后再执行下一个

/**
 * 将一个异步函数包装为具备时序的异步函数
 * 注: 该函数会按照调用顺序依次返回结果,后面的执行的调用(不是调用结果)须要等待前面的,此函数适用于异步函数的内里执行也必须保证顺序时使用,不然请使用 {@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 () => {
  let last = 0
  let sum = 0
  // 模拟一个异步请求,接受参数并返回它,而后等待指定的时间
  async function get(ms) {
    await wait(ms)
    return ms
  }
  const time = 100
  const fn = asyncTimeout(time, throttle(time, get))
  await Promise.all([
    fn(30).then(res => {
      console.log(res, last, sum)
      last = res
      sum += res
    }),
    fn(20).then(res => {
      console.log(res, last, sum)
      last = res
      sum += res
    }),
    fn(10).then(res => {
      console.log(res, last, sum)
      last = res
      sum += res
    }),
  ])
  // last 结果为 10,和 switchMap 的不一样点在于会保留最小间隔期间的第一次,而抛弃掉后面的异步结果,和 switchMap 正好相反!
  console.log(last)
  // 实际上确实执行了 3 次,结果也确实为第一次次调用参数的 3 倍
  console.log(sum)
})()

起初吾辈由于好奇实现了这种方式,但原觉得会和 concatMap 相似的函数却变成了如今这样 -- 更像倒置的 switchMap 了。不过由此看来这种方式的可行性并不大,毕竟,没人须要旧的数据。

总结

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

能够看到吾辈在这里大量使用了 Proxy,那么,缘由是什么呢?这个疑问就留到下次再说吧!
相关文章
相关标签/搜索