[譯]理解 Node.js 事件驅動機制

學習 Node.js 必定要理解的內容之一,文中主要涉及到了 EventEmitter 的使用和一些異步狀況的處理,比較偏基礎,值得一讀。node

閱讀原文api

大多數 Node.js 對象都依賴了 EventEmitter 模塊來監聽和響應事件,好比咱們經常使用的 HTTP requests, responses, 以及 streams。數組

const EventEmitter = require('events');

事件驅動機制的最簡單形式,是在 Node.js 中十分流行的回調函數,例如 fs.readFile。 在回調函數這種形式中,事件每被觸發一次,回調就會被觸發一次。promise

咱們先來探索下這個最基本的方式。架構

你準備好了就叫我哈,Node!

好久好久之前,在 js 裏尚未原生支持 Promise,async/await 還只是一個遙遠的夢想,回調函數是處理異步問題的最原始的方式。app

回調從本質上講是傳遞給其餘函數的函數,在 JavaScript 中函數是第一類對象,這也讓回調的存在成爲可能。異步

必定要搞清楚的是,回調在代碼中的並不表示異步調用。 回調既能夠是同步調用的,也能夠是異步調用的。async

舉個例子,這裏有一個宿主函數 fileSize,它接受一個回調函數 cb,而且能夠經過條件判斷來同步或者異步地調用該回調函數:函數

function fileSize (fileName, cb) {
  if (typeof fileName !== 'string') {
    // Sync
    return cb(new TypeError('argument should be string')); 
  }  
  fs.stat(fileName, (err, stats) => {
    if (err) {   
      // Async
      return cb(err); 
     } 
     // Async
    cb(null, stats.size);
  });
}

這其實也是個反例,這樣寫常常會引發一些意外的錯誤,在設計宿主函數的時候,應當儘量的使用同一種風格,要麼始終都是同步的使用回調,要麼始終都是異步的。學習

咱們來研究下一個典型的異步 Node 函數的簡單示例,它用回調樣式編寫:

const readFileAsArray = function(file, cb) {
  fs.readFile(file, function(err, data) {
    if (err) {
      return cb(err);
    }
    const lines = data.toString().trim().split('\n');
    cb(null, lines);
  });
};

readFileAsArray 函數接受兩個參數:一個文件路徑和一個回調函數。它讀取文件內容,將其拆分紅行數組,並將該數組做爲回調函數的參數傳入,調用回調函數。

如今設計一個用例,假設咱們在同一目錄中的文件 numbers.txt 包含以下內容:

10
11
12
13
14
15

若是咱們有一個需求,要求統計該文件中的奇數數量,咱們可使用 readFileAsArray 來簡化代碼:

readFileAsArray('./numbers.txt', (err, lines) => {
  if (err) throw err;
  const numbers = lines.map(Number);
  const oddNumbers = numbers.filter(n => n%2 === 1);
  console.log('Odd numbers count:', oddNumbers.length);
});

這段代碼將文件內容讀入字符串數組中,回調函數將其解析爲數字,並計算奇數的個數。

這纔是最純粹的 Node 回調風格。回調的第一個參數要遵循錯誤優先的原則,err 能夠爲空,咱們要將回調做爲宿主函數的最後一個參數傳遞。你應該一直用這種方式這樣設計你的函數,由於用戶可能會假設。讓宿主函數把回調當作其最後一個參數,並讓回調函數以一個可能爲空的錯誤對象做爲其第一個參數。

回調在現代 JavaScript 中的替代品

在現代 JavaScript 中,咱們有 Promise,Promise 能夠用來替代異步 API 的回調。回調函數須要做爲宿主函數的一個參數進行傳遞(多個宿主回調進行嵌套就造成了回調地獄),並且錯誤和成功都只能在其中進行處理。而 Promise 對象可讓咱們分開處理成功和錯誤,還容許咱們鏈式調用多個異步事件。

若是 readFileAsArray 函數支持 Promise,咱們能夠這樣使用它,以下所示:

readFileAsArray('./numbers.txt')
  .then(lines => {
    const numbers = lines.map(Number);
    const oddNumbers = numbers.filter(n => n%2 === 1);
    console.log('Odd numbers count:', oddNumbers.length);
  })
  .catch(console.error);

咱們在宿主函數的返回值上調用了一個函數來處理咱們的需求,這個 .then 函數會把剛剛在回調版本中的那個行數組傳遞給這裏的匿名函數。爲了處理錯誤,咱們在結果上添加一個 .catch 調用,當發生錯誤時,它會捕捉到錯誤並讓咱們訪問到這個錯誤。

