前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Promise 毁掉地狱

Promise 毁掉地狱

作者头像
李才哥
发布2019-07-10 11:13:18
1.8K0
发布2019-07-10 11:13:18
举报
文章被收录于专栏:李才哥李才哥
前言

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

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

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

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

代码语言:javascript
复制
<!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 函数和一个处理函数返回结果的函数。

代码语言:javascript
复制
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() 是一个同步函数,那么很自然的想到用__循环__。

代码语言:javascript
复制
for (let i = 0; i < urls.length; i++) {
 loadImg(urls[i])
}

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

代码语言:javascript
复制
loadImg(urls[0])
 .then(addToHtml)
 .then(()=>loadImg(urls[1]))
 .then(addToHtml)
 //...
 .then(()=>loadImg(urls[3]))
 .then(addToHtml)

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

代码语言:javascript
复制
let promise = Promise.resolve()
for (let i = 0; i < urls.length; i++) {
promise = promise
  .then(()=>loadImg(urls[i]))
  .then(addToHtml)
}

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

代码语言:javascript
复制
urls.reduce((promise, url) => {
 return promise
 .then(()=>loadImg(url))
 .then(addToHtml)
}, Promise.resolve())

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

代码语言:javascript
复制
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 链,直到最后的函数返回。

代码语言:javascript
复制
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 之前。

代码语言:javascript
复制
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中的例子改一下:

代码语言:javascript
复制
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 链来顺序处理。

代码语言:javascript
复制
let task = Promise.resolve()
for (let i = 0; i < promises.length; i++) {
 task = task.then(() => promises[i]).then(addToHtml)
}

改成 reduce 版本

代码语言:javascript
复制
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没办法侦听到每一张图片加载完成的事件。而用上一节的方法,我们既能并发请求,又能按顺序响应图片加载完成的事件。

代码语言:javascript
复制
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,还可以继续处理全部都图片加载完后的异步回调。

代码语言:javascript
复制
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 来处理最后还在请求中的异步任务,我们复用第二节__递归__版本的思路来实现这个功能:

代码语言:javascript
复制
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 的使用场景了,先来使用这个方法实现的功能代码:

代码语言:javascript
复制
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 来处理所有图片完成后的回调。

这里指的遍历方法包括:map、reduce、reduceRight、forEach、filter、some、every 因为最近要进行了一些数据汇总,node版本已经是8.11.1了,所以直接写了个async/await的脚本。 但是在对数组进行一些遍历操作时,发现有些遍历方法对Promise的反馈并不是我们想要的结果。

当然,有些严格来讲并不能算是遍历,比如说some,every这些的。

但确实,这些都会根据我们数组的元素来进行多次的调用传入的回调。

这些方法都是比较常见的,但是当你的回调函数是一个Promise时,一切都变了。

async/await为Promise的语法糖

文中会直接使用async/await替换Promise

代码语言:javascript
复制
let result = await func()
// => 等价于
func().then(result => {
 // code here
})
 
// ======
 
async function func () {
 return 1 
}
// => 等价与
function func () {
 return new Promise(resolve => resolve(1))
}

map

map可以说是对Promise最友好的一个函数了。

我们都知道,map接收两个参数:

  1. 对每项元素执行的回调,回调结果的返回值将作为该数组中相应下标的元素
  2. 一个可选的回调函数this指向的参数
代码语言:javascript
复制
[1, 2, 3].map(item => item ** 2) // 对数组元素进行求平方
// > [1, 4, 9]

上边是一个普通的map执行,但是当我们的一些计算操作变为异步的:

代码语言:javascript
复制
[1, 2, 3].map(async item => item ** 2) // 对数组元素进行求平方
// > [Promise, Promise, Promise]

这时候,我们获取到的返回值其实就是一个由Promise函数组成的数组了。

所以为什么上边说map函数为最友好的,因为我们知道,Promise有一个函数为Promise.all会将一个由Promise组成的数组依次执行,并返回一个Promise对象,该对象的结果为数组产生的结果集。

