《Node.js設計模式》Node.js基本模式

counterxing發表於2017-10-07

本系列文章為《Node.js Design Patterns Second Edition》的原文翻譯和讀書筆記,在GitHub連載更新,同步翻譯版連結

歡迎關注我的專欄,之後的博文將在專欄同步:

Node.js Essential Patterns

對於Node.js而言,非同步特性是其最顯著的特徵,但對於別的一些語言,例如PHP,就不常處理非同步程式碼。

在同步的程式設計中,我們習慣於把程式碼的執行想象為自上而下連續的執行計算步驟。每個操作都是阻塞的,這意味著只有在一個操作執行完成後才能執行下一個操作,這種方式利於我們理解和除錯。

然而,在非同步的程式設計中,我們可以在後臺執行諸如讀取檔案或執行網路請求的一些操作。當我們在呼叫非同步操作方法時,即使當前或之前的操作尚未完成,下面的後續操作也會繼續執行,在後臺執行的操作會在任意時刻執行完畢,並且應用程式會在非同步呼叫完成時以正確的方式做出反應。

雖然這種非阻塞方法相比於阻塞方法效能更好,但它實在是讓程式設計師難以理解,並且,在處理較為複雜的非同步控制流的高階應用程式時,非同步順序可能會變得難以操作。

Node.js提供了一系列工具和設計模式,以便我們最佳地處理非同步程式碼。瞭解如何使用它們編寫效能和易於理解和除錯的應用程式非常重要。

在本章中,我們將看到兩個最重要的非同步模式:回撥和事件釋出器。

回撥模式

在上一章中介紹過,回撥是reactor模式handler的例項,回撥本來就是Node.js獨特的程式設計風格之一。回撥函式是在非同步操作完成後傳播其操作結果的函式,總是用來替代同步操作的返回指令。而JavaScript恰好就是表示回撥的最好的語言。在JavaScript中,函式是一等公民,我們可以把函式變數作為引數傳遞,並在另一個函式中呼叫它,把呼叫的結果儲存到某一資料結構中。實現回撥的另一個理想結構是閉包。使用閉包,我們能夠保留函式建立時所在的上下文環境,這樣,無論何時呼叫回撥,都保持了請求非同步操作的上下文。

在本節中,我們分析基於回撥的程式設計思想和模式,而不是同步操作的返回指令的模式。

CPS

JavaScript中,回撥函式作為引數傳遞給另一個函式,並在操作完成時呼叫。在函數語言程式設計中,這種傳遞結果的方法被稱為CPS。這是一個一般概念,而且不只是對於非同步操作而言。實際上,它只是通過將結果作為引數傳遞給另一個函式(回撥函式)來傳遞結果,然後在主體邏輯中呼叫回撥函式拿到操作結果,而不是直接將其返回給呼叫者。

同步CPS

為了更清晰地理解CPS,讓我們來看看這個簡單的同步函式:

function add(a, b) {
  return a + b;
}複製程式碼

上面的例子成為直接程式設計風格,其實沒什麼特別的,就是使用return語句把結果直接傳遞給呼叫者。它代表的是同步程式設計中返回結果的最常見方法。上述功能的CPS寫法如下:

function add(a, b, callback) {
  callback(a + b);
}複製程式碼

add()函式是一個同步的CPS函式,CPS函式只會在它呼叫的時候才會拿到add()函式的執行結果,下列程式碼就是其呼叫方式:

console.log('before');
add(1, 2, result => console.log('Result: ' + result));
console.log('after');複製程式碼

既然add()是同步的,那麼上述程式碼會列印以下結果:

before
Result: 3
after複製程式碼

非同步CPS

那我們思考下面的這個例子,這裡的add()函式是非同步的:

function additionAsync(a, b, callback) {
 setTimeout(() => callback(a + b), 100);
}複製程式碼

在上邊的程式碼中,我們使用setTimeout()模擬非同步回撥函式的呼叫。現在,我們呼叫additionalAsync,並檢視具體的輸出結果。

console.log('before');
additionAsync(1, 2, result => console.log('Result: ' + result));
console.log('after');複製程式碼

上述程式碼會有以下的輸出結果:

before
after
Result: 3複製程式碼

因為setTimeout()是一個非同步操作,所以它不會等待執行回撥,而是立即返回,將控制權交給addAsync(),然後返回給其呼叫者。Node.js中的此屬性至關重要,因為只要有非同步請求產生,控制權就會交給事件迴圈,從而允許處理來自佇列的新事件。

下面的圖片顯示了Node.js中事件迴圈過程:

當非同步操作完成時,執行權就會交給這個非同步操作開始的地方,即回撥函式。執行將從事件迴圈開始,所以它將有一個新的堆疊。對於JavaScript而言,這是它的優勢所在。正是由於閉包儲存了其上下文環境,即使在不同的時間點和不同的位置呼叫回撥,也能夠正常地執行。

同步函式在其完成操作之前是阻塞的。而非同步函式立即返回,結果將在事件迴圈的稍後迴圈中傳遞給處理程式(在我們的例子中是一個回撥)。

非CPS風格的回撥模式

某些情況下情況下,我們可能會認為回撥CPS式的寫法像是非同步的,然而並不是。比如以下程式碼,Array物件的map()方法:

const result = [1, 5, 7].map(element => element - 1);
console.log(result); // [0, 4, 6]複製程式碼

在上述例子中,回撥僅用於迭代陣列的元素,而不是傳遞操作的結果。實際上,這個例子中是使用回撥的方式同步返回,而非傳遞結果。是否是傳遞操作結果的回撥通常在API文件有明確說明。

同步還是非同步?

我們已經看到程式碼的執行順序會因同步或非同步的執行方式產生根本性的改變。這對整個應用程式的流程,正確性和效率都產生了重大影響。以下是對這兩種模式及其缺陷的分析。一般來說,必須避免的是由於其執行順序不一致導致的難以檢測和擴充的混亂。下面是一個有陷阱的非同步例項:

一個有問題的函式

最危險的情況之一是在特定條件下同步執行本應非同步執行的API。以下列程式碼為例:

const fs = require('fs');
const cache = {};

function inconsistentRead(filename, callback) {
  if (cache[filename]) {
    // 如果快取命中,則同步執行回撥
    callback(cache[filename]);
  } else {
    // 未命中,則執行非同步非阻塞的I/O操作
    fs.readFile(filename, 'utf8', (err, data) => {
      cache[filename] = data;
      callback(data);
    });
  }
}複製程式碼

