每天閱讀一個 npm 模組(5)- ee-first

elvinnn發表於2018-09-01

系列文章:

  1. 每天閱讀一個 npm 模組(1)- username
  2. 每天閱讀一個 npm 模組(2)- mem
  3. 每天閱讀一個 npm 模組(3)- mimic-fn
  4. 每天閱讀一個 npm 模組(4)- throttle-debounce

一句話介紹

今天閱讀的模組是 ee-first,通過它我們可以在監聽一系列事件時,得知哪一個事件最先發生並進行相應的操作,當前包版本為 1.1.1,周下載量約為 430 萬。

用法

首先簡單介紹一下 ee-first 中的 ee ,它是 EventEmitter 的縮寫,也就是事件發生器的意思,Node.js 中不少物件都繼承自它,例如:net.Server | fs.ReadStram | stream 等,可以說許多核心 API 都是通過 EventEmitter 來進行事件驅動的,它的使用十分簡單,主要是 emit (發出事件)和 on(監聽事件) 兩個介面:

const EventEmitter = require('events');
const emitter = new EventEmitter();

emitter.on('sayHi', (name) => {
    console.log(`hi, my name is ${name}!`);
});

emitter.emit('sayHi', 'Elvin');
// => 'hi, my name is Elvin!'
複製程式碼

接下來看看 ee-frist 的用法:

const EventEmitter = require('events');
const first = require('ee-first');

// 1. 監聽第一個發生的事件
const ee1 = new EventEmitter();
const ee2 = new EventEmitter();

first([
  [ee1, 'close', 'end', 'error'],
  [ee2, 'error']
], function (err, ee, event, args) {
  console.log(`'${event}' happened!`);
})

ee1.emit('end');
// => 'end' happened!

// 2. 取消繫結的監聽事件
const ee3 = new EventEmitter();
const ee4 = new EventEmitter();

const trunk = first([
  [ee3, 'close', 'end', 'error'],
  [ee4, 'error']
], function (err, ee, event, args) {
  console.log(`'${event}' happened!`);
})

trunk.cancel();
ee1.emit('end');
// => 什麼都不會輸出

複製程式碼

原始碼學習

引數校驗

原始碼中對引數的校驗主要是通過 Array.isArray() 判斷引數是否為陣列,若不是則通過丟擲異常給出提示資訊 —— 對於第三方模組而言,需要對呼叫者保持不信任的態度,所以對引數的校驗十分重要。

在早些年的時候,JavaScript 還不支援 Array.isArray() 方法,當時是通過 Object.prototype.toString.call( someVar ) === '[object Array]' 來判斷 someVar 是否為陣列。當然現在已經是 2018 年了,已經不需要使用這些技巧。

// 原始碼 5-1
function first (stuff, done) {
  if (!Array.isArray(stuff)) {
    throw new TypeError('arg must be an array of [ee, events...] arrays')
  }
    
  for (var i = 0; i < stuff.length; i++) {
    var arr = stuff[i]

    if (!Array.isArray(arr) || arr.length < 2) {
      throw new TypeError('each array member must be [ee, events...]')
    }
    
    // ...
  }
}
複製程式碼

生成響應函式

ee-first 中,首先會對傳入的每一個事件名,都會通過 listener 生成一個事件監聽函式:

// 原始碼 5-2

/**
 * Create the event listener.
 * 
 * @param  {String}    event, 事件名,例如 'end', 'error' 等
 * @param  {Function}  done, 呼叫 ee-first 時傳入的響應函式
 */
function listener (event, done) {
  return function onevent (arg1) {
    var args = new Array(arguments.length)
    var ee = this
    var err = event === 'error' ? arg1 : null

    // copy args to prevent arguments escaping scope
    for (var i = 0; i < args.length; i++) {
      args[i] = arguments[i]
    }

    done(err, ee, event, args)
  }
}
複製程式碼