代码语言:javascript
复制
await Promise.all([1, 2, 3].map(async item => item ** 2))
// > [1, 4, 9]

首先使用Promise.all对数组进行包装,然后用await获取结果。

reduce/reduceRight

reduce的函数签名想必大家也很熟悉了,接收两个参数:

  1. 对每一项元素执行的回调函数,返回值将被累加到下次函数调用中,回调函数的签名:
    1. accumulator累加的值
    2. currentValue当前正在的元素
    3. array调用reduce的数组
  2. 可选的初始化的值,将作为accumulator的初始值
代码语言:javascript
复制
[1, 2, 3].reduce((accumulator, item) => accumulator + item, 0) // 进行加和
// > 6

这个代码也是没毛病的,同样如果我们加和的操作也是个异步的:

代码语言:javascript
复制
[1, 2, 3].reduce(async (accumulator, item) => accumulator + item, 0) // 进行加和
// > Promise {<resolved>: "[object Promise]3"}

这个结果返回的就会很诡异了,我们在回看上边的reduce的函数签名

对每一项元素执行的回调函数,返回值将被累加到下次函数调用中

然后我们再来看代码,async (accumulator, item) => accumulator += item

这个在最开始也提到了,是Pormise的语法糖,为了看得更清晰,我们可以这样写:

代码语言:javascript
复制
(accumulator, item) => new Promise(resolve =>
 resolve(accumulator += item)
)

也就是说,我们reduce的回调函数返回值其实就是一个Promise对象

然后我们对Promise对象进行+=操作,得到那样怪异的返回值也就很合情合理了。

当然,reduce的调整也是很轻松的:

代码语言:javascript
复制
await [1, 2, 3].reduce(async (accumulator, item) => await accumulator + item, 0)
// > 6

我们对accumulator调用await,然后再与当前item进行加和,在最后我们的reduce返回值也一定是一个Promise,所以我们在最外边也添加await的字样

也就是说我们每次reduce都会返回一个新的Promise对象,在对象内部都会获取上次Promise的结果。

我们调用reduce实际上得到的是类似这样的一个Promise对象:

代码语言:javascript
复制
new Promise(resolve => {
 let item = 3
 new Promise(resolve => {
 let item = 2
 new Promise(resolve => {
 let item = 1
 Promise.resolve(0).then(result => resolve(item + result))
 }).then(result => resolve(item + result))
 }).then(result => resolve(item + result))
})

reduceRight

这个就没什么好说的了。。跟reduce只是执行顺序相反而已

forEach

forEach,这个应该是用得最多的遍历方法了,对应的函数签名:

  1. callback,对每一个元素进行调用的函数
    1. currentValue,当前元素
    2. index,当前元素下标
    3. array,调用forEach的数组引用
  2. thisArg,一个可选的回调函数this指向

我们有如下的操作:

代码语言:javascript
复制
// 获取数组元素求平方后的值
[1, 2, 3].forEach(item => {
 console.log(item ** 2)
})
// > 1
// > 4
// > 9
代码语言:javascript
复制
普通版本我们是可以直接这么输出的,但是如果遇到了Promise
代码语言:javascript
复制
// 获取数组元素求平方后的值
[1, 2, 3].forEach(async item => {
 console.log(item ** 2)
})
// > nothing

forEach并不关心回调函数的返回值,所以forEach只是执行了三个会返回Promise的函数

所以如果我们想要得到想要的效果,只能够自己进行增强对象属性了:

代码语言:javascript
复制
Array.prototype.forEachSync = async function (callback, thisArg) {
 for (let [index, item] of Object.entries(this)) {
 await callback(item, index, this)
 }
}
 
await [1, 2, 3].forEachSync(async item => {
 console.log(item ** 2)
})
 
// > 1
// > 4
// > 9

await会忽略非Promise值,await 0、await undefined与普通代码无异

filter

filter作为一个筛选数组用的函数,同样具有遍历的功能:

函数签名同forEach,但是callback返回值为true的元素将被放到filter函数返回值中去。

