Promise 異步流程控制

最近部門在招前端,作爲部門唯一的前端,面試了不少應聘的同學,面試中有一個涉及 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 同步化

至此,這個函數還是有挺多不通用的問題,比如:處理函數必須一致,不能是多種不同的異步函數組成的隊列,異步的回調函數也只能是一種等。關於這種方式的更詳細的描述可以看我之前寫的一篇文章 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 < 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 的使用場景了,先來使用這個方法實現的功能代碼:

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部門,要求工作經驗三年以上,這是公司社招要求的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章