Node.js精進(4)——事件觸發器

語言: CN / TW / HK

Events 是 Node.js 中最重要的核心模組之一,很多模組都是依賴其建立的,例如 上一節分析的流 ,檔案、網路等模組。

比較知名的 Express、KOA 等框架在其內部也使用了 Events 模組。

Events 模組提供了 EventEmitter 類,EventEmitter 也叫事件觸發器,是一種觀察者模式的實現。

觀察者模式是軟體設計模式的一種,在此模式中,一個目標物件(即被觀察者物件)管理所有依賴於它的觀察者物件。

當其自身狀態發生變化時,將以廣播的方式主動傳送通知(在通知中可攜帶一些資料),這樣就能在兩者之間建立觸發機制,達到解耦地目的。

與瀏覽器中的事件處理器不同,在 Node.js 中沒有捕獲、冒泡、preventDefault() 等概念或方法。

本系列所有的示例原始碼都已上傳至Github, 點選此處 獲取。

一、方法原理

在下面的示例中,載入 events 模組,例項化 EventEmitter 類,賦值給 demo 變數,宣告 listener() 監聽函式。

然後呼叫 demo 的 on() 方法註冊 begin 事件,最後呼叫 emit() 觸發 begin 事件,在控制檯打印出“strick”。

const EventEmitter = require('events');
const demo = new EventEmitter();
const listener = () => {    // 監聽函式
  console.log('strick');
};
// 註冊
demo.on('begin', listener);
demo.emit('begin');

若要移除監聽函式,可以像下面這樣,注意,off() 方法不是移除事件,而是函式。

demo.off('begin', listener);

1)建構函式

src/lib/events.js 檔案中,可以看到建構函式的原始碼,它會呼叫 init() 方法,並指定 this,也就是當前例項。

function EventEmitter(opts) {
  EventEmitter.init.call(this, opts);
}

刪減了 init() 方法原始碼,只列出了關鍵部分,當 _events 私有屬性不存在時,就通過 ObjectCreate(null) 建立。

之所以使用 ObjectCreate(null) 是為了得到一個不繼承任何原型方法的乾淨鍵值對。_events 的 key 是事件名稱,value 是監聽函式。

EventEmitter.init = function(opts) {
  // 當 _events 私有屬性不存在時
  if (this._events === undefined ||
      this._events === ObjectGetPrototypeOf(this)._events) {
    this._events = ObjectCreate(null);  // 不繼承任何原型方法的乾淨鍵值對
    this._eventsCount = 0;
  }
};

2)on()

on() 其實是 addListener() 的別名,具體邏輯在 _addListener() 函式中。

EventEmitter.prototype.addListener = function addListener(type, listener) {
  return _addListener(this, type, listener, false);
};
EventEmitter.prototype.on = EventEmitter.prototype.addListener;

在 _addListener() 函式中,會對傳入的事件判斷之前是否註冊過。

如果之前未註冊過,那麼就在鍵值對中註冊新的事件和監聽函式。

如果之前已註冊過,那麼就將多個監聽函式合併成陣列使用,在觸發時會依次執行。

EventEmitter 預設的事件最大監聽數是 10,若註冊的數量超出了這個限制,那麼就會發出警告,不過事件仍然可以正常觸發。

function _addListener(target, type, listener, prepend) {
  let m;
  let events;
  let existing;
  events = target._events;
  // 判斷傳入的事件是否註冊過
  if (events === undefined) {
    events = target._events = ObjectCreate(null);
    target._eventsCount = 0;
  } else {
    existing = events[type];
  }
  // 在鍵值對中註冊新的事件和監聽函式
  if (existing === undefined) {
    events[type] = listener;
    ++target._eventsCount;
  } else {    // 已存在相同名稱的事件
    // 新增第二個相同名稱的事件時,將 events[type] 修改成陣列
    if (typeof existing === "function") {
      existing = events[type] = prepend
        ? [listener, existing]
        : [existing, listener];
    } else if (prepend) {
      existing.unshift(listener);
    } else {
      // 若是陣列,就新增到末尾
      existing.push(listener);
    }
    // 讀取最大事件監聽數
    m = _getMaxListeners(target);
    if (m > 0 && existing.length > m && !existing.warned) {
      existing.warned = true;
      const w = genericNodeError(
        `Possible EventEmitter memory leak detected. ${existing.length} ${String(type)} listeners ` +
        `added to ${inspect(target, { depth: -1 })}. Use emitter.setMaxListeners() to increase limit`,
        { name: 'MaxListenersExceededWarning', emitter: target, type: type, count: existing.length });
      process.emitWarning(w);
    }
  }
  return target;
}

在下面這個示例中,同一個事件,註冊了兩個監聽函式,在觸發時,會先列印“strick”,再列印“freedom”。

const EventEmitter = require('events');
const demo = new EventEmitter();
const listener1 = () => {    // 監聽函式
  console.log('strick');
};
const listener2 = () => {    // 監聽函式
  console.log('freedom');
};
// 註冊
demo.on('begin', listener1);
demo.on('begin', listener2);
demo.emit('begin');

EventEmitter 還提供了一個 once() 方法,也是用於註冊事件,但只會觸發一次。

3)off()

off() 方法是 removeListener() 的別名。

EventEmitter.prototype.off = EventEmitter.prototype.removeListener;

下面是刪減過的 removeListener() 方法原始碼,先是讀取指定事件的監聽函式賦值給 list 變數,型別是函式或陣列。

如果要移除的事件與 list 匹配,當只剩下一個事件時,就賦值 ObjectCreate(null);否則使用 delete 關鍵字刪除鍵值對的屬性。