在現代 JavaScript 中已經支持了 Promise 對象,所以咱們能夠很容易的將其使用在宿主函數之中。下面是支持 Promise 版本的 readFileAsArray 函數(同時支持舊有的回調函數方式):

const readFileAsArray = function(file, cb = () => {}) {
  return new Promise((resolve, reject) => {
    fs.readFile(file, function(err, data) {
      if (err) {
        reject(err);
        return cb(err);
      }      
      const lines = data.toString().trim().split('\n');
      resolve(lines);
      cb(null, lines);
    });
  });
};

咱們使該函數返回一個 Promise 對象,該對象包裹了 fs.readFile 的異步調用。Promise 對象暴露了兩個參數,一個 resolve 函數和一個 reject 函數。

當有異常拋出時,咱們能夠經過向回調函數傳遞 error 來處理錯誤,也一樣可使用 Promise 的 reject 函數。每當咱們將數據交給回調函數處理時,咱們一樣也能夠用 Promise 的 resolve 函數。

在這種同時可使用回調和 Promise 的狀況下,咱們須要作的惟一一件事情就是爲這個回調參數設置默認值,防止在沒有傳遞迴調函數參數時,其被執行而後報錯的狀況。 在這個例子中使用了一個簡單的默認空函數:()=> {}。

經過 async/await 使用 Promise

當須要連續調用異步函數時,使用 Promise 會讓你的代碼更容易編寫。不斷的使用回調會讓事情變得愈來愈複雜,最終陷入回調地獄。

Promise 的出現改善了一點,Generator 的出現又改善了一點。 處理異步問題的最新解決方式是使用 async 函數,它容許咱們將異步代碼視爲同步代碼,使其總體上更加可讀。

如下是使用 async/await 版本的調用 readFileAsArray 的例子:

async function countOdd () {
  try {
    const lines = await readFileAsArray('./numbers');
    const numbers = lines.map(Number);
    const oddCount = numbers.filter(n => n%2 === 1).length;
    console.log('Odd numbers count:', oddCount);
  } catch(err) {
    console.error(err);
  }
}
countOdd();

首先,咱們建立了一個 async 函數 —— 就是一個普通的函數聲明以前,加了個 async 關鍵字。在 async 函數內部,咱們調用了 readFileAsArray 函數,就像把它的返回值賦值給變量 lines 同樣,爲了真的拿到 readFileAsArray 處理生成的行數組,咱們使用關鍵字 await。以後,咱們繼續執行代碼,就好像 readFileAsArray 的調用是同步的同樣。

要讓代碼運行,咱們能夠直接調用 async 函數。這讓咱們的代碼變得更加簡單和易讀。爲了處理異常,咱們須要將異步調用包裝在一個 try/catch 語句中。

有了 async/await 這個特性,咱們沒必要使用任何特殊的API(如 .then 和 .catch )。咱們只是把這種函數標記出來,而後使用純粹的 JavaScript 寫代碼。

咱們能夠把 async/await 這個特性用在支持使用 Promise 處理後續邏輯的函數上。可是,它沒法用在只支持回調的異步函數上(例如setTimeout)。

EventEmitter 模塊

EventEmitter 是一個處理 Node 中各個對象之間通訊的模塊。 EventEmitter 是 Node 異步事件驅動架構的核心。 Node 的許多內置模塊都繼承自 EventEmitter。

它的概念其實很簡單:emitter 對象會發出被定義過的事件,致使以前註冊的全部監聽該事件的函數被調用。因此,emitter 對象基本上有兩個主要特徵:

  • 觸發定義過的事件

  • 註冊或者取消註冊監聽函數

爲了使用 EventEmitter,咱們須要建立一個繼承自 EventEmitter 的類。

class MyEmitter extends EventEmitter {
}

咱們從 EventEmitter 的子類實例化的對象,就是 emitter 對象:

const myEmitter = new MyEmitter();

在這些 emitter 對象的生命週期裏,咱們能夠調用 emit 函數來觸發咱們想要的觸發的任何被命名過的事件。

myEmitter.emit('something-happened');

emit 函數的使用表示發生某種狀況發生了,讓你們去作該作的事情。 這種狀況一般是某些狀態變化引發的。

