在同步式編程中,為了解決特定的問題,代碼被組織成一系列連貫的計(jì)算步驟乐横。其中每一個(gè)步驟都是阻塞的,即只有當(dāng)某個(gè)操作完成以后阳藻,才有可能繼續(xù)執(zhí)行下一個(gè)步驟晰奖。這種方式形成的代碼非常容易閱讀、理解和調(diào)試腥泥。
而在異步式編程中匾南,某些操作比如讀取文件或者處理一個(gè)網(wǎng)絡(luò)請(qǐng)求,是在“后臺(tái)”啟動(dòng)和執(zhí)行的蛔外。當(dāng)我們調(diào)用某個(gè)異步操作后蛆楞,即使其并沒有執(zhí)行完畢溯乒,該異步操作之后的代碼指令也會(huì)立刻繼續(xù)執(zhí)行。
在這種情況下豹爹,我們就需要一種“通知”機(jī)制裆悄。當(dāng)異步操作執(zhí)行完畢,我們會(huì)收到通知臂聋,獲取該操作的結(jié)果并繼續(xù)之前定義的執(zhí)行流程光稼。在 Node.js 中,最基礎(chǔ)的通知機(jī)制就是回調(diào)函數(shù)孩等。它本質(zhì)上就是一種由 runtime 調(diào)用的帶有異步操作結(jié)果的函數(shù)艾君。
Callback 模式
回調(diào)函數(shù)是一種能夠傳遞操作結(jié)果的函數(shù),正是異步編程所需要的肄方。JavaScript 對(duì)于回調(diào)函數(shù)來說是一種理想的語言冰垄,函數(shù)是第一等對(duì)象,可以輕松地賦值給變量权她、作為參數(shù)傳遞給另一個(gè)函數(shù)虹茶、作為函數(shù)的返回值,以及存儲(chǔ)到數(shù)據(jù)結(jié)構(gòu)中隅要。
The continuation-passing style
在 JavaScript 中蝴罪,回調(diào)函數(shù)會(huì)作為參數(shù)傳遞給另一個(gè)函數(shù),并且在操作完成時(shí)連同結(jié)果一起被調(diào)用步清。即執(zhí)行結(jié)果被傳遞給另一個(gè)函數(shù)(callback)洲炊,而不是直接返回給調(diào)用者。這種方式在函數(shù)式編程里稱作 continuation-passing style (CPS)尼啡。
下面是一個(gè)非常簡(jiǎn)單的同步函數(shù):
function add(a, b) {
return a + b
}
和上述函數(shù)等效的 CPS 形式:
function addCps(a, b, callback) {
callback(a + b)
}
console.log('before')
addCps(1, 2, result => console.log(`Result: $result`))
console.log('after')
// => before
// => Result: $result
// => after
addCps
就是一個(gè)同步的 CPS 函數(shù)。
Asynchronous CPS
addCps
函數(shù)的異步版本:
function additionAsync(a, b, callback) {
setTimeout(() => callback(a + b), 100)
}
console.log('before')
additionAsync(1, 2, result => console.log(`Result: ${result}`))
console.log('after')
// => before
// => after
// => Result: 3
上面的代碼使用 setTimeout
來模擬回調(diào)函數(shù)的異步調(diào)用询微。由于 setTimeout
觸發(fā)的是異步操作崖瞭,它并不會(huì)等待回調(diào)函數(shù) callback 執(zhí)行,而是立即返回撑毛。將控制權(quán)交還給 additionAsync
進(jìn)而回到調(diào)用者身上书聚,執(zhí)行主程序中的第二個(gè) console.log
。當(dāng)異步操作執(zhí)行完畢后藻雌,程序從之前控制權(quán)轉(zhuǎn)移時(shí)的位置起恢復(fù)執(zhí)行雌续,callback 中的 console.log
被執(zhí)行。
總結(jié)一下就是胯杭,同步函數(shù)會(huì)阻塞其他操作步驟驯杜,直到其自身執(zhí)行完畢;異步函數(shù)會(huì)立即返回做个,它的執(zhí)行結(jié)果會(huì)在 event loop 的后續(xù)周期中傳遞給 handler(即回調(diào)函數(shù))鸽心。
同步 or 異步
指令的執(zhí)行順序取決于函數(shù)的自然屬性——同步還是異步滚局,這對(duì)于整個(gè)應(yīng)用流程的正確性和效率都有很大的影響。所以需要時(shí)刻注意避免制造矛盾和困惑顽频。
Unleashing Zalgo
一個(gè) API 最危險(xiǎn)的情形之一藤肢,就是有些時(shí)候表現(xiàn)為同步另一些情況下表現(xiàn)為異步。
import {readFile} from 'fs'
const cache = new Map()
function inconsistentRead(filename, cb) {
if (cache.has(filename)) {
// invoked synchronously
cb(cache.get(filename))
} else {
// asynchronous function
readFile(filename, 'utf8', (err, data) => {
cache.set(filename, data)
cb(data)
})
}
}
上述程序就是危險(xiǎn)的糯景。假如某個(gè)文件是第一次被讀取嘁圈,它會(huì)表現(xiàn)為異步操作,讀取文件設(shè)置緩存蟀淮;當(dāng)某個(gè)文件的內(nèi)容已經(jīng)存在于緩存中時(shí)最住,它會(huì)表現(xiàn)為同步操作。
參考下面的示例:
function createFileReader(filename) {
const listeners = []
inconsistentRead(filename, value => {
listeners.forEach(listener => listener(value))
})
return {
onDataReady: listener => listeners.push(listener)
}
}
const reader1 = createFileReader('data.txt')
reader1.onDataReady(data => {
console.log(`First call data: ${data}`)
const reader2 = createFileReader('data.txt')
reader2.onDataReady(data => {
console.log(`Second call data: ${data}`)
})
})
其中 createFileReader
函數(shù)會(huì)創(chuàng)建一個(gè)新的 { onDataReady: function() }
對(duì)象作為通知器灭贷,以幫助我們?yōu)槲募x取操作設(shè)置多個(gè) listener温学。若 inconsistentRead
是純異步操作,實(shí)際上 onDataReady
會(huì)先被調(diào)用甚疟,將傳入的 listener 添加到 listeners 列表中仗岖。之后 inconsistentRead
讀取文件內(nèi)容完畢,回調(diào)函數(shù) cb
執(zhí)行览妖,遍歷 listeners 列表并將讀取到的文件內(nèi)容傳給 listener轧拄。
實(shí)際的執(zhí)行結(jié)果為:
First call data: some data
第二次讀取同一個(gè)文件并沒有獲取到任何內(nèi)容。
原因在于讽膏,當(dāng) reader1
創(chuàng)建時(shí)檩电,inconsistentRead
函數(shù)表現(xiàn)為異步的,因?yàn)樵撐募堑谝淮伪蛔x取府树。因而 onDataReady
會(huì)在剛開始讀取文件時(shí)就將傳入的 listener 添加到 listeners 列表中俐末。文件讀取完畢后 listeners 中注冊(cè)的 listener 被調(diào)用。
reader2
創(chuàng)建時(shí)同一個(gè)文件的緩存內(nèi)容已經(jīng)存在奄侠,inconsistentRead
表現(xiàn)為同步的卓箫。它的回調(diào)函數(shù)會(huì)立即調(diào)用,遍歷 listeners 列表垄潮。然而我們是先創(chuàng)建的 reader2
再添加的 listener烹卒,這就導(dǎo)致遍歷 listeners 列表時(shí),向 listeners 添加 listener 的操作還沒有執(zhí)行弯洗,我們傳入的 listener 并沒有來得及注冊(cè)旅急。
在實(shí)際的應(yīng)用中,上述類型的 bug 會(huì)非常難以定位和復(fù)現(xiàn)牡整。npm 的創(chuàng)造者 Isaac Z. Schlueter 將類似的使用不可預(yù)測(cè)函數(shù)的行為藐吮,叫做 unleashing Zalgo。
使用同步 API
想修復(fù)前面的 inconsistentRead
函數(shù),一種可能的方案就是令其徹底變成同步的炎码。實(shí)際上 Node.js 針對(duì)基礎(chǔ)的 I/O 操作提供了一系列同步的 API盟迟。比如 fs.readFileSync
。
import {readFileSync} from 'fs'
const cache = new Map()
function consistentReadSync(filename) {
if (cache.has(filename)) {
return cache.get(filename)
} else {
const data = readFileSync(filename)
cache.set(filename, data)
return data
}
}
但是潦闲,使用同步 API 而不是異步 API 也有一定的風(fēng)險(xiǎn):
- 針對(duì)特定功能的同步 API 有可能不存在
- 同步 API 會(huì)阻塞 event loop攒菠,暫停任何并發(fā)請(qǐng)求。從而破壞 Node.js 的并發(fā)模型并拖慢整個(gè)應(yīng)用
在很多情況下歉闰,使用同步 I/O 操作在 Node.js 里都是非常不推薦的辖众。但在一些場(chǎng)景下,同步 I/O 可能是最簡(jiǎn)單和高效的方案和敬。比如在應(yīng)用啟動(dòng)時(shí)使用同步阻塞 API 加載配置文件凹炸。
通過延遲執(zhí)行保證異步性
另一種修復(fù) inconsistentRead
函數(shù)的方案就是,將其變成純異步操作昼弟。訣竅就是將同步的回調(diào)函數(shù)延期到“未來”執(zhí)行啤它,而不是在同一個(gè) event loop 周期里立即被調(diào)用。
在 Node.js 中舱痘,可以通過 process.nextTick()
來實(shí)現(xiàn)变骡。它會(huì)接收一個(gè)回調(diào)函數(shù)作為參數(shù),將其推入到事件隊(duì)列頂部芭逝,位于所有 pending 的 I/O 事件之前塌碌,然后立即返回⊙ⅲ回調(diào)函數(shù)會(huì)在 event loop 再次收回控制權(quán)時(shí)立即被調(diào)用台妆。
import {readFile} from 'fs'
const cache = new Map()
function inconsistentRead(filename, callback) {
if (cache.has(filename)) {
// deferred callback invocation
process.nextTick(() => callback(cache.get(filename)))
} else {
// asynchronous function
readFile(filename, 'utf8', (err, data) => {
cache.set(filename, data)
callback(data)
})
}
}
Node.js 回調(diào)函數(shù)的最佳實(shí)踐
回調(diào)函數(shù)出現(xiàn)在最后
在所有核心的 Node.js 函數(shù)中,當(dāng)其接收一個(gè)回調(diào)函數(shù)作為輸入時(shí)胖翰,回調(diào)函數(shù)必須作為最后一個(gè)參數(shù)傳入接剩。
readFile(filename, [options], callback)
error 總是出現(xiàn)在前面
在 Node.js 中,任何 CPS 函數(shù)產(chǎn)生的錯(cuò)誤都必須作為回調(diào)函數(shù)的第一個(gè)參數(shù)傳遞萨咳,任何實(shí)際的執(zhí)行結(jié)果都從第二個(gè)參數(shù)開始搂漠。
readFile('foo.txt', 'utf8', (err, data) => {
if (err) {
handleError(err)
} else {
processData(data)
}
})
最佳實(shí)踐還在于總是檢查 error 是否存在,以及 error 的定義必須是 Error
類型某弦。
傳遞 error
在同步的函數(shù)中,傳遞 error 可以通過常用的 throw
語句而克。而在異步的 CPS 函數(shù)中靶壮,則可以簡(jiǎn)單地將 error 傳遞給鏈條上的下一個(gè)回調(diào)函數(shù)。
import {readFile} from 'fs'
function readJSON(filename, callack) {
readFile(filename, 'utf8', (err, data) => {
let parsed
if (err) {
// propagate the error and exit the current function
return callack(err)
}
try {
// parse the file contents
parsed = JSON.parse(data)
} catch (err) {
// catch parsing errors
return callack(err)
}
// no errors, propagate just the data
callack(null, parsed)
})
}
觀察者模式
在 Node.js 中另外一種非常重要和基礎(chǔ)的模式就是觀察者(Ovserver)模式员萍。同 Reactor 模式腾降、回調(diào)函數(shù)一起,它們都是掌握 Node.js 異步編程的絕對(duì)要求碎绎。
觀察者模式定義了一類稱為 subject 的對(duì)象螃壤,它們可以在狀態(tài)改變時(shí)向一系列稱為觀察者的對(duì)象發(fā)送通知抗果。它是對(duì)回調(diào)函數(shù)的完美補(bǔ)充。主要區(qū)別在于 subject 能夠通知多個(gè)觀察者奸晴,而傳統(tǒng)的 CPS 回調(diào)函數(shù)通常只會(huì)將結(jié)果傳遞給一個(gè) listener冤馏。
EventEmitter
觀察者模式實(shí)際上已經(jīng)通過 EventEmitter
類內(nèi)置到 Node.js 的核心中了。EventEmitter
類允許我們注冊(cè)一個(gè)或者多個(gè)函數(shù)作為 listener寄啼,這些 listener 會(huì)在特定的事件觸發(fā)時(shí)自動(dòng)被調(diào)用逮光。
EventEmitter
類的基礎(chǔ)方法如下:
-
on(event, listener)
:該方法允許我們?yōu)橹付ǖ氖录愋停ㄒ粋€(gè)字符串)注冊(cè)一個(gè)新的 listener(一個(gè)函數(shù)) -
once(event, listener)
:該方法允許我們注冊(cè)一個(gè)新的 listener,并且該 listener 會(huì)在事件觸發(fā)一次之后自動(dòng)被移除 -
emit(event, [arg1], [...])
:該方法會(huì)產(chǎn)生一個(gè)新的事件墩划,并向指定向 listeners 傳遞的額外的參數(shù) -
removeListener(event, listener)
:該方法用來移除某個(gè) listener
上述所有的方法都會(huì)返回一個(gè) EventEmitter
實(shí)例并允許被串聯(lián)起來涕刚。
創(chuàng)建和使用 EventEmitter
import {EventEmitter} from 'events'
import {readFile} from 'fs'
function findRegex(files, regex) {
const emitter = new EventEmitter()
for (const file of files) {
readFile(file, 'utf8', (err, content) => {
if (err) {
return emitter.emit('error', err)
}
emitter.emit('fileread', file)
const match = content.match(regex)
if (match) {
match.forEach(elem => emitter.emit('found', file, elem))
}
})
}
return emitter
}
findRegex(['fileA.txt', 'fileB.json'], /hello \w+/g)
.on('fileread', file => console.log(`${file} was read`))
.on('found', (file, match) => console.log(`Matched "${match}" in ${file}`))
.on('error', err => console.error(`Error emitted ${err.message}`))
令任意對(duì)象變得“可監(jiān)測(cè)”
在 Node.js 的世界里,EventEmitter
很少像上面的例子那樣被直接使用乙帮。更為常見的情況是其他類繼承 EventEmitter
從而變成一個(gè)可監(jiān)測(cè)的對(duì)象杜漠。
import {EventEmitter} from 'events'
import {readFile} from 'fs'
class FindRegex extends EventEmitter {
constructor(regex) {
super()
this.regex = regex
this.files = []
}
addFile(file) {
this.files.push(file)
return this
}
find() {
for (const file of this.files) {
readFile(file, 'utf8', (err, content) => {
if (err) {
return this.emit('error', err)
}
this.emit('fileread', file)
const match = content.match(this.regex)
if (match) {
match.forEach(elem => this.emit('found', file, elem))
}
})
}
return this
}
}
const findRegexInstance = new FindRegex(/hello \w+/g)
findRegexInstance
.addFile('fileA.txt')
.addFile('fileB.json')
.find()
.on('found', (file, match) => console.log(`Matched "${match}" in file ${file}`))
.on('error', err => console.error(`Error emitted ${err.message}`))
EventEmitter vs Callback
以下的幾點(diǎn)可以作為選擇 EventEmitter 還是 Callback 的依據(jù):
- 當(dāng)涉及到需要支持不同類型的事件時(shí),Callback 會(huì)有一定的限制察净。實(shí)際上 Callback 也可以區(qū)分多個(gè)事件驾茴,只需要將事件類型作為參數(shù)傳給回調(diào)函數(shù),或者接收多個(gè)回調(diào)函數(shù)塞绿。但在這樣的情況下沟涨,EventEmitter 可以提供更優(yōu)雅的接口和更精簡(jiǎn)的代碼
- 當(dāng)同樣的事件可能多次發(fā)生或者根本不會(huì)發(fā)生時(shí),應(yīng)該使用 EventEmitter异吻。而無論操作是否成功裹赴,回調(diào)函數(shù)都只會(huì)被調(diào)用一次
- 回調(diào)函數(shù)機(jī)制只支持通知一個(gè)特定的 listener,而 EventEmitter 允許我們?yōu)橥粋€(gè)事件注冊(cè)多個(gè) listener