前言
最近部門在招前端,作為部門唯一的前端,面試了不少應聘的同學,面試中有一個涉及 Promise 的一個問題是:
網頁中預載入20張圖片資源,分步載入,一次載入10張,兩次完成,怎麼控制圖片請求的併發,怎樣感知當前非同步請求是否已完成?
然而能全部答上的很少,能夠給出一個回撥 + 計數版本的,我都覺得合格了。那麼接下來就一起來學習總結一下基於 Promise 來處理非同步的三種方法。
本文的例子是一個極度簡化的一個漫畫閱讀器,用4張漫畫圖的載入來介紹非同步處理不同方式的實現和差異,以下是 HTML 程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
<!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 函式和一個處理函式返回結果的函式。
1 2 3 4 5 6 7 8 9 10 |
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() 是一個同步函式,那麼很自然的想到用__迴圈__。
1 2 3 |
for (let i = 0; i < urls.length; i++) { loadImg(urls[i]) } |
當 loadImg() 為非同步時,我們就只能用 Promise chain 來實現,最終形成這種方式的呼叫:
1 2 3 4 5 6 7 |
loadImg(urls[0]) .then(addToHtml) .then(()=>loadImg(urls[1])) .then(addToHtml) //... .then(()=>loadImg(urls[3])) .then(addToHtml) |
那我們用一箇中間變數來儲存當前的 promise ,就像連結串列的遊標一樣,改過後的 for 迴圈程式碼如下:
1 2 3 4 5 6 |
let promise = Promise.resolve() for (let i = 0; i < urls.length; i++) { promise = promise .then(()=>loadImg(urls[i])) .then(addToHtml) } |
promise 變數就像是一個迭代器,不斷指向最新的返回的 Promise,那我們就進一步使用 reduce 來簡化程式碼。
1 2 3 4 5 |
urls.reduce((promise, url) => { return promise .then(()=>loadImg(url)) .then(addToHtml) }, Promise.resolve()) |
在程式設計中,是可以通過函式的__遞迴__來實現迴圈語句的。所以我們將上面的程式碼改成__遞迴__:
1 2 3 4 5 6 7 8 9 10 11 |
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 鏈,直到最後的函式返回。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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 之前。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
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中的例子改一下:
1 2 3 4 5 6 7 8 9 |
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 鏈來順序處理。
1 2 3 4 |
let task = Promise.resolve() for (let i = 0; i < promises.length; i++) { task = task.then(() => promises[i]).then(addToHtml) } |
改成 reduce 版本
1 2 3 |
promises.reduce((task, imgPromise) => { return task.then(() => imgPromise).then(addToHtml) }, Promise.resolve()) |
demo3地址:Promise 併發請求,順序處理結果
控制最大併發數
現在我們來試著完成一下上面的筆試題,這個其實都__不需要控制最大併發數__。 20張圖,分兩次載入,那用兩個 Promise.all 不就解決了?但是用 Promise.all沒辦法偵聽到每一張圖片載入完成的事件。而用上一節的方法,我們既能併發請求,又能按順序響應圖片載入完成的事件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
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,還可以繼續處理全部都圖片載入完後的非同步回撥。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
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 來處理最後還在請求中的非同步任務,我們複用第二節__遞迴__版本的思路來實現這個功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
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
使用 Promise.race
Promise.race 接受一個 Promise 陣列,返回這個陣列中最先被 resolve 的 Promise 的返回值。終於找到 Promise.race 的使用場景了,先來使用這個方法實現的功能程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
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