查看原文
其他

Promise 异步流程控制

前端大全 2019-11-14

(点击上方公众号,可快速关注)


作者:麦子谷

zhuanlan.zhihu.com/p/29792886



前言


最近部门在招前端,作为部门唯一的前端,面试了不少应聘的同学,面试中有一个涉及 Promise 的一个问题是:


网页中预加载20张图片资源,分步加载,一次加载10张,两次完成,怎么控制图片请求的并发,怎样感知当前异步请求是否已完成?


然而能全部答上的很少,能够给出一个回调 + 计数版本的,我都觉得合格了。那么接下来就一起来学习总结一下基于 Promise 来处理异步的三种方法。


本文的例子是一个极度简化的一个漫画阅读器,用4张漫画图的加载来介绍异步处理不同方式的实现和差异,以下是 HTML 代码:


<!DOCTYPE html>

<html lang="en">

<head>

  <meta charset="UTF-8">

  <meta name="viewport" content="width=device-width, initial-scale=1.0">

  <meta http-equiv="X-UA-Compatible" content="ie=edge">

  <title>Promise</title>

  <style>

    .pics{

      width: 300px;

      margin: 0 auto;

    }

    .pics img{

      display: block;

      width: 100%;

    }

    .loading{

      text-align: center;

      font-size: 14px;

      color: #111;

    }

  </style>

</head>

<body>

  <div class="wrap">

    <div class="loading">正在加载...</div>

    <div class="pics">

    </div>

  </div>

  <script>

  </script>

</body>

</html>


单一请求


最简单的,就是将异步一个个来处理,转为一个类似同步的方式来处理。 先来简单的实现一个单个 Image 来加载的 thenable 函数和一个处理函数返回结果的函数。


function loadImg (url) {

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

    const img = new Image()

    img.onload = function () {

      resolve(img)

    }

    img.onerror = reject

    img.src = url

  })

}


异步转同步的解决思想是:当第一个 loadImg(urls[1]) 完成后再调用 loadImg(urls[2]),依次往下。如果 loadImg() 是一个同步函数,那么很自然的想到用__循环__。


for (let i = 0; i < urls.length; i++) {

  loadImg(urls[i])

}


当 loadImg() 为异步时,我们就只能用 Promise chain 来实现,最终形成这种方式的调用:


loadImg(urls[0])

  .then(addToHtml)

  .then(()=>loadImg(urls[1]))

  .then(addToHtml)

  //...

  .then(()=>loadImg(urls[3]))

  .then(addToHtml)


那我们用一个中间变量来存储当前的 promise ,就像链表的游标一样,改过后的 for 循环代码如下:


let promise = Promise.resolve()

for (let i = 0; i < urls.length; i++) {

promise = promise

  .then(()=>loadImg(urls[i]))

  .then(addToHtml)

}


promise 变量就像是一个迭代器,不断指向最新的返回的 Promise,那我们就进一步使用 reduce 来简化代码。


urls.reduce((promise, url) => {

  return promise

    .then(()=>loadImg(url))

    .then(addToHtml)

}, Promise.resolve())


在程序设计中,是可以通过函数的__递归__来实现循环语句的。所以我们将上面的代码改成__递归__:


function syncLoad (index) {

  if (index >= urls.length) return

      loadImg(urls[index]).then(img => {

      // process img

      addToHtml(img)

      syncLoad (index + 1)

    })

}

 

// 调用

syncLoad(0)


好了一个简单的异步转同步的实现方式就已经完成,我们来测试一下。 这个实现的简单版本已经实现没问题,但是最上面的正在加载还在,那我们怎么在函数外部知道这个递归的结束,并隐藏掉这个 DOM 呢?Promise.then() 同样返回的是 thenable 函数 我们只需要在 syncLoad 内部传递这条 Promise 链,直到最后的函数返回。


function syncLoad (index) {

  if (index >= urls.length) return Promise.resolve()

  return loadImg(urls[index])

    .then(img => {

      addToHtml(img)

      return syncLoad (index + 1)

    })

}

 

// 调用

syncLoad(0)

  .then(() => {

  document.querySelector('.loading').style.display = 'none'

})


现在我们再来完善一下这个函数,让它更加通用,它接受__异步函数__、异步函数需要的参数数组、__异步函数的回调函数__三个参数。并且会记录调用失败的参数,在最后返回到函数外部。另外大家可以思考一下为什么 catch 要在最后的 then 之前。


function syncLoad (fn, arr, handler) {

  if (typeof fn !== 'function') throw TypeError('第一个参数必须是function')

  if (!Array.isArray(arr)) throw TypeError('第二个参数必须是数组')

  handler = typeof fn === 'function' ? handler : function () {}

  const errors = []

  return load(0)

  function load (index) {

    if (index >= arr.length) {

      return errors.length > 0 ? Promise.reject(errors) : Promise.resolve()

    }

    return fn(arr[index])

      .then(data => {

        handler(data)

      })

      .catch(err => {

        console.log(err)              

        errors.push(arr[index])

        return load(index + 1)

      })

      .then(() => {

        return load (index + 1)

      })

  }

}

 