咱們可使用 on 方法添加監聽器函數,而且每次 emitter 對象觸發其關聯的事件時,將執行這些監聽器函數。

事件 !== 異步

先看看這個例子:

const EventEmitter = require('events');

class WithLog extends EventEmitter {
  execute(taskFunc) {
    console.log('Before executing');
    this.emit('begin');
    taskFunc();
    this.emit('end');
    console.log('After executing');
  }
}

const withLog = new WithLog();

withLog.on('begin', () => console.log('About to execute'));
withLog.on('end', () => console.log('Done with execute'));

withLog.execute(() => console.log('*** Executing task ***'));

WithLog 是一個事件觸發器,它有一個方法 —— execute,該方法接受一個參數,即具體要處理的任務函數,並在其先後包裹 log 以輸出其執行日誌。

爲了看到這裏會以什麼順序執行,咱們在兩個命名的事件上都註冊了監聽器,最後執行一個簡單的任務來觸發事件。

下面是上面程序的輸出結果:

Before executing
About to execute
*** Executing task ***
Done with execute
After executing

這裏我想證明的是以上的輸出都是同步發生的,這段代碼裏沒有什麼異步的成分。

  • 第一行輸出了 "Before executing"

  • begin 事件被觸發,輸出 "About to execute"

  • 真正應該被執行的任務函數被調用,輸出 " Executing task "

  • end 事件被觸發,輸出 "Done with execute"

  • 最後輸出 "After executing"

就像普通的回調同樣,不要覺得事件意味着同步或異步代碼。

跟以前的回調同樣,不要一提到事件就認爲它是異步的或者同步的,還要具體分析。

若是咱們傳遞 taskFunc 是一個異步函數,會發生什麼呢?

// ...

withLog.execute(() => {
  setImmediate(() => {
    console.log('*** Executing task ***')
  });
});

輸出結果變成了這樣:

Before executing
About to execute
Done with execute
After executing
*** Executing task ***

這樣就有問題了,異步函數的調用致使 "Done with execute" 和 "After executing" 的輸出並不許確。

要在異步函數完成後發出事件,咱們須要將回調(或 Promise)與基於事件的通訊相結合。 下面的例子說明了這一點。

使用事件而不是常規回調的一個好處是,咱們能夠經過定義多個監聽器對相同的信號作出多個不一樣的反應。若是使用回調來完成這件事,咱們要在單個回調中寫更多的處理邏輯。事件是應用程序容許多個外部插件在應用程序核心之上構建功能的好辦法。你能夠把它們當成鉤子來掛一些因爲狀態變化而引起執行的程序。

異步事件

咱們把剛剛那些同步代碼的示例改爲異步的:

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

class WithTime extends EventEmitter {
  execute(asyncFunc, ...args) {
    this.emit('begin');
    console.time('execute');
    asyncFunc(...args, (err, data) => {
      if (err) {
        return this.emit('error', err);
      }

      this.emit('data', data);
      console.timeEnd('execute');
      this.emit('end');
    });
  }
}

const withTime = new WithTime();

withTime.on('begin', () => console.log('About to execute'));
withTime.on('end', () => console.log('Done with execute'));

withTime.execute(fs.readFile, __filename);

用 WithTime 類執行 asyncFunc 函數,並經過調用 console.time 和 console.timeEnd 報告該asyncFunc 所花費的時間。它在執行以前和以後都將以正確的順序觸發相應的事件,而且還會發出 error/data 事件做爲處理異步調用的信號。

咱們傳遞一個異步的 fs.readFile 函數來測試一下 withTime emitter。 咱們如今能夠直接經過監聽 data 事件來處理讀取到的文件數據,而不用把這套處理邏輯寫到 fs.readFile 的回調函數中。

執行這段代碼,咱們以預期的順序執行了一系列事件,而且獲得異步函數的執行時間,這些是十分重要的。

About to execute
execute: 4.507ms
Done with execute

請注意,咱們是將回調與事件觸發器 emitter 相結合實現的這部分功能。 若是 asynFunc 支持Promise,咱們可使用 async/await 函數來作一樣的事情:

class WithTime extends EventEmitter {
  async execute(asyncFunc, ...args) {
    this.emit('begin');
    try {
      console.time('execute');
      const data = await asyncFunc(...args);
      this.emit('data', data);
      console.timeEnd('execute');
      this.emit('end');
    } catch(err) {
      this.emit('error', err);
    }
  }
}

