新書終於截稿,今天稍有空閒,為大家奉獻一篇關於 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!