async/await 非同步應用的常用場景
前言
async/await 語法用看起來像寫同步程式碼的方式來優雅地處理非同步操作,但是我們也要明白一點,非同步操作本來帶有複雜性,像寫同步程式碼的方式並不能降低本質上的複雜性,所以在處理上我們要更加謹慎, 稍有不慎就可能寫出不是預期執行的程式碼,從而影響執行效率。下面將簡單地描述一下一些日常常用場景,加深對 async/await 認識
最普遍的非同步操作就是請求,我們也可以用 setTimeOut 來簡單模擬非同步請求。
場景1. 一個請求接著一個請求
相信這個場景是最常遇到,後一個請求依賴前一個請求,下面以爬取一個網頁內的圖片為例子進行描述,使用了 superagent 請求模組, cheerio 頁面分析模組,圖片的地址需要分析網頁內容得出,所以必須按順序進行請求。
const request = require('superagent') const cheerio = require('cheerio') // 簡單封裝下請求,其他的類似 function getHTML(url) { // 一些操作,比如設定一下請求頭資訊 return superagent.get(url).set('referer', referer).set('user-agent', userAgent) } // 下面就請求一張圖片 async function imageCrawler(url) { let res = await getHTML(url) let html = res.text let $ = cheerio.load(html) let $img = $(selector)[0] let href = $img.attribs.src res = await getImage(href) retrun res.body } async function handler(url) { let img = await imageCrawler(url) console.log(img) // buffer 格式的資料 // 處理圖片 } handler(url)
上面就是一個簡單的獲取圖片資料的場景,圖片資料是載入進記憶體中,如果只是簡單的儲存資料,可以用流的形式進行儲存,以防止消耗太多記憶體。
其中 await getHTML 是必須的,如果省略了 await 程式就不能按預期得到結果。執行流程會先執行 await 後面的表示式,其實際返回的是一個處於 pending 狀態的 promise,等到這個 promise 處於已決議狀態後才會執行 await 後面的操作,其中的程式碼執行會跳出 async 函式,繼續執行函式外面的其他程式碼,所以並不會阻塞後續程式碼的執行。
場景2.併發請求
有的時候我們並不需要等待一個請求回來才發出另一個請求,這樣效率是很低的,所以這個時候就需要併發執行請求任務。下面以一個查詢為例,先獲取一個人的學校地址和家庭住址,再由這些資訊獲取詳細的個人資訊,學校地址和家庭住址是沒有依賴關係的,後面的獲取個人資訊依賴於兩者
async function infoCrawler(url, name) { let [schoolAdr, homeAdr] = await Promise.all([getSchoolAdr(name), getHomeAdr(name)]) let info = await getInfo(url + `?schoolAdr=${schoolAdr}&homeAdr=${homeAdr}`) return info }
上面使用的 Promise.all 裡面的非同步請求都會併發執行,並等到資料都準備後返回相應的按資料順序返回的陣列,這裡最後處理獲取資訊的時間,由併發請求中最慢的請求決定,例如 getSchoolAdr 遲遲不返回資料,那麼後續操作只能等待,就算 getHomeAdr 已經提前返回了,當然以上場景必須是這麼做,但是有的時候我們並不需要這麼做。
上面第一個場景中,我們只獲取到一張圖片,但是可能一個網頁中不止一張圖片,如果我們要把這些圖片儲存起來,其實是沒有必要等待圖片都併發請求回來後再處理,哪張圖片早回來就儲存哪張就行了
let imageUrls = ['href1', 'href2', 'href3'] async function saveImages(imageUrls) { await Promise.all(imageUrls.map(async imageUrl => { let img = await getImage(imageUrl) return await saveImage(img) })) console.log('done') }
// 如果我們連儲存是否全部完成也不關心,也可以這麼寫
let imageUrls = ['href1', 'href2', 'href3'] // saveImages() 連 async 都省了 function saveImages(imageUrls) { imageUrls.forEach(async imageUrl => { let img = await getImage(imageUrl) saveImage(img) }) }
可能有人會疑問 forEach 不是不能用於非同步嗎,這個說法我也在剛接觸這個語法的時候就聽說過,很明顯 forEach 是可以處理非同步的,只是是併發處理,map 也是併發處理,這個怎麼用主要看你的實際場景,還要看你是否對結果感興趣
場景3.錯誤處理
一個請求發出,可以會遇到各種問題,我們是無法保證一定成功的,報錯是常有的事,所以處理錯誤有時很有必要, async/await 處理錯誤也非常直觀, 使用 try/catch 直接捕獲就可以了
async function imageCrawler(url) { try { let img = await getImage(url) return img } catch (error) { console.log(error) } }
// imageCrawler 返回的是一個 promise 可以這樣處理
async function imageCrawler(url) { let img = await getImage(url) return img } imageCrawler(url).catch(err => { console.log(err) })
可能有人會有疑問,是不是要在每個請求中都 try/catch 一下,這個其實你在最外層 catch 一下就可以了,一些基於中介軟體的設計就喜歡在最外層捕獲錯誤
async function ctx(next) { try { await next() } catch (error) { console.log(error) } }
場景4. 超時處理
一個請求發出,我們是無法確定什麼時候返回的,也總不能一直傻傻的等,設定超時處理有時是很有必要的
function timeOut(delay) {
return new Promise((resolve, reject) => { setTimeout(() => { reject(new Error('不用等了,別傻了')) }, delay) })
}
async function imageCrawler(url,delay) {
try { let img = await Promise.race([getImage(url), timeOut(delay)]) return img } catch (error) { console.log(error) }
}
這裡使用 Promise.race 處理超時,要注意的是,如果超時了,請求還是沒有終止的,只是不再進行後續處理。當然也不用擔心,後續處理會報錯而導致重新處理出錯資訊, 因為 promise 的狀態一經改變是不會再改變的
場景5. 併發限制
在併發請求的場景中,如果需要大量併發,必須要進行併發限制,不然會被網站遮蔽或者造成程序崩潰
async function getImages(urls, limit) { let running = 0 let r let p = new Promise((resolve, reject) => { r = resolve }) function run() { if (running < limit && urls.length > 0) { running++ let url = urls.shift(); (async () => { let img = await getImage(url) running-- console.log(img) if (urls.length === 0 && running === 0) { console.log('done') return r('done') } else { run() } })() run()// 立即到併發上限 } } run() return await p }
總結
以上列舉了一些日常場景處理的程式碼片段,在遇到比較複雜場景時,可以結合以上的場景進行組合使用,如果場景過於複雜,最好的辦法是使用相關的非同步程式碼控制庫。如果想更好地瞭解 async/await 可以先去了解 promise 和 generator, async/await 基本上是 generator 函式的語法糖,下面簡單的描述了一下內部的原理。
function delay(time) { return new Promise((resolve, reject) => { setTimeout(() => { resolve(time) }, time) }) } function *createTime() { let time1 = yield delay(1000) let time2 = yield delay(2000) let time3 = yield delay(3000) console.log(time1, time2, time3) } let iterator = createTime() console.log(iterator.next()) console.log(iterator.next(1000)) console.log(iterator.next(2000)) console.log(iterator.next(3000)) // 輸出 { value: Promise { <pending> }, done: false } { value: Promise { <pending> }, done: false } { value: Promise { <pending> }, done: false } 1000 2000 3000 { value: undefined, done: true }
可以看出每個 value 都是 Promise,並且通過手動傳入引數到 next 就可以設定生成器內部的值,這裡是手動傳入,我只要寫一個遞迴函式讓其自動添進去就可以了
function run(createTime) { let iterator = createTime() let result = iterator.next() function autoRun() { if (!result.done) { Promise.resolve(result.value).then(time => { result = iterator.next(time) autoRun() }).catch(err => { result = iterator.throw(err) autoRun() }) } } autoRun() } run(createTime)
promise.resove 保證返回的是一個 promise 物件 可迭代物件除了有 next 方法還有 throw 方法用於往生成器內部傳入錯誤,只要生成內部能捕獲該物件,生成器就可以繼承執行,類似下面的程式碼
function delay(time) { return new Promise((resolve, reject) => { setTimeout(() => { if (time == 2000) { reject('2000錯誤') } resolve(time) }, time) }) } function *createTime() { let time1 = yield delay(1000) let time2 try { time2 = yield delay(2000) } catch (error) { time2 = error } let time3 = yield delay(3000) console.log(time1, time2, time3) }
可以看出生成器函式其實和 async/await 語法長得很像,只要改一下 async/await 程式碼片段就是生成器函數了
async function createTime() { let time1 = await delay(1000) let time2 try { time2 = await delay(2000) } catch (error) { time2 = error } let time3 = await delay(3000) console.log(time1, time2, time3) } function transform(async) { let str = async.toString() str = str.replace(/async\s+(function)\s+/, '$1 *').replace(/await/g, 'yield') return str }