JavaScript 实现并发请求

185 阅读8分钟

并发请求

前言

并发请求 在我们日常开发中可以作为一个请求优化的方案,也是一道 高频的面试题

在有些时候,我们的站点上有一些请求可能会产生很多,比方说大文件上传中的切片上传,会把很多切块进行多次上传请求。或者说是做一些数据的抓取时,都会产生大量的请求。

当请求很多的时候,如果说我们等一个请求完成之后再进行下一个请求,那么这个效率就比较低了。

所以,我们可能会同时发出多个请求,但是又不能发的太多。因为我们的网络信道也是有资源限制的。

因此,我们就需要去控制这个同时发出请求的数量,也就是 并发数

拟定并发请求方法

所以,我们可以写这么一个函数来控制并发请求:

/**
 * 并发请求方法
 * @param { string[] } urls 待请求的 url 数组
 * @param { Number } maxNum 最大并发数量
 */
function concurRequest(urls, maxNum) {
  return new Promise((resolve) => {
    // ...后续补充
  })
}

我们可以给这个 concurRequest 方法传一个 url 地址的数组,再传一个最大并发数。当所有的请求全部完成之后返回一个 Promise,当所有请求都完成之后这个 Promise 就完成。

这个 Promise没有失败状态 的,哪怕你请求出错了,也是 resolve,只是要把失败原因保存起来。

并发请求执行流程

我们请求的 最终结果 需要按照以下方式进行:

001.png

假设我们要发出的请求 urls 中,有 ABCDE5 个请求地址,并且设置了最大并发数量为 3。那么当所有请求完成之后,需要把请求的结果放到一个数组里边,并且这个 数组中存放的每一个请求结果的索引要和请求 url 一一对应

那么具体的请求过程是怎么样的呢?

首先,按照最大并发数量,我们先抽出 3url 进行请求,如果此时 B 先请求完成了,那么就先把 B 的请求结果存放到数组中对应的位置:

002.png

因为 B 完成了,此时 AC 还未完成,所以 D 可以来进行补位,充分地利用网络带宽,你不能说等 ABC 都完成之后才进行下一个,只要有一个结束了并且不满足最大并发数量,就得补位继续进行请求:

003.png

依次类推,直到所有的请求都完成,然后把请求结果(不管成功还是失败)全部放到结果数组里边。

这个结果数组就是我们整个 concurRequest 要返回的结果。

封装发送单个请求的函数

从上述的流程分析中,我们可以看到,实际上我们是 需要有一个函数来帮助我们发送其中某一个请求 的。

我们怎么知道要发哪个请求呢?这一块我们可以准备一个 下标这个函数 要做的事情就是根据这个 下标 指向的 urls 数组中,取出对应的 url 来发送一个请求,然后把下标指向下一个,继续发送请求。

以此类推。

如果有这么一个函数的话,那么我们后边的处理就会方便很多,这里我们可以在 concurRequest 函数里边再定义一个 request 方法来发送单个请求:

/**
 * 并发请求方法
 * @param { string[] } urls 待请求的 url 数组
 * @param { Number } maxNum 最大并发数量
 */
function concurRequest(urls, maxNum) {
  return new Promise((resolve, reject) => {

    let index = 0 // 下一次请求对应的 url 地址的下标
    const result = [] // 存放最终的请求结果

    /**
     * 根据下标指向的 url,发送单个请求
     */
    async function request() {
      const currentIndex = index // 保存当前请求的下标

      // 取出下标指向的 url,然后 index++
      const url = urls[index]
      index++

      // 请求有可能成功,也有可能失败,所以需要 try catch
      try {
        // 发送单个请求
        const res = await fetch(url)
        
        // 这里不能直接通过 result.push() 存放请求结果,因为我们不知道请求完成的顺序。
        // result.push(res)
        result[currentIndex] = res
      } catch (error) {
        // 请求失败也是一样,也需要将失败的结果存入 result 中
        result[currentIndex] = error
      }
    }
  })
}

处理到这里了还有一个核心问题没处理,就是当调用了这个 request 发送某个请求后,某个请求完成之后,还得应该把下一个请求拿出来执行,也就是 补位

因此,我们可以在 try catch 中写一个 finally

/**
 * 并发请求方法
 * @param { string[] } urls 待请求的 url 数组
 * @param { Number } maxNum 最大并发数量
 */
function concurRequest(urls, maxNum) {
  return new Promise((resolve, reject) => {

    let index = 0 // 下一次请求对应的 url 地址的下标
    const result = [] // 存放最终的请求结果

    /**
     * 根据下标指向的 url,发送单个请求
     */
    async function request() {
      const currentIndex = index // 保存当前请求的下标

      // 取出下标指向的 url,然后 index++
      const url = urls[index]
      index++

      // 请求有可能成功,也有可能失败,所以需要 try catch
      try {
        // 发送单个请求
        const res = await fetch(url)
        
        // 这里不能直接通过 result.push() 存放请求结果,因为我们不知道请求完成的顺序。
        // result.push(res)
        result[currentIndex] = res
      } catch (error) {
        // 请求失败也是一样,也需要将失败的结果存入 result 中
        result[currentIndex] = error
      } finally {
        // 重新调用 request
        if (index < urls.length) {
          request()
        }
      }
    }
  })
}

好了,到现在就还剩一件重要的事情,就是 concurRequest 函数中返回的这个 Promise 什么时候完成呢?

有的人可能会认为这个下标越界了就完成了,这是不对的,因为下标越界了只能说明已经取出 urls 中所有的 url 来执行请求了,并不能说明所有请求都完成了。

所以,我们还需要借助一个变量 count 来记录请求完成的数量:

