在學習本文內容之前,我們必須先了解非同步的概念,首先要強調的是非同步和並行有著本質上的差異。
並行,一般指並行計算,是說同一時刻有多條指令同時被執行,這些指令可能執行於同一CPU
的多核上,或者多個CPU
上,或者多個物理主機甚至多個網絡中。
同步,一般指依照預定的順序依序執行任務,只有當上一個任務完成後,才開始執行下一個任務。
異步,與同步相對應,異步指的是讓CPU
暫時擱置當前任務,先處理下一個任務,當收到上個任務的回調通知後,再返回上個任務繼續執行,整個過程無需第二個線程參與。
或許用圖片的方式解釋並行、同步和非同步更為直觀,假設現在有A、B兩個任務需要處理,使用並行、同步和非同步的處理方式會分別採用如下圖所示的執行方式:
JavaScript
為我們提供了許多非同步的函數,這些函數允許我們方便的執行非同步任務,也就是說,我們現在開始執行一個任務(函數),但任務會在稍後完成,具體完成時間並不清楚。
例如, setTimeout
函數就是一個非常典型的非同步函數,此外, fs.readFile
、 fs.writeFile
同樣也是非同步函數。
我們可以自己定義一個非同步任務的案例,例如自訂一個檔案複製函數copyFile(from,to)
from,
to) : const fs = require('fs')function copyFile(from, to) { fs.readFile(from, (err, data) => { if (err) { console.log(err.message) return } fs.writeFile(to, data, (err) => { if (err) { console.log(err.message) return } console.log('Copy finished') }) })}
函數copyFile
先從參數from
讀取檔案數據,接著將數據寫入參數to
指向的檔案。
我們可以像這樣呼叫copyFile
:
copyFile('./from.txt','./to.txt')//複製檔案
如果這個時候, copyFile(...)
後面還有其他程式碼,那麼程式不會等待copyFile
執行結束,而是直接向下執行,檔案複製任務何時結束,程式並不在乎。
copyFile('./from.txt','./to.txt')//下面的程式碼不會等待上面的程式碼執行結束...
執行到這裡,好像一切還都是正常的,但是,如果我們在copyFile(...)
函數後,直接存取檔案./to.txt
中的內容會發生什麼事?
這將不會讀到複製過來的內容,就行這樣:
copyFile('./from.txt','./to.txt')fs.readFile('./to.txt',(err,data)= >{ ...})
如果在執行程式之前, ./to.txt
檔案尚未創建,將得到以下錯誤:
PS E:CodeNodedemos 3-callback> node .index.js finished Copy finished PS E:CodeNodedemos 3-callback> node .index.js 錯誤:ENOENT: no such file or directory, open 'E:CodeNodedemos 3-callbackto.txt'Copy finished
即使./to.txt
存在,也無法讀取其中複製的內容。
造成這種現象的原因是: copyFile(...)
是異步執行的,程式執行到copyFile(...)
函數後,並不會等待其複製完畢,而是直接向下執行,從而導致出現文件./to.txt
不存在的錯誤,或檔案內容為空白錯誤(如果事先建立檔案)。
非同步函數的具體執行結束的時間是不能確定的,例如readFile(from,to)
函數的執行結束時間大機率取決於檔案from
的大小。
那麼,問題在於我們如何才能準確的定位copyFile
執行結束,從而讀取to
檔案中的內容呢?
這就需要使用回呼函數,我們可以修改copyFile
函數如下:
function copyFile(from, to, callback) { fs.readFile(from, (err, data) => { if (err) { console.log(err.message) return } fs.writeFile(to, data, (err) => { if (err) { console.log(err.message) return } console.log('Copy finished') callback()//複製作業完成後呼叫回調函數}) })}
這樣,我們如果需要在檔案複製完成後,立即執行一些操作,就可以把這些運算寫入回呼函數中:
function copyFile(from, to, callback) { fs.readFile(from, (err, data) => { if (err) { console.log(err.message) return } fs.writeFile(to, data, (err) => { if (err) { console.log(err.message) return } console.log('Copy finished') callback()//複製作業完成後呼叫回調函數}) })}copyFile('./from.txt', './to.txt', function () { //傳入一個回呼函數,讀取「to.txt」檔案中的內容並輸出fs.readFile('./to.txt', (err, data) => { if (err) { console.log(err.message) return } console.log(data.toString()) })})
如果,你已經準備好了./from.txt
文件,那麼以上程式碼就可以直接運行:
PS E:CodeNodedemos 3-callback> node .index.js Copy finished 加入社區“仙宗”,和我一起修仙吧社區地址:http://t.csdn.cn/EKf1h
這種編程方式被稱為“基於回調”的異步編程風格,異步執行的函數應當提供一個回調參數用於在任務結束後呼叫。
這種風格在JavaScript
程式設計中普遍存在,例如檔案讀取函數fs.readFile
、 fs.writeFile
都是非同步函數。
回呼函數可以準確的在非同步工作完成後處理後繼事宜,如果我們需要依序執行多個非同步操作,就需要嵌套回呼函數。
案例場景:依序讀取檔案A與檔案B
程式碼實作:
fs.readFile('./A.txt', (err, data) => { if (err) { console.log(err.message) return } console.log('讀取檔A:' + data.toString()) fs.readFile('./B.txt', (err, data) => { if (err) { console.log(err.message) return } console.log("讀取檔案B:" + data.toString()) })})
執行效果:
PS E:CodeNodedemos 3-callback> node .index.js 讀取檔案A:仙宗無限好,只是缺了佬讀取檔案B:要想入仙宗,連結不能少http://t.csdn.cn/H1faI
透過回呼的方式,就可以在讀取文件A之後,緊接著讀取檔案B。
如果我們還想在檔案B之後,繼續讀取檔案C呢?這就需要繼續嵌套回呼:
fs.readFile('./A.txt', (err, data) => {//第一次回呼if (err) { console.log(err.message) return } console.log('讀取檔A:' + data.toString()) fs.readFile('./B.txt', (err, data) => {//第二次回呼if (err) { console.log(err.message) return } console.log("讀取檔案B:" + data.toString()) fs.readFile('./C.txt',(err,data)=>{//第三次回呼... }) })})
也就是說,如果我們想要依序執行多個非同步操作,需要多層嵌套回調,這在層數較少時是行之有效的,但是當嵌套次數過多時,會出現一些問題。
回呼的約定
實際上, fs.readFile
中的回呼函數的樣式並非個例,而是JavaScript
中的普遍約定。我們日後會自訂大量的回呼函數,也需要遵守這個約定,形成良好的編碼習慣。
約定是:
callback
的第一個參數是為error 而保留的。一旦出現error, callback(err)
就會被呼叫。callback(null, result1, result2,...)
就會被呼叫。基於上述約定,一個回呼函數擁有錯誤處理和結果接收兩個功能,例如fs.readFile('...',(err,data)=>{})
的回呼函數就遵循了這個約定。
如果我們不深究的話,基於回呼的非同步方法處理似乎是相當完美的處理方式。問題在於,如果我們有一個接一個的異步行為,那麼程式碼就會變成這樣:
fs.readFile('./a.txt',(err,data)=>{ if(err){ console.log(err.message) return } //讀取結果操作fs.readFile('./b.txt',(err,data)=>{ if(err){ console.log(err.message) return } //讀取結果操作fs.readFile('./c.txt',(err,data)=>{ if(err){ console.log(err.message) return } //讀取結果操作fs.readFile('./d.txt',(err,data)=>{ if(err){ console.log(err.message) return } … }) }) })})
以上程式碼的執行內容是:
隨著呼叫的增加,程式碼嵌套層級越來越深,包含越來越多的條件語句,從而形成不斷向右縮排的混亂程式碼,難以閱讀和維護。
我們稱這種不斷向右增長(向右縮排)的現象為「回調地獄」或「末日金字塔」!
fs.readFile('a.txt',(err,data)=>{ fs.readFile('b.txt',(err,data)=>{ fs.readFile('c.txt',(err,data)=>{ fs.readFile('d.txt',(err,data)=>{ fs.readFile('e.txt',(err,data)=>{ fs.readFile('f.txt',(err,data)=>{ fs.readFile('g.txt',(err,data)=>{ fs.readFile('h.txt',(err,data)=>{ … /* 通往地獄的大門 ===> */ }) }) }) }) }) }) })})
雖然以上程式碼看起來相當規整,但是這只是用來舉例的理想場面,通常業務邏輯中會有大量的條件語句、資料處理操作等程式碼,從而打亂當前美好的秩序,讓程式碼變的難以維護。
幸運的是, JavaScript
為我們提供了多種解決途徑, Promise
就是其中的最優解。