上述功能使用快取來儲存不同檔案讀取操作的結果。不過記得,這只是一個例子,它缺少錯誤處理,並且其快取邏輯本身不是最佳的(比如沒有快取淘汰策略)。除此之外,上述函式是非常危險的,因為如果沒有設定快取記憶體,它的行為是非同步的,直到fs.readFile()函式返回結果為止,它都不會同步執行,這時快取並不會觸發,而會去走非同步回撥呼叫。

解放zalgo

關於zalgo,其實就是指同步或非同步行為的不確定性,幾乎總是導致非常難追蹤的bug

現在,我們來看看如何使用一個不可預測其順序的函式,它甚至可以輕鬆地中斷一個應用程式。看以下程式碼:

function createFileReader(filename) {
  const listeners = [];
  inconsistentRead(filename, value => {
    listeners.forEach(listener => listener(value));
  });
  return {
    onDataReady: listener => listeners.push(listener)
  };
}複製程式碼

當上述函式被呼叫時,它建立一個充當事件釋出器的新物件,允許我們為檔案讀取操作設定多個事件監聽器。當讀取操作完成並且資料可用時,所有的監聽器將被立即被呼叫。前面的函式使用之前定義的inconsistentRead()函式來實現這個功能。我們現在嘗試呼叫createFileReader()函式:

const reader1 = createFileReader('data.txt');
reader1.onDataReady(data => {
 console.log('First call data: ' + data);
 // 之後再次通過fs讀取同一個檔案
 const reader2 = createFileReader('data.txt');
 reader2.onDataReady(data => {
   console.log('Second call data: ' + data);
 });
});複製程式碼

之後的輸出是這樣的:

First call data: some data複製程式碼

下面來分析為何第二次的回撥沒有被呼叫:

在建立reader1的時候,inconsistentRead()函式是非同步執行的,這時沒有可用的快取結果,因此我們有時間註冊事件監聽器。在讀操作完成後,它將在下一次事件迴圈中被呼叫。

然後,在事件迴圈的迴圈中建立reader2,其中所請求檔案的快取已經存在。在這種情況下,內部呼叫inconsistentRead()將是同步的。所以,它的回撥將被立即呼叫,這意味著reader2的所有監聽器也將被同步呼叫。然而,在建立reader2之後,我們才開始註冊監聽器,所以它們將永遠不被呼叫。

inconsistentRead()回撥函式的行為是不可預測的,因為它取決於許多因素,例如呼叫的頻率,作為引數傳遞的檔名,以及載入檔案所花費的時間等。

在實際應用中,例如我們剛剛看到的錯誤可能會非常複雜,難以在真實應用程式中識別和複製。想象一下,在Web伺服器中使用類似的功能,可以有多個併發請求;想象一下這些請求掛起,沒有任何明顯的理由,沒有任何日誌被記錄。這絕對屬於煩人的bug

npm的創始人和以前的Node.js專案負責人Isaac Z. Schlueter在他的一篇部落格文章中比較了使用這種不可預測的功能來釋放Zalgo。如果您不熟悉Zalgo。可以看看Isaac Z. Schlueter的原始帖子

使用同步API

從上述關於zalgo的示例中,我們知道,API必須清楚地定義其性質:是同步的還是非同步的?

我們合適fix上述的inconsistentRead()函式產生的bug的方式是使它完全同步阻塞執行。並且這是完全可能的,因為Node.js為大多數基本I/O操作提供了一組同步方式的API。例如,我們可以使用fs.readFileSync()函式來代替它的非同步對等體。程式碼現在如下:

const fs = require('fs');
const cache = {};

function consistentReadSync(filename) {
 if (cache[filename]) {
   return cache[filename];
 } else {
   cache[filename] = fs.readFileSync(filename, 'utf8');
   return cache[filename];
 }
}複製程式碼

我們可以看到整個函式被轉化為同步阻塞呼叫的模式。如果一個函式是同步的,那麼它不會是CPS的風格。事實上,我們可以說,使用CPS來實現一個同步的API一直是最佳實踐,這將消除其性質上的任何混亂,並且從效能角度來看也將更加有效。

請記住,將APICPS更改為直接呼叫返回的風格,或者說從非同步到同步的風格。例如,在我們的例子中,我們必須完全改變我們的createFileReader()為同步,並使其適應於始終工作。

另外,使用同步API而不是非同步API,要特別注意以下注意事項:

  • 同步API並不適用於所有應用場景。
  • 同步API將阻塞事件迴圈並將併發請求置於阻塞狀態。它會破壞JavaScript的併發模型,甚至使得整個應用程式的效能下降。我們將在本書後面看到這對我們的應用程式的影響。

在我們的inconsistentRead()函式中,因為每個檔名僅呼叫一次,所以同步阻塞呼叫而對應用程式造成的影響並不大,並且快取值將用於所有後續的呼叫。如果我們的靜態檔案的數量是有限的,那麼使用consistentReadSync()將不會對我們的事件迴圈產生很大的影響。如果我們檔案數量很大並且都需要被讀取一次,而且對效能要求較高的情況下,我們不建議在Node.js中使用同步I/O。然而,在某些情況下,同步I/O可能是最簡單和最有效的解決方案。所以我們必須正確評估具體的應用場景,以選擇最為合適的方案。上述例項其實說明:在實際應用程式中使用同步阻塞API載入配置檔案是非常有意義的。

因此,記得只有不影響應用程式併發能力時才考慮使用同步阻塞I/O

延時處理

另一種fix上述的inconsistentRead()函式產生的bug的方式是讓它僅僅是非同步的。這裡的解決辦法是下一次事件迴圈時同步呼叫,而不是在相同的事件迴圈週期中立即執行,使得其實際上是非同步的。在Node.js中,可以使用process.nextTick(),它延遲函式的執行,直到下一次傳遞事件迴圈。它的功能非常簡單,它將回撥作為引數,並將其推送到事件佇列的頂部,在任何未處理的I/O事件前,並立即返回。一旦事件迴圈再次執行,就會立刻呼叫回撥。

所以看下列程式碼,我們可以較好的利用這項技術處理inconsistentRead()的非同步順序:

const fs = require('fs');
const cache = {};

function consistentReadAsync(filename, callback) {
  if (cache[filename]) {
    // 下一次事件迴圈立即呼叫
    process.nextTick(() => callback(cache[filename]));
  } else {
    // 非同步I/O操作
    fs.readFile(filename, 'utf8', (err, data) => {
      cache[filename] = data;
      callback(data);
    });
  }
}複製程式碼

現在,上述函式保證在任何情況下非同步地呼叫其回撥函式,解決了上述bug