/**
 * 并发请求方法
 * @param { string[] } urls 待请求的 url 数组
 * @param { Number } maxNum 最大并发数量
 */
function concurRequest(urls, maxNum) {
  return new Promise((resolve, reject) => {

    let index = 0 // 下一次请求对应的 url 地址的下标
    let count = 0 // 请求完成的数量
    const result = [] // 存放最终的请求结果

    /**
     * 根据下标指向的 url,发送单个请求
     */
    async function request() {
      const currentIndex = index // 保存当前请求的下标

      // 取出下标指向的 url,然后 index++
      const url = urls[index]
      index++

      // 请求有可能成功,也有可能失败,所以需要 try catch
      try {
        // 发送单个请求
        const res = await fetch(url)
        
        // 这里不能直接通过 result.push() 存放请求结果,因为我们不知道请求完成的顺序。
        // result.push(res)
        result[currentIndex] = res
      } catch (error) {
        // 请求失败也是一样,也需要将失败的结果存入 result 中
        result[currentIndex] = error
      } finally {
        count++ // 每次请求完成,count 计数加一

        // 判断是否请求完成
        if (count === urls.length) {
          resolve(result)
        }

        // 重新调用 request
        if (index < urls.length) {
          request()
        }
      }
    }
  })
}

处理并发数量

最后就是来处理并发数量,这个并发数量其实影响的是什么?其实就是 request 需要调用几次。所以,只需要做一个循环处理就好了:

/**
 * 并发请求方法
 * @param { string[] } urls 待请求的 url 数组
 * @param { Number } maxNum 最大并发数量
 */
function concurRequest(urls, maxNum) {
  return new Promise((resolve, reject) => {

    let index = 0 // 下一次请求对应的 url 地址的下标
    let count = 0 // 请求完成的数量
    const result = [] // 存放最终的请求结果

    /**
     * 根据下标指向的 url,发送单个请求
     */
    async function request() {
      const currentIndex = index // 保存当前请求的下标

      // 取出下标指向的 url,然后 index++
      const url = urls[index]
      index++

      // 请求有可能成功,也有可能失败,所以需要 try catch
      try {
        // 发送单个请求
        const res = await fetch(url)
        
        // 这里不能直接通过 result.push() 存放请求结果,因为我们不知道请求完成的顺序。
        // result.push(res)
        result[currentIndex] = res
      } catch (error) {
        // 请求失败也是一样,也需要将失败的结果存入 result 中
        result[currentIndex] = error
      } finally {
        count++ // 每次请求完成,count 计数加一

        // 判断是否请求完成
        if (count === urls.length) {
          resolve(result)
        }

        // 重新调用 request
        if (index < urls.length) {
          request()
        }
      }
    }

    // 处理并发请求
    // 这里需要注意一个问题,传入的 maxNum 有可能会超过 urls 的长度,所以这里需要判断一下
    for (let i = 0; i < Math.min(urls.length, maxNum); i++) {
      request()
    }
  })
}

细节优化

如果传入的 urls 长度为 0,我们什么都不需要做,只需要 resolve 一个空数组就行了:

/**
 * 并发请求方法
 * @param { string[] } urls 待请求的 url 数组
 * @param { Number } maxNum 最大并发数量
 */
function concurRequest(urls = [], maxNum) {
  return new Promise((resolve) => {
    if (urls.length === 0) {
      resolve([])
    }

    // ...省略
  })
}

完整代码测试

模板页面 index.html

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>并发请求</title>
</head>

<body>
  <button>点击发送并发请求</button>
  <!-- 引入并发请求方法 -->
  <script src="./concurRequest.js"></script>
  <!-- 主程序 -->
  <script src="./index.js"></script>
</body>

</html>

并发请求 concurRequest.js

/**
 * 并发请求方法
 * @param { string[] } urls 待请求的 url 数组
 * @param { Number } maxNum 最大并发数量
 */
function concurRequest(urls = [], maxNum) {
  return new Promise((resolve) => {
    if (urls.length === 0) {
      resolve([])
    }

    let index = 0 // 下一次请求对应的 url 地址的下标
    let count = 0 // 请求完成的数量
    const result = [] // 存放最终的请求结果

    /**
     * 根据下标指向的 url,发送单个请求
     */
    async function request() {
      const currentIndex = index // 保存当前请求的下标

      // 取出下标指向的 url,然后 index++
      const url = urls[index]
      index++

      // 请求有可能成功,也有可能失败,所以需要 try catch
      try {
        // 发送单个请求
        const res = await fetch(url)
        
        // 这里不能直接通过 result.push() 存放请求结果,因为我们不知道请求完成的顺序。
        // result.push(res)
        result[currentIndex] = res
      } catch (error) {
        // 请求失败也是一样,也需要将失败的结果存入 result 中
        result[currentIndex] = error
      } finally {
        count++ // 每次请求完成,count 计数加一

        // 判断是否请求完成
        if (count === urls.length) {
          resolve(result)
        }

        // 重新调用 request
        if (index < urls.length) {
          request()
        }
      }
    }

    // 处理并发请求
    // 这里需要注意一个问题,传入的 maxNum 有可能会超过 urls 的长度,所以这里需要判断一下
    for (let i = 0; i < Math.min(urls.length, maxNum); i++) {
      request()
    }
  })
}

主程序 index.js

const urls = []
for (let i = 1; i <= 200; i++) {
  urls.push(`https://jsonplaceholder.typicode.com/todos/${i}`)
}

const btn = document.querySelector('button')
btn.onclick = async () => {
  // 设置最大并发数量为 5
  const res = await concurRequest(urls, 5)
  console.log(res);
}

请求结果如下所示,请求以最大并发数量 5 个 url 来进行并发请求:

004.gif