// 调用

syncLoad(loadImg, urls, addToHtml)

  .then(() => {

    document.querySelector('.loading').style.display = 'none'

  })

  .catch(console.log)


demo1地址:单一请求 – 多个 Promise 同步化(https://wheato.github.io/demo/promise-demo/demo1.html)


至此,这个函数还是有挺多不通用的问题,比如:处理函数必须一致,不能是多种不同的异步函数组成的队列,异步的回调函数也只能是一种等。关于这种方式的更详细的描述可以看我之前写的一篇文章 Koa引用库之Koa-compose。


当然这种异步转同步的方式在这一个例子中并不是最好的解法,但当有合适的业务场景的时候,这是很常见的解决方案。


并发请求


毕竟同一域名下能够并发多个 HTTP 请求,对于这种不需要按顺序加载,只需要按顺序来处理的并发请求,Promise.all 是最好的解决办法。因为Promise.all 是原生函数,我们就引用文档来解释一下。


Promise.all(iterable) 方法指当所有在可迭代参数中的 promises 已完成,或者第一个传递的 promise(指 reject)失败时,返回 promise。

出自 Promise.all() – JavaScript | MDN

那我们就把demo1中的例子改一下:


const promises = urls.map(loadImg)

Promise.all(promises)

  .then(imgs => {

    imgs.forEach(addToHtml)

    document.querySelector('.loading').style.display = 'none'

  })

  .catch(err => {

    console.error(err, 'Promise.all 当其中一个出现错误,就会reject。')

  })


demo2地址:并发请求 – Promise.all(https://wheato.github.io/demo/promise-demo/demo2.html)


并发请求,按顺序处理结果


Promise.all 虽然能并发多个请求,但是一旦其中某一个 promise 出错,整个 promise 会被 reject 。 webapp 里常用的资源预加载,可能加载的是 20 张逐帧图片,当网络出现问题, 20 张图难免会有一两张请求失败,如果失败后,直接抛弃其他被 resolve 的返回结果,似乎有点不妥,我们只要知道哪些图片出错了,把出错的图片再做一次请求或着用占位图补上就好。 上节中的代码 const promises = urls.map(loadImg) 运行后,全部都图片请求都已经发出去了,我们只要按顺序挨个处理 promises 这个数组中的 Promise 实例就好了,先用一个简单点的 for 循环来实现以下,跟第二节中的单一请求一样,利用 Promise 链来顺序处理。


let task = Promise.resolve()

for (let i = 0; i < promises.length; i++) {

  task = task.then(() => promises[i]).then(addToHtml)

}


改成 reduce 版本


promises.reduce((task, imgPromise) => {

  return task.then(() => imgPromise).then(addToHtml)

}, Promise.resolve())


demo3地址:Promise 并发请求,顺序处理结果(https://wheato.github.io/demo/promise-demo/demo3.html)


控制最大并发数


现在我们来试着完成一下上面的笔试题,这个其实都__不需要控制最大并发数__。 20张图,分两次加载,那用两个 Promise.all 不就解决了?但是用 Promise.all没办法侦听到每一张图片加载完成的事件。而用上一节的方法,我们既能并发请求,又能按顺序响应图片加载完成的事件。


let index = 0

const step1 = [], step2 = []

 

while(index < 10) {

  step1.push(loadImg(`./images/pic/${index}.jpg`))

  index += 1

}

 

step1.reduce((task, imgPromise, i) => {

  return task

    .then(() => imgPromise)

    .then(() => {

      console.log(`第 ${i + 1} 张图片加载完成.`)

    })

}, Promise.resolve())

  .then(() => {

    console.log('>> 前面10张已经加载完!')

  })

  .then(() => {

    while(index < 20) {

      step2.push(loadImg(`./images/pic/${index}.jpg`))

      index += 1

    }

    return step2.reduce((task, imgPromise, i) => {

      return task

        .then(() => imgPromise)

        .then(() => {

          console.log(`第 ${i + 11} 张图片加载完成.`)

        })

    }, Promise.resolve())

  })

  .then(() => {

    console.log('>> 后面10张已经加载完')

  })


上面的代码是针对题目的 hardcode ,如果笔试的时候能写出这个,都已经是非常不错了,然而并没有一个人写出来,said…


demo4地址(看控制台和网络请求):Promise 分步加载 – 1(https://wheato.github.io/demo/promise-demo/demo4.html)


那么我们在抽象一下代码,写一个通用的方法出来,这个函数返回一个 Promise,还可以继续处理全部都图片加载完后的异步回调。


function stepLoad (urls, handler, stepNum) {

const createPromises = function (now, stepNum) {

    let last = Math.min(stepNum + now, urls.length)

    return urls.slice(now, last).map(handler)

  }

  let step = Promise.resolve()

  for (let i = 0; i < urls.length; i += stepNum) {

    step = step

      .then(() => {

        let promises = createPromises(i, stepNum)

        return promises.reduce((task, imgPromise, index) => {

          return task

            .then(() => imgPromise)

            .then(() => {

              console.log(`第 ${index + 1 + i} 张图片加载完成.`)

            })

        }, Promise.resolve())

      })

      .then(() => {

        let current = Math.min(i + stepNum, urls.length)

        console.log(`>> 总共${current}张已经加载完!`)

      })

  }

return step

}


上面代码里的 for 也可以改成 reduce ,不过需要先将需要加载的 urls 按分步的数目,划分成数组,感兴趣的朋友可以自己写写看。


demo5地址(看控制台和网络请求):Promise 分步 – 2(https://wheato.github.io/demo/promise-demo/demo5.html)


但上面的实现和我们说的__最大并发数控制__没什么关系啊,最大并发数控制是指:当加载 20 张图片加载的时候,先并发请求 10 张图片,当一张图片加载完成后,又会继续发起一张图片的请求,让并发数保持在 10 个,直到需要加载的图片都全部发起请求。这个在写爬虫中可以说是比较常见的使用场景了。 那么我们根据上面的一些知识,我们用两种方式来实现这个功能。


使用递归


假设我们的最大并发数是 4 ,这种方法的主要思想是相当于 4 个__单一请求__的 Promise 异步任务在同时运行运行,4 个单一请求不断递归取图片 URL 数组中的 URL 发起请求,直到 URL 全部取完,最后再使用 Promise.all 来处理最后还在请求中的异步任务,我们复用第二节__递归__版本的思路来实现这个功能:


function limitLoad (urls, handler, limit) {

  const sequence = [].concat(urls) // 对数组做一个拷贝

  let count = 0

  const promises = []

 

  const load = function () {

    if (sequence.length <= 0 || count > limit) return

    count += 1

    console.log(`当前并发数: ${count}`)

    return handler(sequence.shift())

      .catch(err => {

        console.error(err)

      })

      .then(() => {

        count -= 1

        console.log(`当前并发数:${count}`)

      })

      .then(() => load())

  }

 

  for(let i = 0; i < limit && i < sequence.length; i++){

    promises.push(load())

  }

  return Promise.all(promises)

}


设定最大请求数为 5,Chrome 中请求加载的 timeline :



demo6地址(看控制台和网络请求):Promise 控制最大并发数 – 方法1(https://wheato.github.io/demo/promise-demo/demo6.html)


使用 Promise.race


Promise.race 接受一个 Promise 数组,返回这个数组中最先被 resolve 的 Promise 的返回值。终于找到 Promise.race 的使用场景了,先来使用这个方法实现的功能代码:


function limitLoad (urls, handler, limit) {

  const sequence = [].concat(urls) // 对数组做一个拷贝

  let count = 0

  let promises

  const wrapHandler = function (url) {

    const promise = handler(url).then(img => {

      return { img, index: promise }

    })

    return promise

  }

  //并发请求到最大数

  promises = sequence.splice(0, limit).map(url => {

    return wrapHandler(url)

  })

  // limit 大于全部图片数, 并发全部请求

  if (sequence.length <= 0) {

    return Promise.all(promises)

  }

  return sequence.reduce((last, url) => {

    return last.then(() => {

      return Promise.race(promises)

    }).catch(err => {

      console.error(err)

    }).then((res) => {

      let pos = promises.findIndex(item => {

        return item == res.index

      })

      promises.splice(pos, 1)

      promises.push(wrapHandler(url))

    })

  }, Promise.resolve()).then(() => {

    return Promise.all(promises)

  })

}


设定最大请求数为 5,Chrome 中请求加载的 timeline :



demo7地址(看控制台和网络请求):Promise 控制最大并发数 – 方法2(https://wheato.github.io/demo/promise-demo/demo7.html)


在使用 Promise.race 实现这个功能,主要是不断的调用 Promise.race 来返回已经被 resolve 的任务,然后从 promises 中删掉这个 Promise 对象,再加入一个新的 Promise,直到全部的 URL 被取完,最后再使用 Promise.all 来处理所有图片完成后的回调。


写在最后


因为工作里面大量使用 ES6 的语法,Koa 中的 await/async 又是 Promise 的语法糖,所以了解 Promise 各种流程控制是对我来说是非常重要的。写的有不明白的地方和有错误的地方欢迎大家留言指正,另外还有其他没有涉及到的方法也请大家提供一下新的方式和方法。


参考资料


JavaScript Promise:简介 | Web | Google Developers

JavaScript Promise迷你书(中文版)



【关于投稿】


如果大家有原创好文投稿,请直接给公号发送留言。


① 留言格式:
【投稿】+《 文章标题》+ 文章链接

② 示例:
【投稿】《不要自称是程序员,我十多年的 IT 职场总结》:http://blog.jobbole.com/94148/

③ 最后请附上您的个人简介哈~



觉得本文对你有帮助?请分享给更多人

关注「前端大全」,提升前端技能

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存