我们要进行一个奇数的筛选,所以我们这么写:

代码语言:javascript
复制
[1, 2, 3].filter(item => item % 2 !== 0)
// > [1, 3]

然后我们改为Promise版本:

代码语言:javascript
复制
[1, 2, 3].filter(async item => item % 2 !== 0)
// > [1, 2, 3]

这会导致我们的筛选功能失效,因为filter的返回值匹配不是完全相等的匹配,只要是返回值能转换为true,就会被认定为通过筛选。

Promise对象必然是true的,所以筛选失效。

所以我们的处理方式与上边的forEach类似,同样需要自己进行对象增强

但我们这里直接选择一个取巧的方式:

代码语言:javascript
复制
Array.prototype.filterSync = async function (callback, thisArg) {
 let filterResult = await Promise.all(this.map(callback))
 // > [true, false, true]
 
 return this.filter((_, index) => filterResult[index])
}
 
await [1, 2, 3].filterSync(item => item % 2 !== 0)

我们可以直接在内部调用map方法,因为我们知道map会将所有的返回值返回为一个新的数组。

这也就意味着,我们map可以拿到我们对所有item进行筛选的结果,true或者false。

接下来对原数组每一项进行返回对应下标的结果即可。

some

some作为一个用来检测数组是否满足一些条件的函数存在,同样是可以用作遍历的

函数签名同forEach,有区别的是当任一callback返回值匹配为true则会直接返回true,如果所有的callback匹配均为false,则返回false

我们要判断数组中是否有元素等于2:

代码语言:javascript
复制
[1, 2, 3].some(item => item === 2)
// > true

然后我们将它改为Promise

代码语言:javascript
复制
[1, 2, 3].some(async item => item === 2)
// > true

这个函数依然会返回true,但是却不是我们想要的,因为这个是async返回的Promise对象被认定为true。

所以,我们要进行如下处理:

代码语言:javascript
复制
Array.prototype.someSync = async function (callback, thisArg) {
 for (let [index, item] of Object.entries(this)) {
 if (await callback(item, index, this)) return true
 }
 
 return false
}
await [1, 2, 3].someSync(async item => item === 2)
// > true

因为some在匹配到第一个true之后就会终止遍历,所以我们在这里边使用forEach的话是在性能上的一种浪费。

同样是利用了await会忽略普通表达式的优势,在内部使用for-of来实现我们的需求

every

以及我们最后的一个every

函数签名同样与forEach一样,

但是callback的处理还是有一些区别的:

其实换一种角度考虑,every就是一个反向的some

some会在获取到第一个true时终止

而every会在获取到第一个false时终止,如果所有元素均为true,则返回true

我们要判定数组中元素是否全部大于3

[1, 2, 3].every(item => item > 3) // > false

很显然,一个都没有匹配到的,而且回调函数在执行到第一次时就已经终止了,不会继续执行下去。

我们改为Promise版本:

代码语言:javascript
复制
[1, 2, 3].every(async => item > 3)
// > true

这个必然是true,因为我们判断的是Promise对象

所以我们拿上边的someSync实现稍微修改一下:

代码语言:javascript
复制
Array.prototype.everySync = async function (callback, thisArg) {
 for (let [index, item] of Object.entries(this)) {
 if (!await callback(item, index, this)) return false
 }
 
 return true
}
await [1, 2, 3].everySync(async item => item === 2)
// > true

当匹配到任意一个false时,直接返回false,终止遍历。

后记

关于数组的这几个遍历方法。

因为map和reduce的特性,所以是在使用async时改动最小的函数。

reduce的结果很像一个洋葱模型

但对于其他的遍历函数来说,目前来看就需要自己来实现了。

四个*Sync函数的实现:https://github.com/Jiasm/notebook/tree/master/array-sync

写在最后

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

参考资料

JavaScript Promise:简介 | Web | Google Developers

JavaScript Promise迷你书(中文版)

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2018-05-26,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 李才哥 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档