EventEmitter:從命令式 JavaScript class 到聲明函數(shù)式的華麗轉身

從命令式到函數(shù)式

新書終于截稿,今天稍有空閑翔烁,為大家奉獻一篇關于 JavaScript 語言風格的文章渺氧,主角是函數(shù)聲明式開發(fā)。
我們對一個簡易的租漂,面向對象的 EventEmitter 系統(tǒng)阶女,一步步改造為函數(shù)式風格颊糜。并結合實例來說明函數(shù)式的優(yōu)秀特性哩治。

靈活的 JavaScript 及其 multiparadigm

相信“函數(shù)式”這個概念對于很多前端開發(fā)者早已不再陌生:我們知道 JavaScript 是一門非常靈活,融合多模式(multiparadigm)的語言衬鱼,這篇文章將會展示 JavaScript 里命令式語言風格和聲明式風格的切換业筏,目的在于使讀者了解這兩種不同語言模式的各自特點,進而在日常開發(fā)中做到合理選擇鸟赫,發(fā)揮 JavaScript 的最大威力蒜胖。

為了方便說明,我們從典型的事件發(fā)布訂閱系統(tǒng)入手抛蚤,一步步完成函數(shù)式風格的改造台谢。事件發(fā)布訂閱系統(tǒng),即所謂的觀察者模式(Pub/Sub 模式)岁经,秉承事件驅動(event-driven)思想朋沮,實現(xiàn)了“高內聚、低耦合”的設計缀壤。

如果讀者對于此模式尚不了解樊拓,建議先閱讀我的原創(chuàng)文章:探索 Node.js 事件機制源碼 打造屬于自己的事件發(fā)布訂閱系統(tǒng)。這篇文章中從 Node.js 事件模塊源碼入手塘慕,剖析了事件發(fā)布訂閱系統(tǒng)的實現(xiàn)筋夏,并基于 ES Next 語法,實現(xiàn)了一個命令式图呢、面向對象的事件發(fā)布和響應器条篷。對于此基礎內容,本文不再過多展開蛤织。

典型 EventEmitter 和改造挑戰(zhàn)

了解事件發(fā)布訂閱系統(tǒng)實現(xiàn)思想的基礎上赴叹,我們來看一段簡單且典型的基礎實現(xiàn):

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]();
      }
    }
  }
}

上面代碼,實現(xiàn)了一個 EventManager 類:我們維護一個 Map 類型的 eventMap瞳筏,對不同事件的所有回調函數(shù)(handlers)進行維護稚瘾。

  • addEventListener 方法對指定事件進行回調函數(shù)存儲;
  • dispatchEvent 方法對指定的觸發(fā)事件姚炕,逐個執(zhí)行其回調函數(shù)摊欠。

在消費層面:

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

這些都比較好理解丢烘。下面我們的挑戰(zhàn)是:

  • 將以上 20 多行命令式的代碼,轉換為 7 行 2 個表達式的聲明式代碼些椒;
  • 不再使用 {...} 和 if 判斷條件播瞳;
  • 采用純函數(shù)實現(xiàn),規(guī)避副作用免糕;
  • 使用一元函數(shù)赢乓,即函數(shù)方程式中只需要一個參數(shù);
  • 使函數(shù)實現(xiàn)可組合(composable)石窑;
  • 代碼實現(xiàn)要干凈牌芋、優(yōu)雅、低耦合松逊。

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

對比圖

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

Step1: 使用函數(shù)取代 class

基于以上挑戰(zhàn)內容,addEventListener 和 dispatchEvent 不再作為 EventManager 類的方法出現(xiàn)经宏,而成為兩個獨立的函數(shù)犀暑,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 這兩個函數(shù):

export default {addEventListener, dispatchEvent};

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

import * as EM from './event-manager.js';
EM.dispatchEvent('event');

因為模塊是單例情況耐亏,所以在不同文件引入時,內部變量 eventMap 是共享的沪斟,完全符合預期广辰。

Step2: 使用箭頭函數(shù)

箭頭函數(shù)區(qū)別于傳統(tǒng)的函數(shù)表達式,更符合函數(shù)式“口味”:

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]();
    }
  }
}

這里要格外注意箭頭函數(shù)對 this 的綁定币喧。當然轨域,箭頭函數(shù)本身也叫做 lambda 函數(shù),從名字上就很“函數(shù)式”杀餐。

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

為了保證純函數(shù)特性,區(qū)別于上述處理史翘,我們不能再去改動 eventMap枉长,而是應該返回一個全新的 Map 類型變量,同時對 addEventListener 和 dispatchEvent 方法的參數(shù)進行改動琼讽,增加了“上一個狀態(tài)”的 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 函數(shù)極其類似钻蹬。保持函數(shù)的純凈吼蚁,是函數(shù)式理念中極其重要的一點。

Step4: 去除聲明風格的 for 循環(huán)

接下來,我們使用 forEach 代替 for 循環(huán):

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: 去除花括號 {...}

因為箭頭函數(shù)總會返回表達式的值,我們不再需要任何 {...} :

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 化