如果 list 是一個數組時,就遍歷它,並記錄匹配位置。若匹配位置在頭部,就呼叫 shift() 方法移除,否則使用 splice() 方法。

EventEmitter.prototype.removeListener = function removeListener(type, listener) {
  const events = this._events;
  // 讀取指定事件的監聽函式,型別是函式或陣列
  const list = events[type];
  // 要移除的事件與 list 匹配
  if (list === listener || list.listener === listener) {
      // 只剩下最後一個事件,就賦值 ObjectCreate(null)
    if (--this._eventsCount === 0) this._events = ObjectCreate(null);
    else {
      delete events[type];  // 刪除鍵值對的屬性
    }
  } else if (typeof list !== "function") {
    let position = -1;
    // 遍歷 list 陣列,若查到匹配的就記錄位置
    for (let i = list.length - 1; i >= 0; i--) {
      if (list[i] === listener || list[i].listener === listener) {
        position = i;
        break;
      }
    }
    // 在頭部就直接呼叫 shift() 方法
    if (position === 0) list.shift();
    else {
      if (spliceOne === undefined)
        spliceOne = require("internal/util").spliceOne;
      // 沒有使用 splice() 方法,選擇了一個最小可用的函式
      spliceOne(list, position);
    }
  }
  return this;
};

Node.js 沒有使用 splice() 方法,而是選擇了一個最小可用的函式,據說效能有所提升。

spliceOne() 函式很簡單,如下所示,從指定索引加一的位置開始迴圈,後一個元素向前搬移到上一個元素的位置,再將最後那個元素移除。

function spliceOne(list, index) {
  for (; index + 1 < list.length; index++)
    list[index] = list[index + 1];
  list.pop();
}

4)emit()

下面是刪減過的 emit() 方法原始碼,首先讀取監聽函式並賦值給 handler。

若 handler 是函式,則直接通過 apply() 執行。

若 handler 是陣列,那麼先呼叫 arrayClone() 函式將其克隆,在遍歷陣列,依次通過 apply() 執行。

EventEmitter.prototype.emit = function emit(type, ...args) {
  const handler = events[type];
  // 若 handler 是函式,則直接執行
  if (typeof handler === 'function') {
    handler.apply(this, args);
  } else {
    const len = handler.length;
    // 陣列克隆,防止在 emit 時移除事件對其進行干擾
    const listeners = arrayClone(handler);
    // 遍歷陣列
    for (let i = 0; i < len; ++i) {
      listeners[i].apply(this, args);
    }
  }
  return true;
};

arrayClone() 函式的作用是防止在 emit 時移除事件對其進行干擾,在函式中使用 switch 分支和陣列的 slice() 方法。

官方說從 Node 版本 8.8.3 開始,這個實現要比簡單地 for 迴圈快。

function arrayClone(arr) {
  // 從 V8.8.3 開始,這個實現要比簡單地  for 迴圈快
  switch (arr.length) {
    case 2: return [arr[0], arr[1]];
    case 3: return [arr[0], arr[1], arr[2]];
    case 4: return [arr[0], arr[1], arr[2], arr[3]];
    case 5: return [arr[0], arr[1], arr[2], arr[3], arr[4]];
    case 6: return [arr[0], arr[1], arr[2], arr[3], arr[4], arr[5]];
  }
  // array.prototype.slice
  return ArrayPrototypeSlice(arr);
}

二、其他概念

1)同步

官方明確指出 EventEmitter 是按照註冊的順序同步地呼叫所有監聽函式,避免競爭條件和邏輯錯誤。

在適當的時候,監聽函式可以使用 setImmediate() 或 process.nextTick() 方法切換到非同步的操作模式,如下所示。

const EventEmitter = require('events');
const demo = new EventEmitter();
demo.on('async', (a, b) => {
  setImmediate(() => {
    console.log(a, b);
  });
});
demo.emit('async', 'a', 'b');

2)迴圈

先來看第一個迴圈的示例,在註冊的 loop 事件中,會不斷地觸發 loop 事件,那麼最終會報棧溢位的錯誤。

const EventEmitter = require('events');
const demo = new EventEmitter();
const listener = () => {
  console.log('strick');
};
demo.on('loop', () => {
  demo.emit('loop');
  listener();
});
demo.emit('loop');  // 報錯

再看看第二個迴圈的示例,在註冊的 loop 事件中,又註冊了一次 loop 事件,這麼處理並不會報錯,因為只是多註冊了一次同名事件而已。

const listener = () => {
 console.log('strick');
};
demo.on('loop', () => {
  demo.on('loop', listener);
  listener();
});
demo.emit('loop');  // strick
demo.emit('loop');  // strick strick

在每次觸發時,列印的數量要比上一次多一個。

3)錯誤處理

在下面這個示例中,由於沒有註冊 error 事件,因此只要一觸發 error 事件就會丟擲錯誤,後面的列印也不會執行。

const EventEmitter = require('events');
const demo = new EventEmitter();
demo.emit('error', new Error('error'));
console.log('strick');

將程式碼做下調整,為了防止 Node.js 主執行緒崩潰,應該始終註冊 error 事件,改造後,雖然也會報錯,但是列印仍然能正常執行。

demo.on('error', err => {
  console.error(err);
});
demo.emit('error', new Error('error'));
console.log('strick');

參考資料:

Node.js技術棧之事件觸發器 非同步迭代器

餓了麼事件非同步面試題

深入理解Node.js之Event

Node.js事件模組 events事件模組

EventEmitter 原始碼分析與簡易實現

原始碼分析:EventEmitter

詳解Object.create(null)