另一個用於延遲執行程式碼的APIsetImmediate()。雖然它們的作用看起來非常相似,但實際含義卻截然不同。process.nextTick()的回撥函式會在任何其他I/O操作之前呼叫,而對於setImmediate()則會在其它I/O操作之後呼叫。由於process.nextTick()在其它的I/O之前呼叫,因此在某些情況下可能會導致I/O進入無限期等待,例如遞迴呼叫process.nextTick()但是對於setImmediate()則不會發生這種情況。當我們在本書後面分析使用延遲呼叫來執行同步CPU繫結任務時,我們將深入瞭解這兩種API之間的區別。

我們保證通過使用process.nextTick()非同步呼叫其回撥函式。

Node.js回撥風格

對於Node.js而言,CPS風格的API和回撥函式遵循一組特殊的約定。這些約定不只是適用於Node.js核心API,對於它們之後也是絕大多數使用者級模組和應用程式也很有意義。因此,我們瞭解這些風格,並確保我們在需要設計非同步API時遵守規定顯得至關重要。

回撥總是最後一個引數

在所有核心Node.js方法中,標準約定是當函式在輸入中接受回撥時,必須作為最後一個引數傳遞。我們以下面的Node.js核心API為例:

fs.readFile(filename, [options], callback);複製程式碼

從前面的例子可以看出,即使是在可選引數存在的情況下,回撥也始終置於最後的位置。其原因是在回撥定義的情況下,函式呼叫更可讀。

錯誤處理總在最前

CPS中,錯誤以不同於正確結果的形式在回撥函式中傳遞。在Node.js中,CPS風格的回撥函式產生的任何錯誤總是作為回撥的第一個引數傳遞,並且任何實際的結果從第二個引數開始傳遞。如果操作成功,沒有錯誤,第一個引數將為nullundefined。看下列程式碼:

fs.readFile('foo.txt', 'utf8', (err, data) => {
  if (err)
    handleError(err);
  else
    processData(data);
});複製程式碼

上面的例子是最好的檢測錯誤的方法,如果不檢測錯誤,我們可能難以發現和除錯程式碼中的bug,但另外一個要考慮的問題是錯誤總是為Error型別,這意味著簡單的字串或數字不應該作為錯誤物件傳遞(難以被try catch程式碼塊捕獲)。

錯誤傳播

對於同步阻塞的寫法而言,我們的錯誤都是通過throw語句丟擲,即使錯誤在錯誤棧中跳轉,我們也能很好地捕獲到錯誤上下文。

但是對於CPS風格的非同步呼叫而言,通過把錯誤傳遞到錯誤棧中的下一個回撥來完成,下面是一個典型的例子:

const fs = require('fs');

function readJSON(filename, callback) {
  fs.readFile(filename, 'utf8', (err, data) => {
    let parsed;
    if (err)
    // 如果有錯誤產生則退出當前呼叫
      return callback(err);
    try {
      // 解析檔案中的資料
      parsed = JSON.parse(data);
    } catch (err) {
      // 捕獲解析中的錯誤,如果有錯誤產生,則進行錯誤處理
      return callback(err);
    }
    // 沒有錯誤,呼叫回撥
    callback(null, parsed);
  });
};複製程式碼

從上面的例子中我們注意到的細節是當我們想要正確地進行異常處理時,我們如何向callback傳遞引數。此外,當有錯誤產生時,我們使用了return語句,立即退出當前函式呼叫,避免進行下面的相關執行。

不可捕獲的異常

從上述readJSON()函式,為了避免將任何異常拋到fs.readFile()的回撥函式中捕獲,我們對JSON.parse()周圍放置一個try catch程式碼塊。在非同步回撥中一旦出錯,將丟擲異常,並跳轉到事件迴圈,不把錯誤傳播到下一個回撥函式去。

Node.js中,這是一個不可恢復的狀態,應用程式會關閉,並將錯誤列印到標準輸出中。為了證明這一點,我們嘗試從之前定義的readJSON()函式中刪除try catch程式碼塊:

const fs = require('fs');

function readJSONThrows(filename, callback) {
  fs.readFile(filename, 'utf8', (err, data) => {
    if (err) {
      return callback(err);
    }
    // 假設parse的執行沒有錯誤
    callback(null, JSON.parse(data));
  });
};複製程式碼

在上面的程式碼中,我們沒有辦法捕獲到JSON.parse產生的異常,如果我們嘗試傳遞一個非標準JSON格式的檔案,將會丟擲以下錯誤:

SyntaxError: Unexpected token d
at Object.parse (native)
at [...]
at fs.js:266:14
at Object.oncomplete (fs.js:107:15)複製程式碼

現在,如果我們看看前面的錯誤棧跟蹤,我們將看到它從fs模組的某處開始,恰好從本地API完成檔案讀取返回到fs.readFile()函式,通過事件迴圈。這些資訊都很清楚地顯示給我們,異常從我們的回撥傳入堆疊,然後直接進入事件迴圈,最終被捕獲並丟擲到控制檯中。
這也意味著使用try catch程式碼塊包裝對readJSONThrows()的呼叫將不起作用,因為塊所在的堆疊與呼叫回撥的堆疊不同。以下程式碼顯示了我們剛才描述的相反的情況:

try {
  readJSONThrows('nonJSON.txt', function(err, result) {
    // ... 
  });
} catch (err) {
  console.log('This will not catch the JSON parsing exception');
}複製程式碼

前面的catch語句將永遠不會收到JSON解析異常,因為它將返回到丟擲異常的堆疊。我們剛剛看到堆疊在事件迴圈中結束,而不是觸發非同步操作的功能。
如前所述,應用程式在異常到達事件迴圈的那一刻中止,然而,我們仍然有機會在應用程式終止之前執行一些清理或日誌記錄。事實上,當這種情況發生時,Node.js會在退出程式之前發出一個名為uncaughtException的特殊事件。以下程式碼顯示了一個示例用例:


process.on('uncaughtException', (err) => {
  console.error('This will catch at last the ' +
    'JSON parsing exception: ' + err.message);
  // Terminates the application with 1 (error) as exit code:
  // without the following line, the application would continue
  process.exit(1);
});複製程式碼

重要的是,未被捕獲的異常會使應用程式處於不能保證一致的狀態,這可能導致不可預見的問題。例如,可能還有不完整的I/O請求執行或關閉可能會變得不一致。這就是為什麼總是建議,特別是在生產環境中,在接收到未被捕獲的異常之後寫上述程式碼進行錯誤日誌記錄。

模組系統及相關模式

模組不僅是構建大型應用的基礎,其主要機制是封裝內部實現、方法與變數,通過介面。在本節中,我們將介紹Node.js的模組系統及其最常見的使用模式。

關於模組

JavaScript的主要問題之一是沒有名稱空間。在全域性範圍內執行的程式會汙染全域性名稱空間,造成相關變數、資料、方法名的衝突。解決這個問題的技術稱為模組模式,看下列程式碼:

const module = (() => {
  const privateFoo = () => {
    // ...
  };
  const privateBar = [];
  const exported = {
    publicFoo: () => {
      // ...
    },
    publicBar: () => {
      // ...
    }
  };
  return exported;
})();
console.log(module);複製程式碼

此模式利用自執行匿名函式實現模組,僅匯出旨希望被公開呼叫的部分。在上面的程式碼中,模組變數只包含匯出的API,而其餘的模組內容實際上從外部訪問不到。我們將在稍後看到,這種模式背後的想法被用作Node.js模組系統的基礎。

Node.js模組相關解釋

CommonJS是一個旨在規範JavaScript生態系統的組織,他們提出了CommonJS模組規範Node.js在此規範之上構建了其模組系統,並新增了一些自定義的擴充套件。為了描述它的工作原理,我們可以通過這樣一個例子解釋模組模式,每個模組都在私有名稱空間下執行,這樣模組內定義的每個變數都不會汙染全域性名稱空間。

自定義模組系統

為了解釋模組系統的遠離,讓我們從頭開始構建一個類似的模組系統。下面的程式碼建立一個模仿Node.js原始require()函式的功能。

我們先建立一個載入模組內容的函式,將其包裝到一個私有的名稱空間內:

function loadModule(filename, module, require) {
  const wrappedSrc = `(function(module, exports, require) {
         ${fs.readFileSync(filename, 'utf8')}
       })(module, module.exports, require);`;
  eval(wrappedSrc);
}複製程式碼

模組的原始碼被包裝到一個函式中,如同自執行匿名函式那樣。這裡的區別在於,我們將一些固有的變數傳遞給模組,特指moduleexportsrequire。注意匯出模組的引數是module.exportsexports,後面我們將再討論。

請記住,這只是一個例子,在真實專案中可不要這麼做。諸如eval()vm模組有可能導致一些安全性的問題,它人可能利用漏洞來進行注入攻擊。我們應該非常小心地使用甚至完全避免使用eval

我們現在來看模組的介面、變數等是如何被require()函式引入的:

const require = (moduleName) => {
  console.log(`Require invoked for module: ${moduleName}`);
  const id = require.resolve(moduleName);
  // 是否命中快取
  if (require.cache[id]) {
    return require.cache[id].exports;
  }
  // 定義module
  const module = {
    exports: {},
    id: id
  };
  // 新模組引入,存入快取
  require.cache[id] = module;
  // 載入模組
  loadModule(id, module, require);
  // 返回匯出的變數
  return module.exports;
};
require.cache = {};
require.resolve = (moduleName) => {
  /* 通過模組名作為引數resolve一個完整的模組 */
};複製程式碼

上面的函式模擬了用於載入模組的原生Node.jsrequire()函式的行為。當然,這只是一個demo,它並不能準確且完整地反映require()函式的真實行為,但是為了更好地理解Node.js模組系統的內部實現,定義模組和載入模組。我們的自制模組系統的功能如下:

  • 模組名稱被作為引數傳入,我們首先做的是找尋模組的完整路徑,我們稱之為idrequire.resolve()專門負責這項功能,它通過一個特定的解析演算法實現相關功能(稍後將討論)。
  • 如果模組已經被載入,它應該存在於快取。在這種情況下,我們立即返回快取中的模組。
  • 如果模組尚未載入,我們將首次載入該模組。建立一個模組物件,其中包含一個使用空物件字面值初始化的exports屬性。該屬性將被模組的程式碼用於匯出該模組的公共API
  • 快取首次載入的模組物件。
  • 模組原始碼從其檔案中讀取,程式碼被匯入,如前所述。我們通過require()函式向模組提供我們剛剛建立的模組物件。該模組通過操作或替換module.exports物件來匯出其公共API。
  • 最後,將代表模組的公共APImodule.exports的內容返回給呼叫者。

正如我們所看到的,Node.js模組系統的原理並不是想象中那麼高深,只不過是通過我們一系列操作來建立和匯入匯出模組原始碼。

定義一個模組

通過檢視我們的自定義require()函式的工作原理,我們現在既然已經知道如何定義一個模組。再來看下面這個例子:

// 載入另一個模組
const dependency = require('./anotherModule');
// 模組內的私有函式
function log() {
  console.log(`Well done ${dependency.username}`);
}
// 通過匯出API實現共有方法
module.exports.run = () => {
  log();
};複製程式碼

需要注意的是模組內的所有內容都是私有的,除非它被分配給module.exports變數。然後,當使用require()載入模組時,快取並返回此變數的內容。

定義全域性變數

即使在模組中宣告的所有變數和函式都在其本地範圍內定義,仍然可以定義全域性變數。事實上,模組系統公開了一個名為global的特殊變數。分配給此變數的所有內容將會被定義到全域性環境下。

注意:汙染全域性名稱空間是不好的,並且沒有充分運用模組系統的優勢。所以,只有真的需要使用全域性變數,才去使用它。

module.exports和exports

對於許多還不熟悉Node.js的開發人員而言,他們最容易混淆的是exportsmodule.exports來匯出公共API的區別。變數export只是對module.exports的初始值的引用;我們已經看到,exports本質上在模組載入之前只是一個簡單的物件。

這意味著我們只能將新屬性附加到匯出變數引用的物件,如以下程式碼所示:

exports.hello = () => {
  console.log('Hello');
}複製程式碼

重新給exports賦值並不會有任何影響,因為它並不會因此而改變module.exports的內容,它只是改變了該變數本身。因此下列程式碼是錯誤的:

exports = () => {
  console.log('Hello');
}複製程式碼

如果我們想要匯出除物件之外的內容,比如函式,我們可以給module.exports重新賦值:

module.exports = () => {
  console.log('Hello');
}複製程式碼

require函式是同步的

另一個重要的細節是上述我們寫的require()函式是同步的,它使用了一個較為簡單的方式返回了模組內容,並且不需要回撥函式。因此,對於module.exports也是同步的,例如,下列的程式碼是不正確的:

setTimeout(() => {
  module.exports = function() {
    // ...
  };
}, 100);複製程式碼

通過這種方式匯出模組會對我們定義模組產生重要的影響,因為它限制了我們同步定義並使用模組的方式。這實際上是為什麼核心Node.js庫提供同步API以代替非同步API的最重要的原因之一。

如果我們需要定義一個需要非同步操作來進行初始化的模組,我們也可以隨時定義和匯出需要我們非同步初始化的模組。但是這樣定義非同步模組我們並不能保證require()後可以立即使用,在第九章,我們將詳細分析這個問題,並提出一些模式來優化解決這個問題。

