由一道面试题目引发的思考(Promise)

837 阅读6分钟

原题如下:

页面上由三个按钮,分别为 A、B、C,点击各个按钮都会发送异步请求且互不影响,每次请求回来的数据都为按钮的名字。请实现当用户依次点击 A、B、C、A、C、B 的时候,最终获取的数据为 A、B、C、A、C、B。

题目分析

拿到题目时,看到“异步请求”就知道肯定跟回调函数有关,而处理异步回调的终极解决方案肯定是 Promise API 无疑。再往下看,从“当用户依次点击 A、B、C、A、C、B 的时候,最终获取的数据为 A、B、C、A、C、B。”这句话才能读出这道题的考察点,也就是考验答者对 Promise API 的熟练度以及对异步事件的理解深度。

首先我们可以肯定的是,当用户点击按钮时,发出异步请求与收到服务器返回的数据之间的时间差是不可预知的,所以用户点击按钮的顺序与收到全部数据的顺序是没有强关联的。

其次,题目中明确提到各个按钮发出的请求且互不影响,也就是说不论点击哪个按钮,发出的请求不管有没有收到回复或返回错误,都与其他按钮发出的请求无关。

但是题目中要求点击按钮发出请求的顺序与收到数据的顺序一致,所以这道题用 Promise.all() 静态方法肯定不合适,因为我们无法一次性将所有请求收集起来,而且只要其中一个请求被拒绝我们只能取到被拒绝的结果。

显然,我们需要模仿队列的行为,以此保证当前面有请求未收到响应(不管成功还是失败)时,后面的所有请求结果都需要待定,直到前面没有请求正在进行时,再将结果取出使用。

解题思路

那么我们如何使用 Promise API 来保证请求结果如同队列的行为呢?首先我们可以尝试自定义一个拥有该功能的函数,假定名为 asyncQueue(),当我们将请求 request(Promise 实例)作为 asyncQueue() 函数的参数执行时,我们期待它也返回一个包含结果的新的 Promise 实例:

const asyncQueue = (() => {
  const queue = [] // 内部维护一个数组,用于存放请求结果

  return function (request) {
    return new Promise((resolve, reject) => {
      // 其他操作

      request.then((res) => resolve(res)).catch((err) => reject(err))
    })
  }
})()

在 IIFE(立即执行函数)里,首先我们创建了一个数组用以存放请求结果,然后返回了一个新函数,通过闭包它可以访问到这个数组,如此避免了污染全局变量的风险。

紧接着,我们在这个函数中返回了一个新的 Promise 实例,当传入的 request 被处理时,通过它的 then()catch() 方法,将结果传入新实例的处理函数(resolve()reject())并执行。

如此一来,我们通过嵌套新的 Promise 实例,把原本请求结果的处理权转移到该新实例中,于是我们就可以决定什么时候改变该实例的状态,将结果传递出去。

const asyncQueue = (() => {
  const queue = [] // 内部维护一个数组,用于存放执行处理器函数的回调

  return function (promise) {
    const index = queue.length++ // 保存函数执行时 queue 长度,再更新

    return new Promise((resolve, reject) => {

      // 检查前面是否还有处于 pending 状态的 promise
      const checkIsLastOne = (callback) => {
        // 当请求 promise 处于 settled 状态时触发

        if (queue.length === 0) {
          // 如果前面没有处于 pending 状态的 promise,直接执行回调,退出函数
          return callback()
        }

        // 前面还有处于 pending 状态的 promise,替换索引为 index 的 undefined 值为传入的回调
        queue.splice(index, 1, callback)

        // 依次遍历 queue,执行所有的回调,遇到 undefined 值退出循环
        let loop = 0
        while ((fn = queue[loop++])) fn()

        if (loop === queue.length + 1) {
          // 当最后一个 promise 处于 settled 状态时,表示所有的请求都已被处理完,此时清空 queue
          queue.length = 0
        }
      }

      promise
        .then((res) => checkIsLastOne(() => resolve(res)))
        .catch((err) => checkIsLastOne(() => reject(err)))
    })
  }
})()

