NodeJS 最大的賣點-事件機制與非同步 IO,對開發者並不是透明的。開發者需要以非同步方式編寫程式碼才用得上這個賣點,而這一點也遭到了一些 NodeJS 反對者的抨擊。但不管怎樣,非同步程式設計確實是 NodeJS 最大的特點,沒有掌握非同步程式設計就不能說是真正學會了 NodeJS。本章將介紹與非同步程式設計相關的各種知識。
在程式碼中,非同步程式設計的直接體現就是回調。非同步程式設計依託於回調來實現,但不能說使用了回調後程式就非同步化了。我們首先可以看看以下程式碼。
function heavyCompute(n, callback) { var count = 0, i, j; for (i = n; i > 0; --i) { for (j = n; j > 0; --j) { count += 1; } } callback(count); } heavyCompute(10000, function (count) { console.log(count); }); console.log('hello');
100000000 hello
可以看到,以上程式碼中的回呼函數仍然先於後續程式碼執行。 JS 本身是單執行緒運行的,不可能在一段程式碼還未結束執行時去執行別的程式碼,因此也就不存在非同步執行的概念。
但是,如果某個函數做的事情是創建一個別的線程或進程,並與JS主線程並行地做一些事情,並在事情做完後通知 JS 主線程,那情況又不一樣了。我們接著看看以下程式碼。
setTimeout(function () { console.log('world'); }, 1000); console.log('hello');
hello world
這次可以看到,回呼函數後於後續程式碼執行了。如同上邊所說,JS 本身是單線程的,無法異步執行,因此我們可以認為setTimeout 這類JS 規範之外的由運行環境提供的特殊函數做的事情是創建一個平行線程後立即返回,讓JS 主進程可以接著執行後續程式碼,並在收到平行進程的通知後再執行回呼函數。除了 setTimeout、setInterval 這些常見的,這類函數還包括 NodeJS 提供的諸如 fs.readFile 之類的非同步 API。
另外,我們仍然回到 JS 是單執行緒運行的這個事實上,這決定了 JS 在執行完一段程式碼之前無法執行包含回呼函數在內的別的程式碼。也就是說,即使平行執行緒完成工作了,通知 JS 主執行緒執行回呼函數了,回呼函數也要等到 JS 主執行緒空閒時才能開始執行。以下就是這麼一個例子。
function heavyCompute(n) { var count = 0, i, j; for (i = n; i > 0; --i) { for (j = n; j > 0; --j) { count += 1; } } } var t = new Date(); setTimeout(function () { console.log(new Date() - t); }, 1000); heavyCompute(50000);
8520
可以看到,本來應該在1秒後被呼叫的回調函數因為 JS 主執行緒忙於運行其它程式碼,實際執行時間被大幅延遲。
程式設計模式
非同步程式設計有許多獨特的程式碼設計模式,為了實現相同的功能,使用同步方式和非同步方式編寫的程式碼會有很大差異。以下分別介紹一些常見的模式。
函數傳回值
使用一個函數的輸出作為另一個函數的輸入是很常見的需求,在同步方式下一般按以下方式編寫程式碼:
var output = fn1(fn2('input')); // Do something.
而在非同步方式下,由於函數執行結果不是透過傳回值,而是透過回呼函數傳遞,因此一般會以以下方式編寫程式碼:
fn2('input', function (output2) { fn1(output2, function (output1) { // Do something. }); });
可以看到,這種方式就是一個回呼函數套一個回呼函多,套得太多了很容易寫出>形狀的程式碼。
遍歷陣列
在遍歷數組時,使用某個函數依序對資料成員做一些處理也是常見的需求。如果函數是同步執行的,一般就會寫出以下程式碼:
var len = arr.length, i = 0; for (; i < len; ++i) { arr[i] = sync(arr[i]); } // All array items have processed.
如果函數是非同步執行的,以上程式碼就無法保證循環結束後所有陣列成員都處理完畢了。如果數組成員必須一個接一個串行處理,則一般按照以下方式編寫非同步程式碼:
(function next(i, len, callback) { if (i < len) { async(arr[i], function (value) { arr[i] = value; next(i + 1, len, callback); }); } else { callback(); } }(0, arr.length, function () { // All array items have processed. }));
可以看到,以上程式碼在非同步函數執行一次並返回執行結果後才傳入下一個陣列成員並開始下一輪執行,直到所有陣列成員處理完畢後,透過回呼的方式觸發後續程式碼的執行。
如果陣列成員可以並行處理,但後續程式碼仍然需要所有陣列成員處理完畢後才能執行的話,則非同步程式碼會調整成以下形式:
(function (i, len, count, callback) { for (; i < len; ++i) { (function (i) { async(arr[i], function (value) { arr[i] = value; if (++count === len) { callback(); } }); }(i)); } }(0, arr.length, 0, function () { // All array items have processed. }));
可以看到,與非同步串列遍歷的版本相比,以上程式碼並行處理所有陣列成員,並透過計數器變數來判斷何時所有陣列成員都處理完畢了。
異常處理
JS 本身提供的異常擷取與處理機制-try..catch..,只能用於同步執行的程式碼。以下是一個例子。
function sync(fn) { return fn(); } try { sync(null); // Do something. } catch (err) { console.log('Error: %s', err.message); }
Error: object is not a function
可以看到,異常會沿著程式碼執行路徑一直冒泡,直到遇到第一個 try 語句時被捕獲住。但由於非同步函數會打斷程式碼執行路徑,非同步函數執行過程中以及執行之後產生的異常冒泡到執行路徑被打斷的位置時,如果一直沒有遇到 try 語句,就作為一個全局異常拋出。以下是一個例子。
function async(fn, callback) { // Code execution path breaks here. setTimeout(function () { callback(fn()); }, 0); } try { async(null, function (data) { // Do something. }); } catch (err) { console.log('Error: %s', err.message); }
/home/user/test.js:4 callback(fn()); ^ TypeError: object is not a function at null._onTimeout (/home/user/test.js:4:13) at Timer.listOnTimeout [as ontimeout] (timers.js:110:15)
因为代码执行路径被打断了,我们就需要在异常冒泡到断点之前用 try 语句把异常捕获住,并通过回调函数传递被捕获的异常。于是我们可以像下边这样改造上边的例子。
function async(fn, callback) { // Code execution path breaks here. setTimeout(function () { try { callback(null, fn()); } catch (err) { callback(err); } }, 0); } async(null, function (err, data) { if (err) { console.log('Error: %s', err.message); } else { // Do something. } });
Error: object is not a function
可以看到,异常再次被捕获住了。在 NodeJS 中,几乎所有异步 API 都按照以上方式设计,回调函数中第一个参数都是 err。因此我们在编写自己的异步函数时,也可以按照这种方式来处理异常,与 NodeJS 的设计风格保持一致。
有了异常处理方式后,我们接着可以想一想一般我们是怎么写代码的。基本上,我们的代码都是做一些事情,然后调用一个函数,然后再做一些事情,然后再调用一个函数,如此循环。如果我们写的是同步代码,只需要在代码入口点写一个 try 语句就能捕获所有冒泡上来的异常,示例如下。
function main() { // Do something. syncA(); // Do something. syncB(); // Do something. syncC(); } try { main(); } catch (err) { // Deal with exception. }
但是,如果我们写的是异步代码,就只有呵呵了。由于每次异步函数调用都会打断代码执行路径,只能通过回调函数来传递异常,于是我们就需要在每个回调函数里判断是否有异常发生,于是只用三次异步函数调用,就会产生下边这种代码。
function main(callback) { // Do something. asyncA(function (err, data) { if (err) { callback(err); } else { // Do something asyncB(function (err, data) { if (err) { callback(err); } else { // Do something asyncC(function (err, data) { if (err) { callback(err); } else { // Do something callback(null); } }); } }); } }); } main(function (err) { if (err) { // Deal with exception. } });
可以看到,回调函数已经让代码变得复杂了,而异步方式下对异常的处理更加剧了代码的复杂度。