實際上,在早期的Node.js中,曾經有一個非同步版本的require(),但由於它對初始化時間和非同步I/O的效能有巨大影響,很快這個API就被刪除了。

resolve演算法

依賴地獄描述了軟體的依賴於不同版本的軟體包的依賴關係,Node.js通過載入不同版本的模組來解決這個問題,具體取決於模組的載入位置。而都是由npm來完成的,相關演算法被稱作resolve演算法,被用到require()函式中。

現在讓我們快速概述一下這個演算法。如下所述,resolve()函式將一個模組名稱(moduleName)作為輸入,並返回模組的完整路徑。然後,該路徑用於載入其程式碼,並且還可以唯一地標識模組。resolve演算法可以分為以下三種規則:

  • 檔案模組:如果moduleName/開頭,那麼它已經被認為是模組的絕對路徑。如果以./開頭,那麼moduleName被認為是相對路徑,它是從使用require的模組的位置開始計算的。
  • 核心模組:如果moduleName不以/./開頭,則演算法將首先嚐試在核心Node.js模組中進行搜尋。
  • 模組包:如果沒有找到匹配moduleName的核心模組,則搜尋在當前目錄下的node_modules,如果沒有搜尋到node_modules,則會往上層目錄繼續搜尋node_modules,直到它到達檔案系統的根目錄。

對於檔案和包模組,單個檔案和目錄也可以匹配到moduleName。特別地,演算法將嘗試匹配以下內容:

  • <moduleName>.js
  • <moduleName>/index.js
  • <moduleName>/package.jsonmain值下宣告的檔案或目錄

resolve演算法的具體文件

node_modules目錄實際上是npm安裝每個包並存放相關依賴關係的地方。這意味著,基於我們剛剛描述的演算法,每個包都有自身的私有依賴關係。例如,看以下目錄結構:


myApp
├── foo.js
└── node_modules
    ├── depA
    │   └── index.js
    └── depB
        │
        ├── bar.js
        ├── node_modules
        ├── depA
        │    └── index.js
        └── depC
             ├── foobar.js
             └── node_modules
                 └── depA
                     └── index.js複製程式碼

在前面的例子中,myAppdepBdepC都依賴於depA;然而,他們都有自己的私有依賴的版本!按照解析演算法的規則,使用require('depA')將根據需要的模組載入不同的檔案,如下:

  • /myApp/foo.js中呼叫的require('depA')會載入/myApp/node_modules/depA/index.js
  • /myApp/node_modules/depB/bar.js中呼叫的require('depA')會載入/myApp/node_modules/depB/node_modules/depA/index.js
  • /myApp/node_modules/depC/foobar.js中呼叫的require('depA')會載入/myApp/node_modules/depC/node_modules/depA/index.js

resolve演算法Node.js依賴關係管理的核心部分,它的存在使得即便應用程式擁有成百上千包的情況下也不會出現衝突和版本不相容的問題。

當我們呼叫require()時,解析演算法對我們是透明的。然而,仍然可以通過呼叫require.resolve()直接由任何模組使用。

模組快取

每個模組只會在它第一次引入的時候載入,此後的任意一次require()呼叫均從之前快取的版本中取得。通過檢視我們之前寫的自定義的require()函式,可以看到快取對於效能提升至關重要,此外也具有一些其它的優勢,如下:

  • 使得模組依賴關係的重複利用成為可能
  • 從某種程度上保證了在從給定的包中要求相同的模組時總是返回相同的例項,避免了衝突

模組快取通過require.cache變數檢視,因此如果需要,可以直接訪問它。在實際運用中的例子是通過刪除require.cache變數中的相對鍵來使某個快取的模組無效,這是在測試過程中非常有用,但在正常情況下會十分危險。

迴圈依賴

許多人認為迴圈依賴是Node.js內在的設計問題,但在真實專案中真的可能發生,所以我們至少知道如何在Node.js中使得迴圈依賴有效。再來看我們自定義的require()函式,我們可以立即看到其工作原理和注意事項。

看下面這兩個模組:

  • 模組a.js
exports.loaded = false;
const b = require('./b');
module.exports = {
  bWasLoaded: b.loaded,
  loaded: true
};複製程式碼
  • 模組b.js
exports.loaded = false;
const a = require('./a');
module.exports = {
  aWasLoaded: a.loaded,
  loaded: true
};複製程式碼

然後我們在main.js中寫以下程式碼:

const a = require('./a');
const b = require('./b');
console.log(a);
console.log(b);複製程式碼

執行上述程式碼,會列印以下結果:

{
  bWasLoaded: true,
  loaded: true
}
{
  aWasLoaded: false,
  loaded: true
}複製程式碼

這個結果展現了迴圈依賴的處理順序。雖然a.jsb.js這兩個模組都在主模組需要的時候完全初始化,但是當從b.js載入時,a.js模組是不完整的。特別,這種狀態會持續到b.js載入完畢的那一刻。這種情況我們應該引起注意,特別要確認我們在main.js中兩個模組所需的順序。

這是由於模組a.js將收到一個不完整的版本的b.js。我們現在明白,如果我們失去了首先載入哪個模組的控制,如果專案足夠大,這可能會很容易發生迴圈依賴。

關於迴圈引用的文件

簡單說就是,為了防止模組載入的死迴圈,Node.js在模組第一次載入後會把它的結果進行快取,下一次再對它進行載入的時候會直接從快取中取出結果。所以在這種迴圈依賴情形下,不會有死迴圈,但是卻會因為快取造成模組沒有按照我們預想的那樣被匯出(export,詳細的案例分析見下文)。

官網給出了三個模組還不是迴圈依賴最簡單的情形。實際上,兩個模組就可以很清楚的表達出這種情況。根據遞迴的思想,解決了最簡單的情形,這一類任意大小規模的問題也就解決了一半(另一半還需要探明隨著問題規模增長,問題的解將會如何變化)。

JavaScript作為一門解釋型的語言,上面的列印輸出清晰的展示出了程式執行的軌跡。在這個例子中,a.js首先requireb.js, 程式進入b.js,在b.js中第一行又requirea.js

如前文所述,為了避免無限迴圈的模組依賴,在Node.js執行a.js 之後,它就被快取了,但需要注意的是,此時快取的僅僅是一個未完工的a.jsan unfinished copy of the a.js)。所以在 b.jsrequirea.js時,得到的僅僅是快取中一個未完工的a.js,具體來說,它並沒有明確被匯出的具體內容(a.js尾端)。所以b.js中輸出的a是一個空物件。

之後,b.js順利執行完,回到a.jsrequire語句之後,繼續執行完成。

模組定義模式

