回調(diào)函數(shù)(Callbacks)是 Node.js 中異步編程的底層構(gòu)件泡垃,但它們遠(yuǎn)遠(yuǎn)達(dá)不到對(duì)用戶友好的程度旨怠。對(duì)于實(shí)現(xiàn)代碼中最常見的串行控制流渠驼,一個(gè)未經(jīng)訓(xùn)練的開發(fā)者很容易陷入到 callback hell 問(wèn)題中。即便實(shí)現(xiàn)是正確的鉴腻,該串行控制流也會(huì)顯得不必要的復(fù)雜和脆弱迷扇。
為了獲得更好的異步編程體驗(yàn),第一個(gè)出現(xiàn)的就是 promise爽哎,一種保存了異步操作的狀態(tài)和最終結(jié)果的對(duì)象蜓席。Promise 可以輕易地被串聯(lián)起來(lái),實(shí)現(xiàn)串行控制流倦青,可以像其他任何對(duì)象一樣自由地轉(zhuǎn)移瓮床。Pormise 大大簡(jiǎn)化了異步代碼,后來(lái)在此基礎(chǔ)上又有了 async 和 await产镐,能夠令異步代碼看起來(lái)就像是同步代碼一樣隘庄。
Promises
Promises 是 ECMAScript 2015 標(biāo)準(zhǔn)(ES6)的一部分,為傳遞異步結(jié)果提供了一種健壯的解決方案癣亚,替代原本的 CPS 樣式的回調(diào)函數(shù)丑掺。Promise 能夠令所有主要的異步控制流更加易讀、簡(jiǎn)潔和健壯述雾。
Promise 是一種用來(lái)代表異步操作的最終結(jié)果(或錯(cuò)誤)的對(duì)象街州。在專業(yè)術(shù)語(yǔ)中,當(dāng)異步操作未完成時(shí)玻孟,我們稱 Promise 是 pending 的唆缴;當(dāng)異步操作成功結(jié)束時(shí),Promise 是 fulfilled 的黍翎;當(dāng)異步操作因?yàn)殄e(cuò)誤終止時(shí)面徽,Promise 是 rejected 的;當(dāng) Promise 或者是 fulfilled 或者是 rejected,則將其認(rèn)定為 settled趟紊。
Promise 對(duì)象的 then()
方法可以獲取成功執(zhí)行后的結(jié)果或者終止時(shí)報(bào)出的錯(cuò)誤:
promise.then(onFulfilled, onRejected)
其中 onFulfilled
是一個(gè)回調(diào)函數(shù)氮双,最終會(huì)接收到 Promise 成功時(shí)的值;onRejected
是另一個(gè)回調(diào)函數(shù)霎匈,最終會(huì)接收 Promise 異常終止時(shí)的值(如果有的話)戴差。
基于回調(diào)函數(shù)的如下代碼:
asyncOperation(arg, (err, result) => {
if (err) {
// handle the error
}
// do stuff with the result
})
Promise 實(shí)現(xiàn)上述同樣的功能,則更加優(yōu)雅铛嘱、結(jié)構(gòu)化:
asyncOperationPromise(arg)
.then(result => {
// do stuff with result
}, err => {
// handle the error
})
asyncOperationPromise()
會(huì)返回一個(gè) Promise暖释,可以被用來(lái)獲取最終結(jié)果的值或者失敗的原因。但最為關(guān)鍵的屬性是墨吓,then()
方法會(huì)同步地返回另一個(gè) Promise饭入。
更進(jìn)一步地,如果 onFulfilled
或者 onRejected
函數(shù)返回一個(gè)值 x
肛真,那么 then()
方法返回的 Promise 會(huì)有以下行為:
- 若
x
是一個(gè)值,則then()
返回的 Promise 使用x
作為自身完成時(shí)的值 - 若
x
是一個(gè) Promise 且成功完成爽航,則x
完成時(shí)返回的值作為then()
返回的 Promise 完成時(shí)的值 - 若
x
是一個(gè) Promise 且因?yàn)殄e(cuò)誤終止蚓让,則x
終止的原因作為then()
返回的 Promise 終止的原因
上述行為能夠令我們將多個(gè) promise 連接成鏈,輕松地將異步操作聚合在一起讥珍。如果我們沒(méi)有指定一個(gè) onFulfilled
或者 onRejected
handler历极,Promise 完成時(shí)的值或者終止時(shí)的原因都會(huì)自動(dòng)地傳遞給鏈條中的下一個(gè) Promise。通過(guò) Promise 鏈衷佃,任務(wù)的執(zhí)行順序突然變得很簡(jiǎn)單趟卸。
asyncOperationPromise(arg)
.then(result1 => {
// return another promise
return asyncOperationPromise(arg2)
})
.then(result2 => {
// return a value
return 'done'
})
.then(undefined, err => {
// any error in the chain is caught here
})
promise API
Promise 構(gòu)造函數(shù)(new Promise((resolve, reject) => {})
)會(huì)創(chuàng)建一個(gè)新的 Promise 實(shí)例,其完成還是終止取決于作為參數(shù)傳入的函數(shù)的行為氏义。
作為參數(shù)傳入的函數(shù)接收如下兩個(gè)參數(shù):
-
resolve(obj)
:resolve 是一個(gè)函數(shù)锄列,在調(diào)用時(shí)為 Promise 提供完成時(shí)的值。當(dāng)obj
是值時(shí)惯悠,則obj
本身作為 Promise 完成時(shí)的值邻邮;當(dāng)obj
是另一個(gè) Promise 時(shí),則obj
完成時(shí)的值作為當(dāng)前 Promise 完成時(shí)的值 -
reject(err)
:Promise 因?yàn)?err
終止
function delay(milliseconds) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(new Date())
}, milliseconds)
})
}
console.log(`${new Date().getSeconds()}s\nDelaying...`)
delay(1000)
.then(newDate => {
console.log(`${newDate.getSeconds()}s`)
})
Promise 最重要的靜態(tài)方法:
-
Promise.resolve(obj)
:從另一個(gè) Promise克婶、thenable 對(duì)象或者值創(chuàng)建一個(gè)新的 Promise -
Promise.reject(err)
:創(chuàng)建一個(gè) Promise筒严,該 Promise 會(huì)因?yàn)?err
終止 -
Promise.all(iterable)
:從一個(gè)可迭代對(duì)象創(chuàng)建 Promise,若該 iterable 中的每一項(xiàng)都提供了一個(gè) fulfill 值情萤,則 Promise 最終以包含這些值的列表作為 fulfill 值鸭蛙;若其中有任意一項(xiàng) reject,則 Promise.all() 返回的 Promise 以第一個(gè) reject 的 err 終止 -
Promise.allSettled(iterable)
:此方法會(huì)等待所有輸入的 Promise 或者 fulfill 或者 reject筋岛,之后返回一個(gè)包含所有 fulfill 值和 reject 原因的列表 -
Promise.race(iterable)
:返回可迭代對(duì)象中第一個(gè) fulfill 或 reject 的 Promise
Promise 關(guān)鍵的實(shí)例方法:
-
promise.catch(onRejected)
:實(shí)際上就是promise.then(undefined, onRejected)
的語(yǔ)法糖 -
promise.finally(onFinally)
:允許我們?cè)O(shè)置一個(gè)onFinally
回調(diào)函數(shù)娶视,在promise
fulfill 或者 reject 時(shí)調(diào)用
順序執(zhí)行
順序執(zhí)行意味著,每次只執(zhí)行一系列任務(wù)中的一個(gè)泉蝌,完成后再依次執(zhí)行后面的任務(wù)歇万。這一系列任務(wù)的先后順序必須是預(yù)先定義好的揩晴,因?yàn)橐粋€(gè)任務(wù)的結(jié)果有可能影響后續(xù)任務(wù)的執(zhí)行。
上述執(zhí)行流程有著不同形式的變種:
- 順序執(zhí)行一系列已知的任務(wù)贪磺,不需要在它們之間傳遞數(shù)據(jù)
- 前一個(gè)任務(wù)的輸出作為后一個(gè)任務(wù)的輸入(chain硫兰、pipeline、waterfall)
- 迭代任務(wù)集合寒锚,同時(shí)在每個(gè)元素上一個(gè)接一個(gè)地運(yùn)行異步任務(wù)
package.json
:
{
"name": "03-promises-web-spider-v2",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"cheerio": "^1.0.0-rc.3",
"mkdirp": "^0.5.1",
"superagent": "^5.2.2",
"slug": "^1.1.0"
},
"engines": {
"node": ">=14"
},
"engineStrict": true
}
spider.js
:
import {promises as fsPromises} from 'fs'
import {dirname} from 'path'
import superagent from 'superagent'
import mkdirp from 'mkdirp'
import {urlToFilename, getPageLinks} from './utils.js'
import {promisify} from 'util'
const mkdirpPromises = promisify(mkdirp)
function download(url, filename) {
console.log(`Downloading ${url}`)
let content
return superagent.get(url)
.then((res) => {
content = res.text
return mkdirpPromises(dirname(filename))
})
.then(() => fsPromises.writeFile(filename, content))
.then(() => {
console.log(`Downloaded and saved: ${url}`)
return content
})
}
function spiderLinks(currentUrl, content, nesting) {
let promise = Promise.resolve()
if (nesting === 0) {
return promise
}
const links = getPageLinks(currentUrl, content)
for (const link of links) {
promise = promise.then(() => spider(link, nesting - 1))
}
return promise
}
export function spider(url, nesting) {
const filename = urlToFilename(url)
return fsPromises.readFile(filename, 'utf8')
.catch((err) => {
if (err.code !== 'ENOENT') {
throw err
}
// The file doesn't exist, so let’s download it
return download(url, filename)
})
.then(content => spiderLinks(url, content, nesting))
}
spider-cli.js
:
import {spider} from './spider.js'
const url = process.argv[2]
const nesting = Number.parseInt(process.argv[3], 10) || 1
spider(url, nesting)
.then(() => console.log('Download complete'))
.catch(err => console.error(err))
utils.js
:
import {join, extname} from 'path'
import {URL} from 'url'
import slug from 'slug'
import cheerio from 'cheerio'
function getLinkUrl(currentUrl, element) {
const parsedLink = new URL(element.attribs.href || '', currentUrl)
const currentParsedUrl = new URL(currentUrl)
if (parsedLink.hostname !== currentParsedUrl.hostname ||
!parsedLink.pathname) {
return null
}
return parsedLink.toString()
}
export function urlToFilename(url) {
const parsedUrl = new URL(url)
const urlPath = parsedUrl.pathname.split('/')
.filter(function (component) {
return component !== ''
})
.map(function (component) {
return slug(component, {remove: null})
})
.join('/')
let filename = join(parsedUrl.hostname, urlPath)
if (!extname(filename).match(/htm/)) {
filename += '.html'
}
return filename
}
export function getPageLinks(currentUrl, body) {
return Array.from(cheerio.load(body)('a'))
.map(function (element) {
return getLinkUrl(currentUrl, element)
})
.filter(Boolean)
}
node spider-cli.js http://www.baidu.com 2
其中的 spiderLinks()
函數(shù)通過(guò)循環(huán)動(dòng)態(tài)地構(gòu)建了一條 Promise 鏈:
- 先定義一個(gè)“空的” Promise 對(duì)象(resovle 到
undefined
)劫映,這個(gè)空 Promise 只是作為鏈條的起點(diǎn) - 在循環(huán)中,不斷將
promise
變量更新為新的 Promise 對(duì)象(通過(guò)調(diào)用上一個(gè) Promise 的then()
方法得到)刹前。這就是 Promise 的異步遍歷模式
在 for
循環(huán)的最后泳赋,promise
變量會(huì)是最后一個(gè) then()
方法返回的 Promise,因而只有當(dāng)鏈條中的所有 Promise 都 resolve 時(shí)喇喉,promise
才會(huì) resolve祖今。
縱觀所有代碼,我們可以不需要像使用 callback 那樣拣技,強(qiáng)制地包含眾多錯(cuò)誤傳遞邏輯千诬。因而大大減少了代碼量和出錯(cuò)的機(jī)會(huì)。
并行執(zhí)行
在某些情況下膏斤,一系列異步任務(wù)的執(zhí)行順序并不重要徐绑,我們需要的只是當(dāng)所有的任務(wù)都完成后能收到通知。
雖然 Node.js 是單線程的莫辨,但得益于其 non-blocking nature傲茄,我們?nèi)钥梢詫?shí)現(xiàn)并發(fā)行為。
比如我們有一個(gè) Main 函數(shù)需要執(zhí)行兩個(gè)異步任務(wù):
- Main 函數(shù)首先觸發(fā)異步任務(wù) Task1 和 Task2 的執(zhí)行沮榜。異步任務(wù)觸發(fā)后盘榨,會(huì)將程序控制權(quán)立即交還給 Main 函數(shù),再轉(zhuǎn)交給 event loop
- 當(dāng) Task1 中的異步任務(wù)結(jié)束時(shí)蟆融,event loop 調(diào)用 Task1 的回調(diào)函數(shù)较曼,將控制權(quán)交給 Task1。Task1 執(zhí)行完成自身內(nèi)部的同步指令振愿,通知 Main 函數(shù)并返還控制權(quán)
- 當(dāng) Task2 中的異步任務(wù)結(jié)束時(shí)捷犹,event loop 調(diào)用 Task2 的回調(diào)函數(shù),將控制權(quán)交給 Task2冕末。在 Task2 的終點(diǎn)萍歉,Main 函數(shù)再次被通知。Main 函數(shù)得知 Task1 和 Task2 全部結(jié)束档桃,繼續(xù)執(zhí)行或者返回結(jié)果
簡(jiǎn)單來(lái)說(shuō)枪孩,在 Node.js 中,我們只能并發(fā)地執(zhí)行異步操作,因?yàn)樗鼈兊牟l(fā)行為是由內(nèi)部的非阻塞 API 控制的蔑舞。同步(阻塞)操作無(wú)法并發(fā)地執(zhí)行拒担,除非它們的執(zhí)行與異步操作交織在一起,或者由 setTimeout()
攻询、setImmediate()
包裹从撼。
Promise 實(shí)現(xiàn)并發(fā)執(zhí)行流,可以借助內(nèi)置的 Promise.all()
方法钧栖。該方法會(huì)返回一個(gè)新的 Promise低零,只有當(dāng)所有傳入的 Promise 都 fulfill 時(shí),新 Promise 才會(huì) fulfill拯杠。如果傳入的 Promise 之間沒(méi)有因果關(guān)系掏婶,這些 Promise 就會(huì)并發(fā)地執(zhí)行。
對(duì)于前面的 spider 應(yīng)用潭陪,只需要將 spiderLinks()
函數(shù)改為如下形式:
function spiderLinks(currentUrl, content, nesting) {
if (nesting === 0) {
return Promise.resolve()
}
const links = getPageLinks(currentUrl, content)
const promises = links.map(link => spider(link, nesting - 1))
return Promise.all(promises)
}
Async/await
Promise 鏈相對(duì)于 callback hell 來(lái)說(shuō)肯定是要好太多的雄妥,但是我們?nèi)匀恍枰{(diào)用 then()
方法,以及為鏈條中的每一個(gè)任務(wù)創(chuàng)建新的函數(shù)依溯,對(duì)于日常編程中非常普遍的控制流來(lái)說(shuō)還是比較麻煩茎芭。而 Async/await 可以幫助我們寫出像同步代碼一樣可讀性強(qiáng)、容易理解的異步代碼誓沸。
Async 函數(shù)是一種特殊的函數(shù),在函數(shù)體里面可以使用 await
表達(dá)式“暫鸵妓冢”任意一個(gè) Promise 的執(zhí)行拜隧,將控制權(quán)交還給 async 函數(shù)的調(diào)用者,等該 Promise revolve 后再返回到暫停的地方繼續(xù)執(zhí)行趁仙。
function delay(milliseconds) {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(new Date())
}, milliseconds)
})
}
async function playingWithDelays() {
console.log('Initial date: ', new Date())
const dateAfterOneSecond = await delay(1000)
console.log('Date after one second: ', dateAfterOneSecond)
const dateAfterThreeSeconds = await delay(3000)
console.log('Date after 3 secnods: ', dateAfterThreeSeconds)
return 'done'
}
playingWithDelays()
.then(result => {
console.log(`After 4 seconds: ${result}`)
})
錯(cuò)誤處理
Async/await 的另一個(gè)巨大的優(yōu)勢(shì)在于洪添,它能夠標(biāo)準(zhǔn)化 try...catch
代碼塊的行為,不管是針對(duì)同步代碼中的 throw
雀费,抑或是異步代碼中的 Promise reject干奢。
function delayError(milliseconds) {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error(`Error after ${milliseconds}ms`))
})
})
}
async function playingWithErrors(throwSyncError) {
try {
if (throwSyncError) {
throw new Error('This is a synchronous error')
}
await delayError(1000)
} catch (err) {
console.log(`We have an error: ${err.message}`)
} finally {
console.log('Done')
}
}
// playingWithErrors(true)
playingWithErrors(false)
串行執(zhí)行
借助 Async/await,可以對(duì)之前的 spider 應(yīng)用實(shí)現(xiàn)很多優(yōu)化盏袄。比如 download()
函數(shù):
async function download(url, filename) {
console.log(`Downloading ${url}`)
const {text: content} = await superagent.get(url)
await mkdirpPromises(dirname(filename))
await fsPromises.writeFile(filename, content)
console.log(`Downloaded and saved: ${url}`)
return content
}
整段代碼行數(shù)大大減少忿峻,看起來(lái)也很“平整”,沒(méi)有任何層級(jí)和縮進(jìn)辕羽。
接下來(lái)是 spiderLinks()
函數(shù)逛尚,使用 async/await 異步地遍歷一個(gè)列表:
async function spiderLinks(currentUrl, content, nesting) {
if (nesting === 0) {
return
}
const links = getPageLinks(currentUrl, content)
for (const link of links) {
await spider(link, nesting - 1)
}
}
然后是 spider()
函數(shù),如何簡(jiǎn)單地通過(guò) try...catch
處理錯(cuò)誤刁愿,令異步代碼更加易讀:
export async function spider(url, nesting) {
const filename = urlToFilename(url)
let content
try {
content = await fsPromises.readFile(filename, 'utf8')
} catch (err) {
if (err.code !== 'ENOENT') {
throw err
}
content = await download(url, filename)
}
return spiderLinks(url, content, nesting)
}
并行執(zhí)行
使用純 async/await 實(shí)現(xiàn)并行的異步執(zhí)行流程绰寞,可以參考如下代碼:
async function spiderLinks(currentUrl, content, nesting) {
if (nesting === 0) {
return
}
const links = getPageLinks(currentUrl, content)
const promises = links.map(link => spider(link, nesting - 1))
for (const promise of promises) {
await promise
}
}
然而上述代碼存在一定的問(wèn)題。如果列表中有一個(gè) Promise reject 了,我們不得不等待列表中其他所有的 Promise 都 resolve滤钱,spiderLinks()
函數(shù)返回的 Promise 才會(huì) reject觉壶。這種行為在多數(shù)情況下都是不理想的。
我們通常都會(huì)想要在操作發(fā)生錯(cuò)誤的第一時(shí)間捕獲錯(cuò)誤信息件缸。因而并行執(zhí)行異步操作铜靶,最后仍建議使用下面形式的代碼:
async function spiderLinks(currentUrl, content, nesting) {
if (nesting === 0) {
return
}
const links = getPageLinks(currentUrl, content)
const promises = links.map(link => spider(link, nesting - 1))
return Promise.all(promises)
}