最近部門在招前端,做爲部門惟一的前端,面試了很多應聘的同窗,面試中有一個涉及 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 :
demo6地址(看控制檯和網絡請求): Promise 控制最大併發數 - 方法1Promise.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在使用 Promise.race
實現這個功能,主要是不斷的調用 Promise.race
來返回已經被 resolve
的任務,而後從 promises
中刪掉這個 Promise 對象,再加入一個新的 Promise,直到所有的 URL 被取完,最後再使用 Promise.all
來處理全部圖片完成後的回調。
由於工做裏面大量使用 ES6 的語法,Koa 中的 await/async 又是 Promise 的語法糖,因此瞭解 Promise 各類流程控制是對我來講是很是重要的。寫的有不明白的地方和有錯誤的地方歡迎你們留言指正,另外還有其餘沒有涉及到的方法也請你們提供一下新的方式和方法。
咱們目前有 1 個前端的 HC,base 深圳,一家擁有 50 架飛機的物流公司的AI部門,要求工做經驗三年以上,這是公司社招要求的。 感興趣的就聯繫我吧,Email: d2hlYXRvQGZveG1haWwuY29t