模組系統除了自帶處理依賴關係的機制之外,最常見的功能就是定義API。對於定義API,主要需要考慮私有和公共功能之間的平衡。其目的是最大化資訊隱藏內部實現和暴露的API可用性,同時將這些與可擴充套件性和程式碼重用性進行平衡。

在本節中,我們將分析一些在Node.js中定義模組的最流行模式;每個模組都保證了私有變數的透明,可擴充套件性和程式碼重用。

命名匯出

暴露公共API的最基本方法是使用命名匯出,其中包括將我們想要公開的所有值分配給由export(或module.exports)引用的物件的屬性。以這種方式,生成的匯出物件將成為一組相關功能的容器或名稱空間。

看下面程式碼,是此模式的實現:

//file logger.js
exports.info = (message) => {
  console.log('info: ' + message);
};
exports.verbose = (message) => {
  console.log('verbose: ' + message);
};複製程式碼

匯出的函式隨後作為引入其的模組的屬性使用,如下面的程式碼所示:

// file main.js
const logger = require('./logger');
logger.info('This is an informational message');
logger.verbose('This is a verbose message');複製程式碼

大多數Node.js模組使用這種定義。

CommonJS規範僅允許使用exports變數來公開public成員。因此,命名的匯出模式是唯一與CommonJS規範相容的模式。使用module.exportsNode.js提供的一個擴充套件,以支援更廣泛的模組定義模式。

函式匯出

最流行的模組定義模式之一包括將整個module.exports變數重新分配給一個函式。它的主要優點是它只暴露了一個函式,為模組提供了一個明確的入口點,使其更易於理解和使用,它也很好地展現了單一職責原則。這種定義模組的方法在社群中也被稱為substack模式,在以下示例中檢視此模式:

// file logger.js
module.exports = (message) => {
  console.log(`info: ${message}`);
};複製程式碼

該模式也可以將匯出的函式用作其他公共API的名稱空間。這是一個非常強大的組合,因為它仍然給模組一個單獨的入口點(exports的主函式)。這種方法還允許我們公開具有次要或更高階用例的其他函式。以下程式碼顯示瞭如何使用匯出的函式作為名稱空間來擴充套件我們之前定義的模組:

module.exports.verbose = (message) => {
  console.log(`verbose: ${message}`);
};複製程式碼

這段程式碼演示瞭如何呼叫我們剛才定義的模組:

// file main.js
const logger = require('./logger');
logger('This is an informational message');
logger.verbose('This is a verbose message');複製程式碼

雖然只是匯出一個函式也可能是一個限制,但實際上它是一個完美的方式,把重點放在一個單一的函式,它代表著這個模組最重要的一個功能,同時使得內部私有變數屬性更加透明,而只是暴露匯出函式本身的屬性。

Node.js的模組化鼓勵我們遵循採用單一職責原則(SRP):每個模組應該對單個功能負責,該職責應完全由該模組封裝,以保證複用性。

注意,這裡講的substack模式,就是通過僅匯出一個函式來暴露模組的主要功能。使用匯出的函式作為名稱空間來匯出別的次要功能。

構造器(類)匯出

匯出建構函式的模組是匯出函式的模組的特例。其不同之處在於,使用這種新模式,我們允許使用者使用建構函式建立新的例項,但是我們也可以擴充套件其原型並建立新類(繼承)。以下是此模式的示例:

// file logger.js
function Logger(name) {
  this.name = name;
}
Logger.prototype.log = function(message) {
  console.log(`[${this.name}] ${message}`);
};
Logger.prototype.info = function(message) {
  this.log(`info: ${message}`);
};
Logger.prototype.verbose = function(message) {
  this.log(`verbose: ${message}`);
};
module.exports = Logger;複製程式碼

我們通過以下方式使用上述模組:

// file main.js
const Logger = require('./logger');
const dbLogger = new Logger('DB');
dbLogger.info('This is an informational message');
const accessLogger = new Logger('ACCESS');
accessLogger.verbose('This is a verbose message');複製程式碼

通過ES2015class關鍵字語法也可以實現相同的模式:

class Logger {
  constructor(name) {
    this.name = name;
  }
  log(message) {
    console.log(`[${this.name}] ${message}`);
  }
  info(message) {
    this.log(`info: ${message}`);
  }
  verbose(message) {
    this.log(`verbose: ${message}`);
  }
}
module.exports = Logger;複製程式碼

鑑於ES2015的類只是原型的語法糖,該模組的使用將與其基於原型和建構函式的方案完全相同。

匯出建構函式或類仍然是模組的單個入口點,但與substack模式比起來,它暴露了更多的模組內部結構。然而,另一方面,當想要擴充套件該模組功能時,我們可以更加方便。

這種模式的變種包括對不使用new的呼叫。這個小技巧讓我們將我們的模組用作工廠。看下列程式碼:

function Logger(name) {
  if (!(this instanceof Logger)) {
    return new Logger(name);
  }
  this.name = name;
};複製程式碼

其實這很簡單:我們檢查this是否存在,並且是Logger的一個例項。如果這些條件中的任何一個都為false,則意味著Logger()函式在不使用new的情況下被呼叫,然後繼續正確建立新例項並將其返回給呼叫者。這種技術允許我們將模組也用作工廠:

// file logger.js
const Logger = require('./logger');
const dbLogger = Logger('DB');
accessLogger.verbose('This is a verbose message');複製程式碼

ES2015new.target語法從Node.js 6開始提供了一個更簡潔的實現上述功能的方法。該利用公開了new.target屬性,該屬性是所有函式中可用的元屬性,如果使用new關鍵字呼叫函式,則在執行時計算結果為true
我們可以使用這種語法重寫工廠:

function Logger(name) {
  if (!new.target) {
    return new LoggerConstructor(name);
  }
  this.name = name;
}複製程式碼

這個程式碼完全與前一段程式碼作用相同,所以我們可以說ES2015new.target語法糖使得程式碼更加可讀和自然。

例項匯出

我們可以利用require()的快取機制來輕鬆地定義具有從建構函式或工廠建立的狀態的有狀態例項,可以在不同模組之間共享。以下程式碼顯示了此模式的示例:

//file logger.js
function Logger(name) {
  this.count = 0;
  this.name = name;
}
Logger.prototype.log = function(message) {
  this.count++;
  console.log('[' + this.name + '] ' + message);
};
module.exports = new Logger('DEFAULT');複製程式碼

這個新定義的模組可以這麼使用:

// file main.js
const logger = require('./logger');
logger.log('This is an informational message');複製程式碼