我認爲這段代碼比以前的回調風格的代碼以及使用 .then/.catch 風格的代碼更具可讀性。async/await 讓咱們更加接近 JavaScript 語言自己(沒必要再使用 .then/.catch 這些 api)。

事件參數和錯誤

在以前的例子中,有兩個事件被髮出時還攜帶了別的參數。

error 事件被觸發時會攜帶一個 error 對象。

this.emit('error', err);

data 事件被觸發時會攜帶一個 data 對象。

this.emit('data', data);

咱們能夠在 emit 函數中不斷的添加參數,固然第一個參數必定是事件的名稱,除去第一個參數以外的全部參數均可以在該事件註冊的監聽器中使用。

例如,要處理 data 事件,咱們註冊的監聽器函數將訪問傳遞給 emit 函數的 data 參數,而這個 data 也正是由 asyncFunc 返回的數據。

withTime.on('data', (data) => {
  // do something with data
});

error 事件比較特殊。在咱們基於回調的那個示例中,若是不使用監聽器處理 error 事件,node 進程將會退出。

舉個因爲錯誤使用參數而形成程序崩潰的例子:

class WithTime extends EventEmitter {
  execute(asyncFunc, ...args) {
    console.time('execute');
    asyncFunc(...args, (err, data) => {
      if (err) {
        return this.emit('error', err); // Not Handled
      }

      console.timeEnd('execute');
    });
  }
}

const withTime = new WithTime();

withTime.execute(fs.readFile, ''); // BAD CALL
withTime.execute(fs.readFile, __filename);

第一次調用 execute 將會觸發 error 事件,因爲沒有處理 error ,Node 程序隨之崩潰:

events.js:163
      throw er; // Unhandled 'error' event
      ^
Error: ENOENT: no such file or directory, open ''

第二次執行調用將受到此崩潰的影響,而且可能根本不會被執行。

若是咱們爲這個 error 事件註冊一個監聽器函數來處理 error,結果將大不相同:

withTime.on('error', (err) => {
  // do something with err, for example log it somewhere
  console.log(err)
});

若是咱們執行上述操做,將會報告第一次執行 execute 時發送的錯誤,可是此次 node 進程不會崩潰退出,其餘程序的調用也都能正常完成:

{ Error: ENOENT: no such file or directory, open '' errno: -2, code: 'ENOENT', syscall: 'open', path: '' }
execute: 4.276ms

須要注意的是,基於 Promise 的函數有些不一樣,它們暫時只是輸出一個警告:

UnhandledPromiseRejectionWarning: Unhandled promise rejection (rejection id: 1): Error: ENOENT: no such file or directory, open ''

DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

另外一種處理異常的方式是在監聽全局的 uncaughtException 進程事件。 然而,使用該事件全局捕獲錯誤並非一個好辦法。

關於 uncaughtException,通常都會建議你避免使用它,可是若是必須用它,你應該讓進程退出:

process.on('uncaughtException', (err) => {
  // something went unhandled.
  // Do any cleanup and exit anyway!

  console.error(err); // don't do just that.

  // FORCE exit the process too.
  process.exit(1);
});

可是,假設在同一時間發生多個錯誤事件,這意味着上面的 uncaughtException 監聽器將被屢次觸發,這可能會引發一些問題。

EventEmitter 模塊暴露了 once 方法,這個方法發出的信號只會調用一次監聽器。因此,這個方法常與 uncaughtException 一塊兒使用。

監聽器的順序

若是針對一個事件註冊多個監聽器函數,當事件被觸發時,這些監聽器函數將按其註冊的順序被觸發。

// first
withTime.on('data', (data) => {
  console.log(`Length: ${data.length}`);
});

// second
withTime.on('data', (data) => {
  console.log(`Characters: ${data.toString().length}`);
});

withTime.execute(fs.readFile, __filename);

上述代碼會先輸出 Length 信息,再輸出 Characters 信息,執行的順序與註冊的順序保持一致。

若是你想定義一個新的監聽函數,可是但願它可以第一個被執行,你還可使用 prependListener 方法:

withTime.on('data', (data) => {
  console.log(`Length: ${data.length}`);
});

withTime.prependListener('data', (data) => {
  console.log(`Characters: ${data.toString().length}`);
});

withTime.execute(fs.readFile, __filename);

上述代碼中,Charaters 信息將首先被輸出。

最後,你能夠用 removeListener 函數來刪除某個監聽器函數。

相關文章
相關標籤/搜索