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

lucas_580e331d326b4發表於2019-02-16

從命令式到函式式

新書終於截稿,今天稍有空閒,為大家奉獻一篇關於 JavaScript 語言風格的文章,主角是函式宣告式。

靈活的 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,對不同事件的所有回撥函式(handler)進行維護。

  • 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 的繫結。

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

三目運算子更加直觀簡潔:

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 應用可以理解為:

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

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

總結

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

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

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

Happy Coding!

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

相關文章