因為模組被快取,所以每個需要Logger模組的模組實際上總是會檢索該物件的相同例項,從而共享它的狀態。這種模式非常像建立單例。然而,它並不保證整個應用程式的例項的唯一性,因為它發生在傳統的單例模式中。在分析解析演算法時,實際上已經看到,一個模組可能會多次安裝在應用程式的依賴關係樹中。這導致了同一邏輯模組的多個例項,所有這些例項都執行在同一個Node.js應用程式的上下文中。在第7章中,我們將分析匯出有狀態的例項和一些可替代的模式。

我們剛剛描述的模式的擴充套件包括exports用於建立例項的建構函式以及例項本身。這允許使用者建立相同物件的新例項,或者如果需要也可以擴充套件它們。為了實現這一點,我們只需要為例項分配一個新的屬性,如下面的程式碼所示:

module.exports.Logger = Logger;複製程式碼

然後,我們可以使用匯出的建構函式建立類的其他例項:

const customLogger = new logger.Logger('CUSTOM');
customLogger.log('This is an informational message');複製程式碼

從程式碼可用性的角度來看,這類似於將匯出的函式用作名稱空間,該模組匯出一個物件的預設例項,這是我們大部分時間使用的功能,而更多的高階功能(如建立新例項或擴充套件物件的功能)仍然可以通過較少的暴露屬性來使用。

修改其他模組或全域性作用域

一個模組甚至可以匯出任何東西這可以看起來有點不合適;但是,我們不應該忘記一個模組可以修改全域性範圍和其中的任何物件,包括快取中的其他模組。請注意,這些通常被認為是不好的做法,但是由於這種模式在某些情況下(例如測試)可能是有用和安全的,有時確實可以利用這一特性,這是值得了解和理解的。我們說一個模組可以修改全域性範圍內的其他模組或物件。它通常是指在執行時修改現有物件以更改或擴充套件其行為或應用的臨時更改。

以下示例顯示了我們如何向另一個模組新增新函式:

// file patcher.js
// ./logger is another module
require('./logger').customMessage = () => console.log('This is a new functionality');複製程式碼

編寫以下程式碼:

// file main.js
require('./patcher');
const logger = require('./logger');
logger.customMessage();複製程式碼

在上述程式碼中,必須首先引入patcher程式才能使用logger模組。

上面的寫法是很危險的。主要考慮的是擁有修改全域性名稱空間或其他模組的模組是具有副作用的操作。換句話說,它會影響其範圍之外的實體的狀態,這可能導致不可預測的後果,特別是當多個模組與相同的實體進行互動時。想象一下,有兩個不同的模組嘗試設定相同的全域性變數,或者修改同一個模組的相同屬性,效果可能是不可預測的(哪個模組勝出?),但最重要的是它會對在整個應用程式產生影響。

觀察者模式

Node.js中的另一個重要和基本的模式是觀察者模式。與reactor模式,回撥模式和模組一樣,觀察者模式是Node.js基礎之一,也是使用許多Node.js核心模組和使用者定義模組的基礎。

觀察者模式是對Node.js的資料響應的理想解決方案,也是對回撥的完美補充。我們給出以下定義:

釋出者定義一個物件,它可以在其狀態發生變化時通知一組觀察者(或監聽者)。

與回撥模式的主要區別在於,主體實際上可以通知多個觀察者,而傳統的CPS風格的回撥通常主體的結果只會傳播給一個監聽器。

EventEmitter類

在傳統的物件導向程式設計中,觀察者模式需要介面,具體類和層次結構。在Node.js中,都變得簡單得多。觀察者模式已經內建在核心模組中,可以通過EventEmitter類來實現。 EventEmitter類允許我們註冊一個或多個函式作為監聽器,當特定的事件型別被觸發時,它的回撥將被呼叫,以通知其監聽器。以下影象直觀地解釋了這個概念:

EventEmitter是一個類(原型),它是從事件核心模組匯出的。以下程式碼顯示瞭如何獲得對它的引用:

const EventEmitter = require('events').EventEmitter;
const eeInstance = new EventEmitter();複製程式碼

EventEmitter的基本方法如下:

  • on(event,listener):此方法允許您為給定的事件型別(String型別)註冊一個新的偵聽器(一個函式)
  • once(event, listener):此方法註冊一個新的監聽器,然後在事件首次釋出之後被刪除
  • emit(event, [arg1], [...]):此方法會生成一個新事件,並提供其他引數以傳遞給偵聽器
  • removeListener(event, listener):此方法將刪除指定事件型別的偵聽器

所有上述方法將返回EventEmitter例項以允許連結。監聽器函式function([arg1], [...]),所以它只是接受事件發出時提供的引數。在偵聽器中,這是指EventEmitter生成事件的例項。
我們可以看到,一個監聽器和一個傳統的Node.js回撥有很大的區別;特別地,第一個引數不是error,它是在呼叫時傳遞給emit()的任何資料。

建立和使用EventEmitter

我們來看看我們如何在實踐中使用EventEmitter。最簡單的方法是建立一個新的例項並立即使用它。以下程式碼顯示了在檔案列表中找到匹配特定正則的檔案內容時,使用EventEmitter實現實時通知訂閱者的功能:

const EventEmitter = require('events').EventEmitter;
const fs = require('fs');

function findPattern(files, regex) {
  const emitter = new EventEmitter();
  files.forEach(function(file) {
    fs.readFile(file, 'utf8', (err, content) => {
      if (err)
        return emitter.emit('error', err);
      emitter.emit('fileread', file);
      let match;
      if (match = content.match(regex))
        match.forEach(elem => emitter.emit('found', file, elem));
    });
  });
  return emitter;
}複製程式碼

由前面的函式EventEmitter處理將產生的三個事件:

  • fileread事件:當檔案被讀取時觸發
  • found事件:當檔案內容被正則匹配成功時觸發
  • error事件:當讀取檔案出現錯誤時觸發

下面看findPattern()函式是如何被觸發的:

findPattern(['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 ' + file))
  .on('error', err => console.log('Error emitted: ' + err.message));複製程式碼

在前面的例子中,我們為EventParttern()函式建立的EventEmitter生成的每個事件型別註冊了一個監聽器。

錯誤傳播

如果事件是非同步傳送的,EventEmitter不能在異常情況發生時丟擲異常,異常會在事件迴圈中丟失。相反,而是emit是發出一個稱為錯誤的特殊事件,Error物件通過引數傳遞。這正是我們在之前定義的findPattern()函式中正在做的。

對於錯誤事件,始終是最佳做法註冊偵聽器,因為Node.js會以特殊的方式處理它,並且如果沒有找到相關聯的偵聽器,將自動丟擲異常並退出程式。

讓任意物件可觀察

有時,直接通過EventEmitter類建立一個新的可觀察的物件是不夠的,因為原生EventEmitter類並沒有提供我們實際運用場景的擴充功能。我們可以通過擴充套件EventEmitter類使一個通用物件可觀察。

