最近部门在招前端,做为部门惟一的前端,面试了很多应聘的同窗,面试中有一个涉及 Promise 的一个问题是:javascript
网页中预加载20张图片资源,分步加载,一次加载10张,两次完成,怎么控制图片请求的并发,怎样感知当前异步请求是否已完成?css
然而能所有答上的不多,可以给出一个回调 + 计数版本的,我都以为合格了。那么接下来就一块儿来学习总结一下基于 Promise 来处理异步的三种方法。html
本文的例子是一个极度简化的一个漫画阅读器,用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 函数和一个处理函数返回结果的函数。java
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()
是一个同步函数,那么很天然的想到用__循环__。git
for (let i = 0; i < urls.length; i++) {
loadImg(urls[i])
}
复制代码
当 loadImg()
为异步时,咱们就只能用 Promise chain 来实现,最终造成这种方式的调用:github
loadImg(urls[0])
.then(addToHtml)
.then(()=>loadImg(urls[1]))
.then(addToHtml)
//...
.then(()=>loadImg(urls[3]))
.then(addToHtml)
复制代码
那咱们用一个中间变量来存储当前的 promise ,就像链表的游标同样,改事后的 for
循环代码以下:web
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 同步化
至此,这个函数仍是有挺多不通用的问题,好比:处理函数必须一致,不能是多种不一样的异步函数组成的队列,异步的回调函数也只能是一种等。关于这种方式的更详细的描述能够看我以前写的一篇文章 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
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 并发请求,顺序处理结果
如今咱们来试着完成一下上面的笔试题,这个其实都__不须要控制最大并发数__。 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
那么咱们在抽象一下代码,写一个通用的方法出来,这个函数返回一个 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
但上面的实现和咱们说的__最大并发数控制__没什么关系啊,最大并发数控制是指:当加载 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 < urls.length; i++){
promises.push(load())
}
return Promise.all(promises)
}
复制代码
设定最大请求数为 5,Chrome 中请求加载的 timeline :
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 :
在使用 Promise.race
实现这个功能,主要是不断的调用 Promise.race
来返回已经被 resolve
的任务,而后从 promises
中删掉这个 Promise 对象,再加入一个新的 Promise,直到所有的 URL 被取完,最后再使用 Promise.all
来处理全部图片完成后的回调。
由于工做里面大量使用 ES6 的语法,Koa 中的 await/async 又是 Promise 的语法糖,因此了解 Promise 各类流程控制是对我来讲是很是重要的。写的有不明白的地方和有错误的地方欢迎你们留言指正,另外还有其余没有涉及到的方法也请你们提供一下新的方式和方法。
咱们目前有 1 个前端的 HC,base 深圳,一家拥有 50 架飞机的物流公司的AI部门,要求工做经验三年以上,这是公司社招要求的。 感兴趣的就联系我吧,Email: d2hlYXRvQGZveG1haWwuY29t