当我们执行 asyncQueue() 函数时,函数内部会自主判断前面是否有未完成的请求,如果有就等待前面所有请求执行处理完毕,再将当前请求结果传递出去。

要做到这一点,关键就在于我们需要在 asyncQueue() 被执行时,保存此时 queue 数组的长度为 index,再更新数组长度。当 request 处理 settled 状态时,将结果取出暂时保存,并将其作为新 Promise 实例处理函数参数,再包裹为回调函数传入 checkIsLastOne() 函数中。

checkIsLastOne() 函数中,我们通过获取此时 queue 数组长度是否等于 0 来判断前面是否有执行中的请求。如果有,就将传入的回调函数与queue 数组中 index 索引指向的元素(undefined)替换。

此时我们并不知道之前的请求是否被处理,所以遍历 queue 中的元素,依次执行这些函数,当遇到元素值为 undefine 时(表示之前的请求正在执行)停止循环,如果遍历完所有的元素(所有的请求都被处理),将 queue 数组清空。

我们试试使用 asyncQueue() 函数解答该题:

<button onclick="handleClick('A', 1000)">A</button>
<button onclick="handleClick('B', 1000)">B</button>
<button onclick="handleClick('C', 2000)">C</button>
const request = (name, delay) => {
  // 模拟请求
  return new Promise((resolve, reject) => {
    setTimeout(() => resolve(name), delay)
  })
}

const handleClick = (name, delay) => {
  asyncQueue(request(name, delay)).then(res => console.log(res))
}

依次点击按钮 A、B、C、A、C、B 的结果如下:

Test

显然,asyncQueue() 函数已经能够满足题目要求了。

Promise.asyncQueue()

有了 asyncQueue() 函数,再遇到类似类似题目中需要处理异步结果顺序与发出请求的顺序一致这样的需求,基本可以迎刃而解了。

如果业务中需要大量使用到该方法,我们可以将它纳入全局 Promise 对象的静态方法来使用:

if (!Promise.asyncQueue) {
  Promise.asyncQueue = (() => {
    const queue = [] // 内部维护一个数组,用于存放执行处理器函数的回调

    return function () {
      if (arguments.length === 0) {
        // 必须传入一个参数
        throw new TypeError('1 argument required, but only 0 present.')
      }

      let promise = arguments[0]

      if (!/Promise/.test(Object.prototype.toString.call(promise))) {
        // 判断传入参数是否是 promise,如果不是默认用 Promise.resolve()处理
        promise = Promise.resolve(promise)
      }

      const index = queue.length++ // 保存函数执行时 queue 长度,再更新

      return new Promise((resolve, reject) => {

        // 检查前面是否还有处于 pending 状态的 promise
        const checkIsLastOne = (callback) => {
          // 当请求 promise 处于 settled 状态时触发

          if (queue.length === 0) {
            // 如果前面没有处于 pending 状态的 promise,直接执行回调,退出函数
            return callback()
          }

          // 前面还有处于 pending 状态的 promise,替换索引为 index 的 undefined 值为传入的回调
          queue.splice(index, 1, callback)

          // 依次遍历 queue,执行所有的回调,遇到 undefined 值退出循环
          let loop = 0
          while ((fn = queue[loop++])) fn()

          if (loop === queue.length + 1) {
            // 当最后一个 promise 处于 settled 状态时,表示所有的请求都已被处理完,此时清空 queue
            queue.length = 0
          }
        }

        promise
          .then((res) => checkIsLastOne(() => resolve(res)))
          .catch((err) => checkIsLastOne(() => reject(err)))
      })
    }
  })()
}

Promise.asyncQueue() 静态方法必须传入一个参数,如果该参数不是 Promise 类型,默认使用 Promise.resolve() 处理。

之后我们就可以这样使用:

Promise.asyncQueue(asyncFn)
  .then((res) => console.log(res))
  .catch((err) => console.log(err))

小结

本文主要从一道面试题展开分析和思考,解决了如何将异步事件调用顺序与处理它们结果的函数调用顺序一致的问题。

以上就是本文的全部内容,本人水平有限,如果错误或有争议的地方,请指出。

最后,感谢阅读!欢迎交流分享!