為了演示這個模式,我們試著在物件中實現findPattern()函式的功能,如下程式碼所示:

const EventEmitter = require('events').EventEmitter;
const fs = require('fs');
class FindPattern extends EventEmitter {
  constructor(regex) {
    super();
    this.regex = regex;
    this.files = [];
  }
  addFile(file) {
    this.files.push(file);
    return this;
  }
  find() {
    this.files.forEach(file => {
      fs.readFile(file, 'utf8', (err, content) => {
        if (err) {
          return this.emit('error', err);
        }
        this.emit('fileread', file);
        let match = null;
        if (match = content.match(this.regex)) {
          match.forEach(elem => this.emit('found', file, elem));
        }
      });
    });
    return this;
  }
}複製程式碼

我們定義的FindPattern類中運用了核心模組util提供的inherits()函式來擴充套件EventEmitter。以這種方式,它成為一個符合我們實際運用場景的可觀察類。以下是其用法的示例:

const findPatternObject = new FindPattern(/hello \w+/);
findPatternObject
  .addFile('fileA.txt')
  .addFile('fileB.json')
  .find()
  .on('found', (file, match) => console.log(`Matched "${match}"
       in file ${file}`))
  .on('error', err => console.log(`Error emitted ${err.message}`));複製程式碼

現在,通過繼承EventEmitter的功能,我們現在可以看到FindPattern物件除了可觀察外,還有一整套方法。
這在Node.js生態系統中是一個很常見的模式,例如,核心HTTP模組的Server物件定義了listen()close()setTimeout()等方法,並且在內部它也繼承自EventEmitter函式,從而允許它在收到新的請求、建立新的連線或者伺服器關閉響應請求相關的事件。

擴充套件EventEmitter的物件的其他示例是Node.js流。我們將在第五章中更詳細地分析Node.js的流。

同步和非同步事件

與回撥模式類似,事件也支援同步或非同步傳送。至關重要的是,我們決不應當在同一個EventEmitter中混合使用兩種方法,但是在釋出相同的事件型別時考慮同步或者非同步顯得至關重要,以避免產生因同步與非同步順序不一致導致的zalgo

釋出同步和非同步事件的主要區別在於觀察者註冊的方式。當事件非同步釋出時,即使在EventEmitter初始化之後,程式也會註冊新的觀察者,因為必須保證此事件在事件迴圈下一週期之前不被觸發。正如上邊的findPattern()函式中的情況。它代表了大多數Node.js非同步模組中使用的常用方法。

相反,同步釋出事件要求在EventEmitter函式開始發出任何事件之前就得註冊好觀察者。看下面的例子:

const EventEmitter = require('events').EventEmitter;
class SyncEmit extends EventEmitter {
  constructor() {
    super();
    this.emit('ready');
  }
}
const syncEmit = new SyncEmit();
syncEmit.on('ready', () => console.log('Object is ready to be  used'));複製程式碼

如果ready事件是非同步釋出的,那麼上述程式碼將會正常執行,然而,由於事件是同步釋出的,並且監聽器在傳送事件之後才被註冊,所以結果不呼叫監聽器,該程式碼將無法列印到控制檯。

由於不同的應用場景,有時以同步方式使用EventEmitter函式是有意義的。因此,要清楚地突出我們的EventEmitter的同步和非同步性,以避免產生不必要的錯誤和異常。

事件機制與回撥機制的比較

在定義非同步API時,常見的難點是檢查是否使用EventEmitter的事件機制或僅接受回撥函式。一般區分規則是這樣的:當一個結果必須以非同步方式返回時,應該使用回撥函式,當需要結果不確定其方式時,應該使用事件機制來響應。

但是,由於這兩者實在太相近,並且可能兩種方式都能實現相同的應用場景,所以產生了許多混亂。以下列程式碼為例:

function helloEvents() {
  const eventEmitter = new EventEmitter();
  setTimeout(() => eventEmitter.emit('hello', 'hello world'), 100);
  return eventEmitter;
}

function helloCallback(callback) {
  setTimeout(() => callback('hello world'), 100);
}複製程式碼

helloEvents()helloCallback()在其功能上可以被認為是等價的,第一個使用事件機制實現,第二個則使用回撥來通知呼叫者,而將事件作為引數傳遞。但是真正區分它們的是可執行性,語義和要實現或使用的程式碼量。雖然我們不能給出一套確定性的規則來選擇一種風格,但我們當然可以提供一些提示來幫助你做出決定。

相比於第一個例子,即觀察者模式而言,回撥函式在支援不同型別的事件時有一些限制。但是事實上,我們仍然可以通過將事件型別作為回撥的引數傳遞,或者通過接受多個回撥來區分多個事件。然而,這樣做的話不能被認為是一個優雅的API。在這種情況下,EventEmitter可以提供更好的介面和更精簡的程式碼。

EventEmitter更優秀的另一種應用場景是多次觸發同一事件或不觸發事件的情況。事實上,無論操作是否成功,一個回撥預計都只會被呼叫一次。但有一種特殊情況是,我們可能不知道事件在哪個時間點觸發,在這種情況下,EventEmitter是首選。

最後,使用回撥的API僅通知特定的回撥,但是使用EventEmitter函式可以讓多個監聽器都接收到通知。

回撥機制和事件機制結合使用

還有一些情況可以將事件機制和回撥結合使用。特別是當我們匯出非同步函式時,這種模式非常有用。node-glob模組是該模組的一個示例。

glob(pattern, [options], callback)複製程式碼

該函式將一個檔名匹配模式作為第一個引數,後面兩個引數分別為一組選項和一個回撥函式,對於匹配到指定檔名匹配模式的檔案列表,相關回撥函式會被呼叫。同時,該函式返回EventEmitter,它展現了當前程式的狀態。例如,當成功匹配檔名時可以實時釋出match事件,當檔案列表全部匹配完畢時可以實時釋出end事件,或者該程式被手動中止時釋出abort事件。看以下程式碼:

const glob = require('glob');
glob('data/*.txt', (error, files) => console.log(`All files found: ${JSON.stringify(files)}`))
  .on('match', match => console.log(`Match found: ${match}`));複製程式碼

總結

在本章中,我們首先了解了同步和非同步的區別。然後,我們探討了如何使用回撥機制和回撥機制來處理一些基本的非同步方案。我們還了解到兩種模式之間的主要區別,何時比另一種模式更適合解決具體問題。我們只是邁向更先進的非同步模式的第一步。

在下一章中,我們將介紹更復雜的場景,瞭解如何利用回撥機制和事件機制來處理高階非同步控制問題。

相關文章