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

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

歡迎關注個人專欄,以後的博文將在專欄同步:html

Node.js Essential Patterns

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

在同步的編程中,咱們習慣於把代碼的執行想象爲自上而下連續的執行計算步驟。每一個操做都是阻塞的,這意味着只有在一個操做執行完成後才能執行下一個操做,這種方式利於咱們理解和調試。java

然而,在異步的編程中,咱們能夠在後臺執行諸如讀取文件或執行網絡請求的一些操做。當咱們在調用異步操做方法時,即便當前或以前的操做還沒有完成,下面的後續操做也會繼續執行,在後臺執行的操做會在任意時刻執行完畢,而且應用程序會在異步調用完成時以正確的方式作出反應。node

雖然這種非阻塞方法相比於阻塞方法性能更好,但它實在是讓程序員難以理解,而且,在處理較爲複雜的異步控制流的高級應用程序時,異步順序可能會變得難以操做。react

Node.js提供了一系列工具和設計模式,以便咱們最佳地處理異步代碼。瞭解如何使用它們編寫性能和易於理解和調試的應用程序很是重要。git

在本章中,咱們將看到兩個最重要的異步模式:回調和事件發佈器。程序員

回調模式

在上一章中介紹過,回調是reactor模式handler的實例,回調原本就是Node.js獨特的編程風格之一。回調函數是在異步操做完成後傳播其操做結果的函數,老是用來替代同步操做的返回指令。而JavaScript剛好就是表示回調的最好的語言。在JavaScript中,函數是一等公民,咱們能夠把函數變量做爲參數傳遞,並在另外一個函數中調用它,把調用的結果存儲到某一數據結構中。實現回調的另外一個理想結構是閉包。使用閉包,咱們可以保留函數建立時所在的上下文環境,這樣,不管什麼時候調用回調,都保持了請求異步操做的上下文。github

在本節中,咱們分析基於回調的編程思想和模式,而不是同步操做的返回指令的模式。算法

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}`));

總結

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

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

相關文章
相關標籤/搜索