EventEmitter:從命令式 JavaScript class 到宣告函式式的華麗轉身

lucas_580e331d326b4發表於2019-02-16

從命令式到函式式

新書終於截稿,今天稍有空閒,為大家奉獻一篇關於 JavaScript 語言風格的文章,主角是函式宣告式開發。 我們對一個簡易的,物件導向的 EventEmitter 系統,一步步改造為函式式風格。並結合例項來說明函式式的優秀特性。

靈活的 JavaScript 及其 multiparadigm

相信“函式式”這個概念對於很多前端開發者早已不再陌生:我們知道 JavaScript 是一門非常靈活,融合多模式(multiparadigm)的語言,這篇文章將會展示 JavaScript 裡命令式語言風格和宣告式風格的切換,目的在於使讀者瞭解這兩種不同語言模式的各自特點,進而在日常開發中做到合理選擇,發揮 JavaScript 的最大威力。

為了方便說明,我們從典型的事件釋出訂閱系統入手,一步步完成函式式風格的改造。事件釋出訂閱系統,即所謂的觀察者模式(Pub/Sub 模式),秉承事件驅動(event-driven)思想,實現了“高內聚、低耦合”的設計。

如果讀者對於此模式尚不瞭解,建議先閱讀我的原創文章:探索 Node.js 事件機制原始碼 打造屬於自己的事件釋出訂閱系統。這篇文章中從 Node.js 事件模組原始碼入手,剖析了事件釋出訂閱系統的實現,並基於 ES Next 語法,實現了一個命令式、物件導向的事件釋出和響應器。對於此基礎內容,本文不再過多展開。

典型 EventEmitter 和改造挑戰

瞭解事件釋出訂閱系統實現思想的基礎上,我們來看一段簡單且典型的基礎實現:

class EventManager {
  construct (eventMap = new Map()) {
    this.eventMap = eventMap;
  }
  addEventListener (event, handler) {
    if (this.eventMap.has(event)) {
      this.eventMap.set(event, this.eventMap.get(event).concat([handler]));
    } else {
      this.eventMap.set(event, [handler]);
    }
  }
  dispatchEvent (event) {
    if (this.eventMap.has(event)) {
      const handlers = this.eventMap.get(event);
      for (const i in handlers) {
        handlers[i]();
      }
    }
  }
}
複製程式碼

上面程式碼,實現了一個 EventManager 類:我們維護一個 Map 型別的 eventMap,對不同事件的所有回撥函式(handlers)進行維護。

  • addEventListener 方法對指定事件進行回撥函式儲存;
  • dispatchEvent 方法對指定的觸發事件,逐個執行其回撥函式。

在消費層面:

const em = new EventManager();
em.addEventListner('hello', function() {
  console.log('hi');
});
em.dispatchEvent('hello'); // hi
複製程式碼

這些都比較好理解。下面我們的挑戰是:

  • 將以上 20 多行命令式的程式碼,轉換為 7 行 2 個表示式的宣告式程式碼;
  • 不再使用 {...} 和 if 判斷條件;
  • 採用純函式實現,規避副作用;
  • 使用一元函式,即函式方程式中只需要一個引數;
  • 使函式實現可組合(composable);
  • 程式碼實現要乾淨、優雅、低耦合。

我們先看一下最終結果對比圖:

對比圖

馬上我們就一步步介紹這種蛻變過程。

Step1: 使用函式取代 class

基於以上挑戰內容,addEventListener 和 dispatchEvent 不再作為 EventManager 類的方法出現,而成為兩個獨立的函式,eventMap 作為變數:

const eventMap = new Map();

function addEventListener (event, handler) {
  if (eventMap.has(event)) {
    eventMap.set(event, eventMap.get(event).concat([handler]));
  } else {
    eventMap.set(event, [handler]);
  }
}
function dispatchEvent (event) {
  if (eventMap.has(event)) {
    const handlers = this.eventMap.get(event);
    for (const i in handlers) {
      handlers[i]();
    }
  }
}
複製程式碼

在模組化的需求下,我們可以 export 這兩個函式:

export default {addEventListener, dispatchEvent};
複製程式碼

同時使用 import 引入依賴,注意 import 實現是單例模式(singleton):

import * as EM from './event-manager.js';
EM.dispatchEvent('event');
複製程式碼

因為模組是單例情況,所以在不同檔案引入時,內部變數 eventMap 是共享的,完全符合預期。

Step2: 使用箭頭函式

箭頭函式區別於傳統的函式表示式,更符合函式式“口味”:

const eventMap = new Map();
const addEventListener = (event, handler) => {
  if (eventMap.has(event)) {
    eventMap.set(event, eventMap.get(event).concat([handler]));
  } else {
    eventMap.set(event, [handler]);
  }
}
const dispatchEvent = event => {
  if (eventMap.has(event)) {
    const handlers = eventMap.get(event);
    for (const i in handlers) {
      handlers[i]();
    }
  }
}
複製程式碼

這裡要格外注意箭頭函式對 this 的繫結。當然,箭頭函式本身也叫做 lambda 函式,從名字上就很“函式式”。

Step3: 去除副作用,增加返回值

為了保證純函式特性,區別於上述處理,我們不能再去改動 eventMap,而是應該返回一個全新的 Map 型別變數,同時對 addEventListener 和 dispatchEvent 方法的引數進行改動,增加了“上一個狀態”的 eventMap,以便推演出全新的 eventMap:

const addEventListener = (event, handler, eventMap) => {
  if (eventMap.has(event)) {
    return new Map(eventMap).set(event, eventMap.get(event).concat([handler]));
  } else {
    return new Map(eventMap).set(event, [handler]);
  }
}
const dispatchEvent = (event, eventMap) => {
  if (eventMap.has(event)) {
    const handlers = eventMap.get(event);
    for (const i in handlers) {
      handlers[i]();
    }
  }
  return eventMap;
}
複製程式碼

沒錯,這個過程就和 Redux 中的 reducer 函式極其類似。保持函式的純淨,是函式式理念中極其重要的一點。

Step4: 去除宣告風格的 for 迴圈

接下來,我們使用 forEach 代替 for 迴圈:

const addEventListener = (event, handler, eventMap) => {
  if (eventMap.has(event)) {
    return new Map(eventMap).set(event, eventMap.get(event).concat([handler]));
  } else {
    return new Map(eventMap).set(event, [handler]);
  }
}
const dispatchEvent = (event, eventMap) => {
  if (eventMap.has(event)) {
    eventMap.get(event).forEach(a => a());
  }
  return eventMap;
}
複製程式碼

Step5: 應用二元運算子

我們使用 || 和 && 來使程式碼更加簡潔直觀:

const addEventListener = (event, handler, eventMap) => {
  if (eventMap.has(event)) {
    return new Map(eventMap).set(event, eventMap.get(event).concat([handler]));
  } else {
    return new Map(eventMap).set(event, [handler]);
  }
}
const dispatchEvent = (event, eventMap) => {
  return (
    eventMap.has(event) &&
    eventMap.get(event).forEach(a => a())
  ) || event;
}
複製程式碼

需要格外注意 return 語句的表示式,這是很典型的處理手段:

return (
    eventMap.has(event) &&
    eventMap.get(event).forEach(a => a())
  ) || event;
複製程式碼

Step6: 使用三目運算子代替 if

if 這種命令式的“醜八怪”怎麼可能存在,我們使用三目運算子更加直觀簡潔:

const addEventListener = (event, handler, eventMap) => {
  return eventMap.has(event) ?
    new Map(eventMap).set(event, eventMap.get(event).concat([handler])) :
    new Map(eventMap).set(event, [handler]);
}
const dispatchEvent = (event, eventMap) => {
  return (
    eventMap.has(event) &&
    eventMap.get(event).forEach(a => a())
  ) || event;
}
複製程式碼

Step7: 去除花括號 {...}

因為箭頭函式總會返回表示式的值,我們不再需要任何 {...} :

const addEventListener = (event, handler, eventMap) =>
   eventMap.has(event) ?
     new Map(eventMap).set(event, eventMap.get(event).concat([handler])) :
     new Map(eventMap).set(event, [handler]);
     
