Chinese (Traditional) (中文(繁體)) translation by Stypstive (you can also view the original English article)
第一次接觸編程時,我們就知道了一塊代碼是從頭執行到尾的。 這就是所謂的同步編程:每個操作完成之後,後面的才會繼續。 對於不花計算機太多時間的操作,比如數字相加、操作字符串、或變量賦值等等,這種執行過程沒什麼問題。
但如果一個任務花的時間稍微長一點,你該怎麼辦呢?比如訪問磁盤上的一個文件,發送一個網絡請求,或等待一個計時器結束。 在同步編程中,這時候你的程序啥也做不了,只能乾等著。
對於一些簡單的情況,你的程序可以有多個實例同時在跑,或許還能忍受,但是對於很多服務器應用來說,這就是個噩夢。
進入異步編程 在異步執行的程序中,你的代碼在等待某件事的同時可以繼續執行,然後這件事發生了你又可以跳回去。
以網絡請求為例。 向一個較慢的服務器發送一個網絡請求,可能足足要花三秒鐘才能響應,你的程序可以在這個慢服務器響應的同時繼續干其它的事。 在這個例子中,三秒鐘對人來說或許算不了什麼,但服務器不一樣,它可能還等著響應上千個其它請求呢。 那麼,你要如何在Node.js中處理異步呢?
最基本的方式是使用回調。 一個回調其實就是一個函數,只不過它是在一個異步操作完成時被調用。 按慣例,Node.js的回調函數至少應該有一個參數,err
。 回調可以有更多的參數 (通常表示傳遞給回調函數的數據),但至少應該有一個是err
。 你可能已經猜到了,err
表示一個錯誤對象 (當發生了一個錯誤時就會產生這樣一個對象,後面還會提到)
我們來看一個非常簡單的例子。 我們要用到Node.js內置的文件系統模塊fs
。 在此腳本中,我們會去讀一個文本文件的內容。 此代碼的最後一行是一個console.log
,那麼問題來了:如果你執行這個腳本,你會在看到文件內容之前看到這個日誌結果嗎?
var fs = require('fs'); fs.readFile( 'a-text-file.txt', //the filename of a text file that says "Hello!" 'utf8', //the encoding of the file, in this case, utf-8 function(err,text) { //the callback console.log('Error:',err); //Errors, if any console.log('Text:',text); //the contents of the file } ); //Will this be before or after the Error / Text? console.log('Does this get logged before or after the contents of the text file?');
因為它是異步的,我們實際上會在看到文本內容之前就看到最後一句console.log
的執行了。 如果你在該腳本的同一目錄下有一個名為a-text-file.txt的文件,你會看到err
值為null
,而text
的值為此文本的內容。
如果不存在a-text-file.txt文件,err
為一個Error對象,而text
的值是undefined
。 這種情況產生了一類重要的回調:因為錯誤無處不在,你總是要處理它們,回調就是一種重要方式。 為處理錯誤,你需要檢查err
變量的值,如果它有非nul值,則說明有錯誤發生了。 一般來說,err
參數不會是false
,所以總可通過真值檢測來判斷是否有錯。
var fs = require('fs'); fs.readFile( 'a-text-file.txt', //the filename of a text file that says "Hello!" 'utf8', //the encoding of the file, in this case, utf-8 function(err,text) { //the callback if (err) { console.error(err); //display an error to the console } else { console.log('Text:',text); //no error, so display the contents of the file } } );
又比如說你想按照一定的順序展示兩個文件的內容。 你會得到類似於這樣的代碼:
var fs = require('fs'); fs.readFile( 'a-text-file.txt', //the filename of a text file that says "Hello!" 'utf8', //the encoding of the file, in this case, utf-8 function(err,text) { //the callback if (err) { console.error(err); //display an error to the console } else { console.log('First text file:',text); //no error, so display the contents of the file fs.readFile( 'another-text-file.txt', //the filename of a text file that says "Hello!" 'utf8', //the encoding of the file, in this case, utf-8 function(err,text) { //the callback if (err) { console.error(err); //display an error to the console } else { console.log('Second text file:',text); //no error, so display the contents of the file } } ); } } );
這個代碼不僅看起來太醜,且存在不少問題:
-
你是在串行加載文件;如果同時加載並在都加載完時返回,效率會更高。
-
語法上正確,可讀性卻極差。 注意那嵌套函數的數目,和不斷深入的縮進,想想就可怕。 你可以用一些技巧讓它看起來更好一些,但又會犧牲一些其他方面的可讀性。
-
這種寫法不是通用方式。 對於兩個文件或許可行,但如果有9個文件呢? 當前這種寫法就太不靈活了。
但別急,我們可以用async.js來解決所有這些問題 (也許還能解決其他一些問題呢)。
用Async.js進行回調
首先,讓我們從安裝async.js入手。
npm install async —-save
Async.js可將一系列函數粘連起來,既可以是串行,也可以是並行。讓我們重寫前面的例子吧: 讓我們重寫前面的例子吧:
var async = require('async'), //async.js module fs = require('fs'); async.series( //execute the functions in the first argument one after another [ //The first argument is an array of functions function(cb) { //`cb` is shorthand for "callback" fs.readFile( 'a-text-file.txt', 'utf8', cb ); }, function(cb) { fs.readFile( 'another-text-file.txt', 'utf8', cb ); } ], function(err,values) { //The "done" callback that is ran after the functions in the array have completed if (err) { //If any errors occurred when functions in the array executed, they will be sent as the err. console.error(err); } else { //If err is falsy then everything is good console.log('First text file:',values[0]); console.log('Second text file:',values[1]); } } );
兩個代碼幾乎是一樣的,串行加載每個文件,唯一的區別在於這裡在讀完所有文件之後才顯示結果。 相比而言,這個代碼更簡潔清晰 (後面還會有其他改進)。 async.series
取一個函數數組作為參數,並串行執行它們。
每個函數只能有一個參數,即回調 (在我們的代碼中是cb
)。 cb
執行時應該與其他任意回調一樣具有相同類型的參數,所以我們將其傳入為fs.readFile
的參數。
最後,它們的結果被發送到最後的回調,即async.series
的第二個參數。 這些結果被存在一個數組中,它們按async.series
第一個參數中的函數的順序而排列。
通過async.js,錯誤處理被簡化了,因為如果遇到一個錯誤,它會返回錯誤到最後一個回調中,並且不在執行任何其他異步函數。
所有內容合到一起
另一個相關的函數是async.parallel
;它和async.series
的參數相同,所以你總可以不改變其他語法的情況下替換使用這兩個函數。 這裡,很適合於講一下並行和並發的異同。
JavaScript基本上算是一種單線程的語言,即它一次只能同時做一件事。 但它可以在一個獨立的線程中處理其他任務 (比如大部分I/O函數),這也正是異步編程在JS中發力之處。 但不要把並行和並發弄混了。
當你用async.parallel
執行兩件事時,你並沒有打開另一個線程去解析JavaScript,也沒有同時做兩件事----你只不過在async.parallel
的第一個參數中的函數間傳遞控制權。 所以,如果你將同步代碼塞到async.parallel中,並沒有任何好處。
我們最好用圖示來解釋:



這就是前面我們用並行方式重寫的例子----唯一的差別在於用async.parallel
取代了async.series
。
var async = require('async'), //async.js module fs = require('fs'); async.parallel( //execute the functions in the first argument, but don't wait for the first function to finish to start the second [ //The first argument is an array of functions function(cb) { //`cb` is shorthand for "callback" fs.readFile( 'a-text-file.txt', 'utf8', cb ); }, function(cb) { fs.readFile( 'another-text-file.txt', 'utf8', cb ); } ], function(err,values) { //The "done" callback that is ran after the functions in the array have completed if (err) { //If any errors occurred when functions in the array executed, they will be sent as the err. console.error(err); } else { //If err is falsy then everything is good console.log('First text file:',values[0]); console.log('Second text file:',values[1]); } } );
執行一遍又一遍
在前面的例子中,我們執行的是固定數目的操作,但如果是變化個數的異步操作呢? 如果你只用回調和常規語言構造,代碼會迅速變得一團糟,因為你需要用一些拙劣的計數器或條件檢測,這會掩蓋代碼的真正邏輯。 讓我們看一個用async.js重寫的循環代碼吧。
在這個例子中,我們要在當前目錄中寫入十個文件,文件名由計數確定,每個文件中包含了簡短的內容。 通過修改async.times
的第一個參數,你可以改變文件的數目。 本例中,fs.writeFile
的回調只需要一個err
參數,而async.times
函數還可以支持一個返回值。 和async.series一樣,它被存到一個數組中,傳遞給最後一個回調的第二個參數。
var async = require('async'), fs = require('fs'); async.times( 10, // number of times to run the function function(runCount,callback) { fs.writeFile( 'file-'+runCount+'.txt', //the new file name 'This is file number '+runCount, //the contents of the new file callback ); }, function(err) { if (err) { console.error(err); } else { console.log('Wrote files.'); } } );
這裡有必要提一下的是,async.js中的大部分函數都默認是並行而非串行執行的。 所以,在上述例子中,它會同時開始生成文件,並在最後完全寫完之時匯報結果。
這些默認並行執行的函數都有一個相對應的串行函數,函數命名方式大概你也猜到了,後綴為'Series’。 所以,如果你想以串行而非並行執行上述例子,只需要將async.times
換成async.timesSeries
即可。
在我們下一個循環的例子中,我們要介紹async.unti函數。 async.until
會一直 (串行) 執行一個異步函數,直到指定條件滿足為止。這個函數有三個函數參數。 這個函數有三個函數參數。
第一個函數參數是一個測試,如果你希望終止循環,就讓它返回真值,如果你希望循環一直繼續下去,那就讓它返回假值。 第二個函數參數是一個異步函數,最後一個函數參數是一個完成回調函數。 看一看下面這個例子:
var async = require('async'), fs = require('fs'), startTime = new Date().getTime(), //the unix timestamp in milliseconds runCount = 0; async.until( function () { //return true if 4 milliseconds have elapsed, otherwise false (and continue running the script) return new Date().getTime() > (startTime + 5); }, function(callback) { runCount += 1; fs.writeFile( 'timed-file-'+runCount+'.txt', //the new file name 'This is file number '+runCount, //the contents of the new file callback ); }, function(err) { if (err) { console.error(err); } else { console.log('Wrote files.'); } } );
這個腳本花費5毫秒來生成新的文件。 在腳本開始,我們記錄了開始的時間 (unix紀元時間),然後在測試函數中我們得到當前時間,並將其與開始時間比較,看是否超過了5毫秒。 如果你多次執行這個腳本,你會得到不同的結果。
在我的機器上,5毫秒可生成6到20個文件。 有意思的是,如果你嘗試在測試函數或異步函數中加入console.log
,你會得到不同的結果,因為寫到終端也是需要時間的。 這只不過是告訴你,在軟件中,一切都是有性能開銷的。
for each循環是一個好用的結構,它可以讓你通過訪問數組的每一項來分別完成一些事情。 在async.js中,實現這個功能的是async.each
函數。 此函數有三個參數:集合或數組,操作每一項的異步函數,完成回調。
在下面的示例中,我們取一個字符串數組 (這裡是狩獵犬品種),並為每個字符串生成一個文件。 當所有文件都生成完畢時,完成回調會被執行。 你大概猜到了,錯誤是通過err
對像傳遞到完成回調中去的。 async.each
是並行執行的,但如果你想要串行執行,你需要將async.each
換成async.eachSeries
。
var async = require('async'), fs = require('fs'); async.each( //an array of sighthound dog breeds ['greyhound','saluki','borzoi','galga','podenco','whippet','lurcher','italian-greyhound'], function(dogBreed, callback) { fs.writeFile( dogBreed+'.txt', //the new file name 'file for dogs of the breed '+dogBreed, //the contents of the new file callback ); }, function(err) { if (err) { console.error(err); } else { console.log('Done writing files about dogs.'); } } );
async.each
的一表親是async.map
函數;它們的差別在於你可以將值傳回到完成回調中去。 使用async.map
函數時,你將一個數組或一個集合作為每一個參數傳入,然後傳入一個異步函數,作用於數組或集合的每個元素。 最後一個函數是完成回調。
下面的例子中,傳入了狗的品種數組,並用每一項生成一個文件名。 然後,文件名被傳入到fs.readFile
中,它會將文件內容讀出來,並傳遞回回調函數。 最後,你會在完成回調函數中接收到一個文件內容的數組。
var async = require('async'), fs = require('fs'); async.map( ['greyhound','saluki','borzoi','galga','podenco','whippet','lurcher','italian-greyhound'], function(dogBreed, callback) { fs.readFile( dogBreed+'.txt', //the new file name 'utf8', callback ); }, function(err, dogBreedFileContents) { if (err) { console.error(err); } else { console.log('dog breeds'); console.log(dogBreedFileContents); } } );
async.filter
與async.each
及async.map
在語法上也很像,只不過它傳遞一個布爾值給每一項的回調,而非文件的值。 在完成回調中,你得到一個新數組,但它只包含那些你在每項回調中傳入一個true
或真值對應的些項的文件內容。
var async = require('async'), fs = require('fs'); async.filter( ['greyhound','saluki','borzoi','galga','podenco','whippet','lurcher','italian-greyhound'], function(dogBreed, callback) { fs.readFile( dogBreed+'.txt', //the new file name 'utf8', function(err,fileContents) { if (err) { callback(err); } else { callback( err, //this will be falsy since we checked it above fileContents.match(/greyhound/gi) //use RegExp to check for the string 'greyhound' in the contents of the file ); } } ); }, function(err, dogBreedFileContents) { if (err) { console.error(err); } else { console.log('greyhound breeds:'); console.log(dogBreedFileContents); } } );
此例中,我們與前面的例子更進一步。 注意看,我們是如何增加一個函數,並處理錯誤的。 當你需要操作異步函數的結果,但仍讓async.js處理錯誤時,if
err
和callback(err)
模式非常有用。
此外,你會注意到我們將err變量作為第一個參數傳遞給回調函數。 初一看,似乎不怎麼對。 但因為我們已經檢查過err的真值,我們知道了它是假的,因此可以安全地傳遞給回調。
越過懸崖邊的瀑布
目前為止,我們已經介紹了多個有用的異步函數,且它們都有對應的同步版本。 現在,讓我們投入到async.waterfall
中,而它並沒有同步版本。
瀑布 (waterfall) 的概念指的是一個異步函數的結果串行傳遞給另一個異步函數作為輸入。 這是一個非常強大的概念,特別是當你需要將多個互相依賴的異步函數串起來時。 使用async.waterfall
時,第一個參數是一個函數數組,第二個參數是完成回調。
在函數數組中,第一個函數總是只有一個參數,即一個回調。 後續的每個函數的參數都需要匹配前一個函數的回調函數的非err參數,再加上一個新的回調。



在我們下一個例子中,我們將利用瀑布作為粘合劑將一些概念組合起來。 在作為第一個參數的數組中,我們有三個函數:第一個加載當前目錄中的目錄列表,第二個作用於這個目錄列表,並用async.map
在每個文件上運行fs.stat
,第三個函數針對第一個函數得到的目錄列表,對每個文件讀取文件內容(fs.readFile
)。
async.waterfall
串行執行每個函數,所以它總是在執行完所有fs.stat
之後再執行那些fs.readfile
。 在這第一個例子中,第二和第三個函數互不依賴,所以它們可以用一個async.parallel
封裝起來並行執行,以減小執行時間,但我們將在下一個例子中再次修改這個結果。
注意:運行此示例時,當前目錄中不要放太多文本文件,不然你的終端窗口中將會長時間出現大量垃圾文本。
var async = require('async'), fs = require('fs'); async.waterfall([ function(callback) { fs.readdir('.',callback); //read the current directory, pass it along to the next function. }, function(fileNames,callback) { //`fileNames` is the directory listing from the previous function async.map( fileNames, //The directory listing is just an array of filenames, fs.stat, //so we can use async.map to run fs.stat for each filename function(err,stats) { if (err) { callback(err); } else { callback(err,fileNames,stats); //pass along the error, the directory listing and the stat collection to the next item in the waterfall } } ); }, function(fileNames,stats,callback) { //the directory listing, `fileNames` is joined by the collection of fs.stat objects in `stats` async.map( fileNames, function(aFileName,readCallback) { //This time we're taking the filenames with map and passing them along to fs.readFile to get the contents fs.readFile(aFileName,'utf8',readCallback); }, function(err,contents) { if (err) { callback(err); } else { //Now our callback will have three arguments, the original directory listing (`fileNames`), the fs.stats collection and an array of with the contents of each file callback(err,fileNames,stats,contents); } } ); } ], function(err, fileNames,stats,contents) { if (err) { console.error(err); } else { console.log(fileNames); console.log(stats); console.log(contents); } } );
比如說,我們可以讓所有文本內容量為500字節。 我們在運行上面的代碼時,不管你是否需要那些文本文件,每個文件的大小和內容都會被讀取出來。 那麼,如何只得到這些文件的文件信息,然後根據其中包含的文件大小信息來讀取較小文件的內容呢?
首先,我們要將所有的匿名函數換成有名字的函數。 這只是個人偏好,但可以讓代碼更清晰一點,並易於理解 (可重用性也更好一些)。 你可以想像得到,我們要讀取文件的大小,並評估這些大小數值,然後,根據只讀取滿足文件大小要求的文件。 這個任務可由Array.filter
來輕鬆完成,但這是一個同步函數,而async.waterfall卻需要異步風格的函數。 Async.js中有一個幫助函數,可將同步函數封裝為異步函數,它有一個很響亮的名字:async.asyncify
。
用async.asyncify
封裝函數只需要做三件事。 首先,從arrayFsStat
函數中取出文件名和文件信息數組,然後用map
將它們合併。 然後,我們過濾出文件大小小於300的那些項。 最後,我們取出綁定在一起的文件名和文件信息對象,再次用map
來取出文件名。
當我們得到所有大小不起過300的文件的文件名之後,我們可用async.map
和fs.readFile
來得到它們的內容。 實現這個任務的方式有很多種,但我們這裡將其分解開來了,以表現出最大的靈活性和可重用性。 async.waterfall
的使用展示了我們如何將同步函數和異步函數混合和匹配起來。
var async = require('async'), fs = require('fs'); //Our anonymous refactored into named functions function directoryListing(callback) { fs.readdir('.',callback); } function arrayFsStat(fileNames,callback) { async.map( fileNames, fs.stat, function(err,stats) { if (err) { callback(err); } else { callback(err,fileNames,stats); } } ); } function arrayFsReadFile(fileNames,callback) { async.map( fileNames, function(aFileName,readCallback) { fs.readFile(aFileName,'utf8',readCallback); }, function(err,contents) { if (err) { callback(err); } else { callback(err,contents); } } ); } //These functions are synchronous function mergeFilenameAndStat(fileNames,stats) { return stats.map(function(aStatObj,index) { aStatObj.fileName = fileNames[index]; return aStatObj; }); } function above300(combinedFilenamesAndStats) { return combinedFilenamesAndStats .filter(function(aStatObj) { return aStatObj.size >= 300; }); } function justFilenames(combinedFilenamesAndStats) { return combinedFilenamesAndStats .map(function(aCombinedFileNameAndStatObj) { return aCombinedFileNameAndStatObj.fileName; }); } async.waterfall([ directoryListing, arrayFsStat, async.asyncify(mergeFilenameAndStat), //asyncify wraps synchronous functions in a err-first callback async.asyncify(above300), async.asyncify(justFilenames), arrayFsReadFile ], function(err,contents) { if (err) { console.error(err); } else { console.log(contents); } } );
更進一步,我們的函數還可以優化。 比如說,我們希望寫一個與上述功能完全一樣的函數,但允許靈活地選擇任何路徑。 與async.waterfall接近的一個函數是async.seq
。 async.waterfall
只是執行連接成瀑布狀的一些函數,而async.seq
是返回一個函數,該函數的任務是執行瀑布狀函數。 除了創建一個函數,你還可以為第一個異步函數傳入一個值。
遷移到async.seq
只需要稍微修改一下。 首先,我們將修改directoryListing
函數,它讓可以接受一個路徑參數。 然後,我們添加一個變量存儲我們的新函數 (directoryAbove300
)。 第三,我們將從async.waterfall
中取出數組參數,然後將其變成async.seq
的參數。 我們的瀑布函數中的完成回調,現在則成了directoryAbove300
的完成回調。
var async = require('async'), fs = require('fs'), directoryAbove300; function directoryListing(initialPath,callback) { //we can pass a variable into the first function used in async.seq - the resulting function can accept arguments and pass them this first function fs.readdir(initialPath,callback); } function arrayFsStat(fileNames,callback) { async.map( fileNames, fs.stat, function(err,stats) { if (err) { callback(err); } else { callback(err,fileNames,stats); } } ); } function arrayFsReadFile(fileNames,callback) { async.map( fileNames, function(aFileName,readCallback) { fs.readFile(aFileName,'utf8',readCallback); }, function(err,contents) { if (err) { callback(err); } else { callback(err,contents); } } ); } function mergeFilenameAndStat(fileNames,stats) { return stats.map(function(aStatObj,index) { aStatObj.fileName = fileNames[index]; return aStatObj; }); } function above300(combinedFilenamesAndStats) { return combinedFilenamesAndStats .filter(function(aStatObj) { return aStatObj.size >= 300; }); } function justFilenames(combinedFilenamesAndStats) { return combinedFilenamesAndStats .map(function(aCombinedFileNameAndStatObj) { return aCombinedFileNameAndStatObj.fileName; }) } //async.seq will produce a new function that you can use over and over directoryAbove300 = async.seq( directoryListing, arrayFsStat, async.asyncify(mergeFilenameAndStat), async.asyncify(above300), async.asyncify(justFilenames), arrayFsReadFile ); directoryAbove300( '.', function(err, fileNames,stats,contents) { if (err) { console.error(err); } else { console.log(fileNames); } } );
關於承諾 (Promises) 和異步 (Async) 函數
你也許會好奇,我為什麼還沒提到承諾 (promises)。 我對它們其實並沒什麼意見,它們非常好用,且比回調更優美。但是,它們是處理異步代碼的完全不同的方式。
Node.js內置函數使用第一個參數為err
的回調,而且成千上萬個其它模塊也使用這種模式。 事實上,這也是為什麼此教程中使用fs
的原因-Node.js中一些諸如文件系統這樣的基礎功能使用的是回調,所以不用承諾還使用回調類型的代碼是Node.js編程的關鍵內容。
有一些相關的解決方案,比如Bluebird將第一個參數為err的回調封裝為基於承諾的函數,但那又是另一個故事了。 Async.js只是提供一些比喻的方式,讓異步代碼更為可讀和可管理。
擁抱異步世界
JavaScript已經成為事實上的網絡工作語言。 它不是沒有學習曲線的,而且大量的框架和庫也讓你目不睱接。 如果你在工作中還需要其它的資源來學習或使用,請查看我們在Envato marketplace中的資源。
但學習異步編程又是完全不同的事,希望本教程至少可以讓你感覺到它的有用之處。
異步是編寫服務器端JavaScript代碼的關鍵所在,但如果你沒有良好的習慣,你的代碼將變成無法管理的回調怪獸。 通過像async.js這樣的庫,和它所提供的大量的比喻式的工具,你會發現編寫異步代碼同樣有意思。
Subscribe below and we’ll send you a weekly email summary of all new Code tutorials. Never miss out on learning about the next big thing.
Update me weekly