最后一步就是實現(xiàn) currying 化操作旗国,具體思路將我們的函數(shù)變?yōu)橐辉ㄖ唤邮芤粋€參數(shù))枯怖,實現(xiàn)方法即使用高階函數(shù)(higher-order function)。為了簡化理解能曾,讀者可以認為即是將參數(shù) (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 化知識,這里不再展開寿冕。

當然這樣的處理蕊程,需要考慮一下參數(shù)的順序。我們通過實例蚂斤,來進行消化存捺。

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 的概念。簡單來說曙蒸,函數(shù)的 partial 應用可以理解為:

函數(shù)在執(zhí)行時,要帶上所有必要的參數(shù)進行調用岗钩。但是纽窟,有時參數(shù)可以在函數(shù)被調用之前提前獲知。這種情況下兼吓,一個函數(shù)有一個或多個參數(shù)預先就能用上臂港,以便函數(shù)能用更少的參數(shù)進行調用。

比如:

const sum = a => b => a + b;
const sumTen = sum(10)
sumTen(20)
// 30

就是一種體現(xiàn)视搏。

回到我們的場景中审孽,對于 onHello 函數(shù),其參數(shù)即表示 hello 事件觸發(fā)時的回調浑娜。這里 myEventMap2 以及 hello 事件等都是預先設定好的佑力。對于 hello 函數(shù)同理,它只需要出發(fā) 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,實現(xiàn)了對于 hello 事件的兩個回調函數(shù)組合响驴,以及 log 函數(shù)組合透且。

compose(f, g, h) 等同于 (...args) => f(g(h(...args))).

關于 compose 方法的奧秘,以及不同實現(xiàn)方式豁鲤,請關注作者:Lucas HC秽誊,我將會專門寫一篇文章介紹罕邀,并分析為什么 Redux 對 compose 的實現(xiàn)稍顯晦澀,同時剖析一種更加直觀的實現(xiàn)方式养距。

總結

函數(shù)式理念也許對于初學者并不是十分友好诉探。讀者可以根據(jù)自身熟悉程度以及偏好,在上述 8 個 steps 中棍厌,隨時停止閱讀肾胯。同時歡迎討論。

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

就像 @顏海鏡 大佬說的:

函數(shù)式的結果就是,到最后自己也就看不懂了束析。艳馒。。

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

Happy Coding!

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末扳缕,一起剝皮案震驚了整個濱河市慌闭,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌躯舔,老刑警劉巖驴剔,帶你破解...
    沈念sama閱讀 218,607評論 6 507
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異粥庄,居然都是意外死亡丧失,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,239評論 3 395
  • 文/潘曉璐 我一進店門飒赃,熙熙樓的掌柜王于貴愁眉苦臉地迎上來利花,“玉大人,你說我怎么就攤上這事载佳〕词拢” “怎么了?”我有些...
    開封第一講書人閱讀 164,960評論 0 355
  • 文/不壞的土叔 我叫張陵蔫慧,是天一觀的道長挠乳。 經常有香客問我,道長,這世上最難降的妖魔是什么睡扬? 我笑而不...
    開封第一講書人閱讀 58,750評論 1 294
  • 正文 為了忘掉前任盟蚣,我火速辦了婚禮,結果婚禮上卖怜,老公的妹妹穿的比我還像新娘屎开。我一直安慰自己,他們只是感情好马靠,可當我...
    茶點故事閱讀 67,764評論 6 392
  • 文/花漫 我一把揭開白布奄抽。 她就那樣靜靜地躺著,像睡著了一般甩鳄。 火紅的嫁衣襯著肌膚如雪逞度。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,604評論 1 305
  • 那天妙啃,我揣著相機與錄音档泽,去河邊找鬼。 笑死揖赴,一個胖子當著我的面吹牛馆匿,可吹牛的內容都是我干的。 我是一名探鬼主播储笑,決...
    沈念sama閱讀 40,347評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼甜熔,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了突倍?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 39,253評論 0 276
  • 序言:老撾萬榮一對情侶失蹤盆昙,失蹤者是張志新(化名)和其女友劉穎羽历,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體淡喜,經...
    沈念sama閱讀 45,702評論 1 315
  • 正文 獨居荒郊野嶺守林人離奇死亡秕磷,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 37,893評論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了炼团。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,015評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖震鹉,靈堂內的尸體忽然破棺而出覆醇,到底是詐尸還是另有隱情,我是刑警寧澤锌俱,帶...
    沈念sama閱讀 35,734評論 5 346
  • 正文 年R本政府宣布晤郑,位于F島的核電站,受9級特大地震影響,放射性物質發(fā)生泄漏造寝。R本人自食惡果不足惜磕洪,卻給世界環(huán)境...
    茶點故事閱讀 41,352評論 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望诫龙。 院中可真熱鬧析显,春花似錦、人聲如沸签赃。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,934評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽姊舵。三九已至晰绎,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間括丁,已是汗流浹背荞下。 一陣腳步聲響...
    開封第一講書人閱讀 33,052評論 1 270
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留史飞,地道東北人尖昏。 一個月前我還...
    沈念sama閱讀 48,216評論 3 371
  • 正文 我出身青樓,卻偏偏與公主長得像构资,于是被迫代替她去往敵國和親抽诉。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 44,969評論 2 355

推薦閱讀更多精彩內容