const dispatchEvent = (event, eventMap) =>
  (eventMap.has(event) && eventMap.get(event).forEach(a => a())) || event;
複製程式碼

Step8: 完成 currying 化

最後一步就是實現 currying 化操作,具體思路將我們的函式變為一元(只接受一個引數),實現方法即使用高階函式(higher-order function)。為了簡化理解,讀者可以認為即是將引數 (a, b, c) 簡單的變成 a => b => c 方式:

const addEventListener = handler => event => eventMap =>
   eventMap.has(event) ?
     new Map(eventMap).set(event, eventMap.get(event).concat([handler])) :
     new Map(eventMap).set(event, [handler]);
     
const dispatchEvent = event => eventMap =>
  (eventMap.has(event) && eventMap.get(event).forEach (a => a())) || event;
複製程式碼

如果讀者對於此理解有一定困難,建議先補充一下 currying 化知識,這裡不再展開。

當然這樣的處理,需要考慮一下引數的順序。我們通過例項,來進行消化。

currying 化使用:

const log = x => console.log (x) || x;
const myEventMap1 = addEventListener(() => log('hi'))('hello')(new Map());
dispatchEvent('hello')(myEventMap1); // hi
複製程式碼

partial 使用:

const log = x => console.log (x) || x;
let myEventMap2 = new Map();
const onHello = handler => myEventMap2 = addEventListener(handler)('hello')(myEventMap2);
const hello = () => dispatchEvent('hello')(myEventMap2);

onHello(() => log('hi'));
hello(); // hi
複製程式碼

熟悉 python 的讀者可能會更好理解 partial 的概念。簡單來說,函式的 partial 應用可以理解為:

函式在執行時,要帶上所有必要的引數進行呼叫。但是,有時引數可以在函式被呼叫之前提前獲知。這種情況下,一個函式有一個或多個引數預先就能用上,以便函式能用更少的引數進行呼叫。

比如:

const sum = a => b => a + b;
const sumTen = sum(10)
sumTen(20)
// 30
複製程式碼

就是一種體現。

回到我們的場景中,對於 onHello 函式,其引數即表示 hello 事件觸發時的回撥。這裡 myEventMap2 以及 hello 事件等都是預先設定好的。對於 hello 函式同理,它只需要出發 hello 事件即可。

組合使用:

const log = x => console.log (x) || x;
const compose = (...fns) => fns.reduce((f, g) => (...args) => f(g(...args)));
const addEventListeners = compose(
  log,
  addEventListener(() => log('hey'))('hello'),
  addEventListener(() => log('hi'))('hello')
);

const myEventMap3 = addEventListeners(new Map()); // myEventMap3
dispatchEvent('hello')(myEventMap3); // hi hey
複製程式碼

這裡需要格外注意 compose 方法。熟悉 Redux 的讀者,如果閱讀過 Redux 原始碼,對於 compose 一定並不陌生。我們通過 compose,實現了對於 hello 事件的兩個回撥函式組合,以及 log 函式組合。

compose(f, g, h) 等同於 (...args) => f(g(h(...args))).
複製程式碼

關於 compose 方法的奧祕,以及不同實現方式,請關注作者:Lucas HC,我將會專門寫一篇文章介紹,並分析為什麼 Redux 對 compose 的實現稍顯晦澀,同時剖析一種更加直觀的實現方式。

總結

函式式理念也許對於初學者並不是十分友好。讀者可以根據自身熟悉程度以及偏好,在上述 8 個 steps 中,隨時停止閱讀。同時歡迎討論。

本文意譯了 Martin Novák 的 新文章,歡迎大神斧正。

就像 @顏海鏡 大佬說的:

函式式的結果就是,到最後自己也就看不懂了。。。

廣告時間: 如果你對前端發展,尤其 React 技術棧感興趣:我的新書中,也許有你想看到的內容。關注作者 Lucas HC,新書出版將會有送書活動。

Happy Coding!

PS: 作者 Github倉庫 和 知乎問答連結 歡迎各種形式交流。

相關文章