這裡有兩個需要注意的地方:

  1. error 事件進行了特殊的處理,因為在 Node.js 中,假如進行某些操作失敗了的話,那麼會將錯誤資訊作為第一個引數傳給回撥函式,例如檔案的讀取操作:fs.readFile(filePath, (err, data) => { ... }。在我看來,這種將錯誤資訊作為第一個引數傳給回撥函式的做法,能夠引起開發者對異常資訊的重視,是十分值得推薦的編碼規範。
  2. 通過 new Array() 和迴圈賦值的操作,將 onevent 函式的引數儲存在了新陣列 args 中,並將其傳遞給 done 函式。假如不考慮低版本相容性的話,這裡可以使用 ES6 的方法 Array.from() 實現這個功能。不過我暫時沒有想出為什麼要進行這個複製操作,雖然作者進行了註釋,說是為了防止引數作用域異常,但是我沒有想到這個場景,希望知道的讀者能在評論區指出來~

繫結響應函式

接下來則是將生成的事件響應函式繫結到對應的 EventEmitter 上即可,關鍵就是 var fn = listener(event, callback); ee.on(event, fn) 這兩句話:

// 原始碼 5-3
function first (stuff, done) {
  var cleanups = []

  for (var i = 0; i < stuff.length; i++) {
    var arr = stuff[i]
    var ee = arr[0]

    for (var j = 1; j < arr.length; j++) {
      var event = arr[j]
      var fn = listener(event, callback)

      // listen to the event
      ee.on(event, fn)
      // push this listener to the list of cleanups
      cleanups.push({
        ee: ee,
        event: event,
        fn: fn
      })
    }
  }
    
  function callback () {
    cleanup()
    done.apply(null, arguments)
  }
  
  // ...
}
複製程式碼

移除響應函式

在上一步中,不知道有沒有大家注意到兩個 cleanup

  1. 在原始碼 5-3 的開頭,宣告瞭 cleanups 這個陣列,並在每一次繫結響應函式的時候,都通過 cleanups.push() 的方式,將事件和響應函式一一對應地儲存了起來。

  2. 原始碼 5-3 尾部的 callback 函式中,在執行 done() 這個響應函式之前,會呼叫 cleanup() 函式,該函式十分簡單,就是通過遍歷 cleanups 陣列,將之前繫結的事件監聽函式再逐一移除。之所以需要清除是因為繫結事件監聽函式會對記憶體有不小的消耗(這也是為什麼在 Node.js 中,預設情況下每一個 EventEmitter 最多隻能繫結 10 個監聽函式),其實現如下:

    // 原始碼 5-4
    function cleanup () {
      var x
      for (var i = 0; i < cleanups.length; i++) {
        x = cleanups[i]
        x.ee.removeListener(x.event, x.fn)
      }
    }
    複製程式碼

thunk 函式

最後還剩下一點程式碼沒有說到,這段程式碼最短,但也是讓我收穫最大的地方 —— 幫我理解了 thunk 這個常用概念的具體含義。

// 原始碼 5-5
function first (stuff, done) {
  // ...

  function thunk (fn) {
    done = fn
  }

  thunk.cancel = cleanup

  return thunk
}
複製程式碼

thunk.cancel = cleanup 這行很容易理解,就是讓 first() 的返回值擁有移除所有響應函式的能力。關鍵在於這裡 thunk 函式的宣告我一開始不能理解它的作用:用 const thunk = {calcel: cleanup} 替代不也能實現同樣的移除功能嘛?

後來通過閱讀作者所寫的測試程式碼才發了在 README.md 中沒有提到的用法:

// 原始碼 5-6 測試程式碼
const EventEmitter = require('events').EventEmitter
const assert = require('assert')
const first = require('ee-first')

it('should return a thunk', function (testDone) {
    const thunk = first([
        [ee1, 'a', 'b', 'c'],
        [ee2, 'a', 'b', 'c'],
        [ee3, 'a', 'b', 'c'],
    ])
    thunk(function (err, ee, event, args) {
        assert.ifError(err)
        assert.equal(ee, ee2)
        assert.equal(event, 'b')
        assert.deepEqual(args, [1, 2, 3])
        testDone()
    })

    ee2.emit('b', 1, 2, 3)
})
複製程式碼

上面的程式碼很好的展示了 thunk 的作用:它將本來需要兩個引數的 first(stuff, done) 函式變成了只需要一個回撥函式作為引數的 thunk(done) 函式。

這裡引用阮一峰老師在 Thunk 函式的含義和用法 一文中所做的定義,我覺得非常準確,也非常易於理解:

在 JavaScript 語言中,Thunk 函式將多引數函式替換成單引數的版本,且只接受回撥函式作為引數

當然,更廣義地而言,所謂 thunk 就是將一段程式碼通過函式包裹起來,從而延遲它的執行(A thunk is a function that wraps an expression to delay its evaluation)。

// 這段程式碼會立即執行
// x === 3
let x = 1 + 2;

// 1 + 2 只有在 foo 函式被呼叫時才執行
// 所以 foo 就是一個 thunk
let foo = () => 1 + 2
複製程式碼

這段解釋和示例程式碼來自於 redux-thunk - Whtat's a thunk ?

寫在最後

ee-first 是我這些天讀過的最舒服的程式碼,既有詳盡的註釋,也不會像昨天所閱讀的 throttle-debounce 模組那樣讓人覺得註釋過於冗餘。

另外當面對一段程式碼不知有何作用時,可以通過相關的測試程式碼入手進行探索。

關於我:畢業於華科,工作在騰訊,elvin 的部落格 歡迎來訪 ^_^

相關文章