淺析 JavaScript 函數式編程
前言
隨着 React 的流行,函數式編程在前端領域備受關注。尤其近幾年,越來越多的類庫偏向於函數式開發:lodash/fp,Rx.js、Redux 的純函數,React16.8 推出的 hooks,Vue3.0 的 composition Api... 同時在 ES5/ES6 標準中也有體現,例如:箭頭函數、迭代器、map、filter、reduce 等。
那麼爲什麼要使用函數式編程呢?我們通過一個例子感受一下:在業務需求開發中,我們更多時候是對數據的處理,例如:將字符串數組進行分類,轉爲字符串對象格式
。
// jsList => jsObj
const jsList = [
'es5:forEach',
'es5:map',
'es5:filter',
'es6:find',
'es6:findIndex',
'add'
]
const jsObj = {
es5: ["forEach", "map", "filter"],
es6: ["find", "findIndex"]
}
先通過我們最常用的命令式實現一遍:
const jsObj = {}
for (let i = 0; i < jsList.length; i++) {
const item = jsList[i];
const [vesion, apiName] = item.split(":")
if (apiName) {
if (!jsObj[vesion]) {
jsObj[vesion] = []
}
jsObj[vesion].push(apiName);
}
}
接下來再看函數式的實現:
const jsObj = jsList
.map(item => item.split(':'))
.filter(arr => arr.length === 2)
.reduce((obj, item) => {
const [version, apiName] = item
return {
...obj,
[version]: [...(obj[version] || []), apiName]
}
}, {})
兩段代碼對比下來,會發現命令式的實現過程中會產生大量的臨時變量,還參雜大量的邏輯處理,通常只有讀完整段代碼纔會明白具體做了什麼。如果後續需求變更,又會添加更多的邏輯處理,想想腦殼都痛...
反觀函數式的實現:單看每個函數,就可以知道在做什麼,代碼更加語義化,可讀性更高。整個過程就像一條完整的流水線,數據從一個函數輸入,處理完成後流入下一個處理函數... 每個函數都是各司其職。
接下來,讓我們在窺探函數式編程的世界之前,先簡單瞭解一下上面提到的編程範式。
編程範式
編程範式是指軟件工程中的一類典型的編程風格,編程範式提供並決定了程序員對程序的看法。
例如在面向對象編程中,程序員認爲程序是一系列相互作用的對象;而在函數式編程中,程序會被當做一個無狀態的函數計算的序列。常見的編程範式如下:
命令式編程
命令式編程是一種描述電腦所需作出的行爲的編程範式,也是目前使用最廣的編程範式,其主要思想就是站在計算機的角度思考問題,關注計算執行步驟,每一步都是指令。(代表:C、C++、Java)
大部分命令式編程語言都支持四種基本的語句:
-
運算語句;
-
循環語句(for、while);
-
條件分支語句(if else、switch);
-
無條件分支語句(return、break、continue)。
計算機執行的每一個步驟都是程序員控制的,所以可以更加精細嚴謹的控制代碼,提高應用程序的性能;但是由於存在大量的流程控制語句,在處理多線程、併發問題時,容易造成邏輯紊亂。
聲明式編程
聲明式編程描述的是目標的性質,讓計算機明白目標,而非流程。通過定義具體的規則,以便系統底層可以自動實現具體功能。(代表:Haskell)
相較於命令式編程範式,不需要流程控制語言,沒有冗餘的操作步驟,使得代碼更加語義化,降低了代碼的複雜性;但是其底層實現的邏輯並不可控,不適合做更加精細的代碼優化。
總結下來,這兩種編程範式最大的不同就是:
-
How:命令式編程告訴計算機
如何
計算,關心解決問題的步驟; -
What:聲明式編程告訴計算機需要計算
什麼
,關心解決問題的目標。
函數式編程
聲明式編程是一個大的概念,其下包含一些有名的子編程範式:約束式編程、領域專屬語言、邏輯式編程、函數式編程。其中領域專屬語言(DSL)和函數式編程(FP)在前端領域的應用更加廣泛,接下來開始我們今天的主角 -- 函數式編程。
函數式編程並不是一種工具,而是一種可以適用於任何環境的編程思想,它是一種以函數使用爲主的軟件開發風格。這與大家都熟悉的面向對象編程的思維方式完全不同,函數式的目的是通過函數抽象作用在數據流的操作,從而在系統中消除副作用並減少對狀態的改變。
爲了充分理解函數式編程,我們先來看下它有哪些基本概念?
概念
函數是一等公民
函數與其他數據類型一樣,不僅可以賦值給變量,也可以當作參數傳遞,或者做爲函數的返回值。例如:
// 做爲變量
fn = () => {}
// 做爲參數
function fn1(fn){fn()}
// 做爲函數返回值
function fn2(){return () => {} }
正是函數是‘一等公民’的前提,函數式編程才得以實現,而在 JavaScript 中,閉包和高階函數成了中堅力量。
純函數
純函數是這樣一種函數,即相同的輸入,永遠會得到相同的輸出,而且沒有任何可觀察的副作用。
提到純函數,熟悉 redux 的同學可能再熟悉不過了,在 redux 中所有的修改都需要使用純函數。純函數具有以下特點:
-
無狀態:函數的輸出僅取決於輸入,而不依賴外部狀態;
-
無副作用:不會造成超出其作用域的變化,即不修改函數參數或全局變量等。
function add(obj) {
obj.num += 1
return obj
}
const obj = {num: 1}
add(obj)
console.log(obj)
// { num: 2 }
這個函數不是純的,因爲 js 對象傳遞的是引用地址,函數內部的修改會直接影響外部變量,最後產生了預料之外的結果。接下來,我們改成純函數的寫法:
function add(obj) {
const _obj = {...obj}
_obj.num += 1
return _obj
}
const obj = {num: 1}
add(obj)
console.log(obj);
// { num: 1 }
通過在函數內部創建新的變量進行更改(是不是有想起 redux 的 reducer 寫法~~),從而避免產生副作用。純函數除了無副作用外,還有其他好處:
-
可緩存性正是因爲函數式聲明的無狀態特點,即:相同輸入總能得到相同的輸出。所以我們可以提前緩存函數的執行結果,實現更多功能。例如:優化斐波拉契數列的遞歸解法。
-
可移植性 / 自文檔化純函數的依賴很明確,更易於觀察和理解,配合類型簽名可以使程序更加簡單易讀。
// get :: a -> a
const get = function (id) { return id}
// map :: (a -> b) -> [a] -> [b]
const map = curry(function (f, res){
return res.map(f)
})
- 可測試性純函數讓測試更加簡單,只需簡單地給函數一個輸入,然後斷言輸出就可以了。
副作用
函數的副作用是指在調用函數時,除了返回函數值外還產生了額外的影響。例如修改上個例子中的修改參數或者全局變量。除此之外,以下副作用也都有可能會發生:
-
更改全局變量
-
處理用戶輸入
-
屏幕打印或打印 log 日誌
-
DOM 查詢以及瀏覽器 cookie、localstorage 查詢
-
發送 http 請求
-
拋出異常,未被當前函數捕獲
-
...
副作用往往會影響代碼的可讀性和複雜性,從而導致意想不到的 bug。在實際開發中,我們是離不開副作用的,那麼在函數式編程中應儘量減少副作用,儘量書寫純函數。
引用透明
如果一個函數對於相同輸出始終產生同一個輸出結果,完全不依賴外部環境的變化,那麼就可以說它是引用透明的。
數據不可變
所有數據被創建後不可更改,如果想要修改變量,需要新建一個新的對象進行修改(例如上面純函數提到的例子)。
說完這些概念,我們再來看一下在函數式編程中又有哪些常見的操作。
柯里化(curry)
把接受多個參數的函數變換成接受一個單一參數的函數,並返回接受剩餘參數而且返回結果的新函數。
F(a,b,c) => F(a)(b)(c)
接下來我們實現一版簡單的 curry 函數。
function curry(targetFunc) {
// 獲取目標函數的參數個數
const argsLen = targetFunc.length
return function func(...rest) {
return rest.length < argsLen ? func.bind(null, ...rest) : targetFunc.apply(null, rest)
}
}
function add(a,b,c,d) {
return a + b + c + d
}
console.log(curry(add)(1)(2)(3)(4));
console.log(curry(add)(1, 2)(3)(4));
// 10
仔細的同學可能已經看出來,上面實現的 curry 函數並不是單純柯里化函數,因爲柯里化強調的是生成單元函數,但是單次傳入多個參數也可以,更像是柯里化和偏函數的綜合應用。那偏函數又是怎麼定義的呢?
偏函數(Partial)是指固定一個函數的一些參數,然後產生另一個更小元的函數。
偏函數在創建的時候還可以傳入預設的partials
參數,類似bind
的使用。通常情況下,我們不會自己寫 curry 函數,像 Lodash、Ramda 這些庫都實現了 curry 函數,這些庫實現的 curry 函數和柯里化的定義也是不太一樣的。
const add = function (a, b, c) {return a + b + c}
const curried = _.curry(add)
curried(1)(2)(3)
curried(1, 2)(3)
curried(1, 2, 3)
// 還實現了附加參數的佔位符
curried(1)(_, 3)(2)
組合(compose)
compose 在函數式編程中也是一個很重要的思想。**把複雜的邏輯拆分成一個個簡單任務,最後組合起來完成任務,使得整個過程的數據流更明確、可控、可讀。**這也印證了上面我們提到過:函數式編程像一條流水線,初始數據通過多個函數依次處理,最後完成整體輸出。
// 整個過程處理
a => fn => b
// 拆分成多段處理
a => fn1 => fn2 => fn3 => b
接下來,我們實現一般簡單的 compose:
function compose(...fns) {
return fns.reduce((a,b) => {
return (...args) => {
return a(b(...args))
}
})
}
function fn1(a) {
console.log('fn1: ', a);
return a+1
}
function fn2(a) {
console.log('fn2: ', a);
return a+1
}
function fn3(a) {
console.log('fn3: ', a);
return a+1
}
console.log(compose(fn1, fn2, fn3)(1));
// fn3: 1
// fn2: 2
// fn1: 3
// 4
分析上述 compose 的實現,可以看出 fn3 是先於 fn2 執行,fn2 先於 fn1 執行,也就是說:compose 創建了一個從右向左執行的數據流。如果要實現從左到右的數據流,可以直接更改 compose 的部分代碼即可實現:
-
更換 Api 接口:把
reduce
改爲reduceRight
-
交互包裹位置:把
a(b(...args))
改爲b(a(...args))
。
也可以使用 Ramda 中提供的組合方式:管道(pipe)。
R.pipe(fn1, fn2, fn3)
函數組合不僅讓代碼更富有可讀性,數據流的整體流向也更加清晰,程序更加可控。接下來,我們看下函數式編程在具體業務中的實踐。
編程實踐
數據處理
業務開發過程中,我們更多的時候是對接口請求數據或表單提交數據的處理,尤其是經常開發 B 端的同學更是深有體會。筆者之前就做過針對大量表單數據的處理需求,例如:針對用戶提交的表單數據做一定的處理:1. 清除空格;2. 全部轉爲大寫。
首先我們站在函數式編程的思維上分析一下整個需求:
-
抽象:每個處理過程都是一個純函數
-
組合:通過 compose 組合每一個處理函數
-
擴展:只需刪除或添加對應的處理純函數即可
接下來,我們看一下整體的實現:
// 1. 實現遍歷函數
function traverse (obj, handler) {
if (typeof obj !== 'object') return handler(obj)
const copy = {}
Object.keys(obj).forEach(key => {
copy[key] = traverse(obj[key], handler)
})
return copy
}
// 2. 實現具體業務處理的純函數
function toUpperCase(str) {
return str.toUpperCase() // 轉爲大寫
}
function toTrim(str) {
return str.trim() // 刪除前後空格
}
// 3. 通過compose執行
// 用戶提交數據如下:
const obj = {
info: {
name: ' asyncguo '
},
address: {
province: 'beijing',
city: 'beijing',
area: 'haidian'
}
}
console.log(traverse(obj, compose(toUpperCase, toTrim)));
/**
{
info: { name: 'ASYNCGUO' },
address: { province: 'BEIJING', city: 'BEIJING', area: 'HAIDIAN' }
}
*/
redux 中間件實現
說到函數式在 JavaScript 中的實踐,那就不得不聊一下 redux。首先我們先實現一版簡單 redux:
function createStore(reducer) {
let currentState
let listeners = []
function getState() {
return currentState
}
function dispatch(action) {
currentState = reducer(currentState, action)
listeners.map(listener => {
listener()
})
return action
}
function subscribe(cb) {
listeners.push(cb)
return () => {}
}
dispatch({type: 'ZZZZZZZZZZ'})
return {
getState,
dispatch,
subscribe
}
}
// 應用實例如下:
function reducer(state = 0, action) {
switch (action.type) {
case 'ADD':
return state + 1
case 'MINUS':
return state - 1
default:
return state
}
}
const store = createStore(reducer)
console.log(store);
store.subscribe(() => {
console.log('change');
})
console.log(store.getState());
console.log(store.dispatch({type: 'ADD'}));
console.log(store.getState());
首先使用reducer
初始化store
,後續事件產生時,通過dispatch
更新store
狀態,同時通過getState
獲取store
的最新狀態。
redux
規範了單向數據流,action
只能由dispatch
函數派發,並通過純函數reducer
更新狀態state
,然後繼續等待下一次的事件。這種單向數據流的機制進一步簡化事件管理的複雜度,並且還可以在事件流程中插入中間件(middleware)。通過中間件,可以實現日誌記錄、thunk、異步處理等一系列擴展處理,大大得增強事件處理的靈活性。
接下來對上面的 redux 進一步增強優化:
// 擴展createStore
function createStore(reducer, enhancer){
if (enhancer) {
return enhancer(createStore)(reducer)
}
...
}
// 中間件的實現
function applyMiddleware(...middlewares) {
return function (createStore) {
return function (reducer) {
const store = createStore(reducer)
let _dispatch = store.dispatch
const middlewareApi = {
getState: store.getState,
dispatch: action => _dispatch(action)
}
// 獲取中間件數組:[mid1, mid2]
// mid1 = next1 => action1 => {}
// mid2 = next2 => action2 => {}
const midChain = middlewares.map(mid => mid(middlewareApi))
// 通過compose組合中間件:mid1(mid2(mid3())),得到最終的dispatch
// 1. compse執行順序:next2 => next1
// 2. 最終dispatch:action1 (action1中調用next時,回到上一個中間件action2; action2中調用next時,回到最原始的dispatch)
_dispatch = compose(...midChain)(store.dispatch)
return {
...store,
dispatch: _dispatch
}
}
}
}
// 自定義中間件模板
const middleaware = store => next => action => {
// ...邏輯處理
next(action)
}
通過compose
組合所有的middleware
,然後返回包裝過的dispatch
。接下來,在每次dispatch
時,action
會經過全部中間件進行一系列操作,最後透傳給純函數reducer
進行真正的狀態更新。任何middleware
能夠做到的事情,我們都可以通過手動包裝dispatch
調用實現,但是放在同一個地方統一管理使得整個項目的擴展變得更加容易。
// 1. 手動包裝dispatch調用,實現logger功能
function dispatchWithLog(store, action) {
console.log('dispatching', action)
store.dispatch(action)
console.log('next state', store.getState())
}
dispatchWithLog(store, {type: 'ADD'})
// 2. 中間件方式包裝dispatch調用
const store = new Store(reducer, applyMiddleware(thunkMiddleware, loggerMiddleware))
store.dispatch(() => {
setTimeout(() => {
store.dispatch({type: 'ADD'})
}, 2000)
})
// 中間件執行過程
thunk => logger => store.dispatch
RxJS
提到Rxjs
,更多人想到應該是響應式編程(Reactive Programming, RP),即使用異步數據流進行編程。響應式編程使用Rx.Observale
爲異步數據提供統一的名爲可觀察的流(observeale stream)的概念,可以說響應式編程的世界就是流的世界。想要提取其值,就必須先訂閱它。例如:
Rx.observale.of(1, 2, 3, 4, 5)
.filter(x => x%2 !== 0)
.map(x => x * x)
.subscrible(x => console.log(`ext: ${x}`))
通過上面的例子,可以發現響應式編程就是讓整個編程過程流式化,就像一條流水線,同時以函數式編程爲主,即流水線的每條工序都是無副作用的(純函數)。所以更準確的說Rxjs
應該是函數響應式編程(Functional Reactive Programming,FRP),顧名思義,FRP 同時具有函數式編程和響應式編程的特點。(今天主要是講函數式編程,更多Rxjs
部分的內容,感興趣的同學可以自行了解一下。筆者還是很推薦學習一下Rxjs
在異步數據流上的處理~)
總結
函數式編程是一個很大的話題,今天我們主要是介紹了一下函數式編程的基礎概念,當然還有更高級的概念:Functor(函子)、Monad、Application Functor 等還沒有提到,真正掌握這些東西還是需要一定練習積累,感興趣的同學可以自行了解一下,或者期待筆者後續的文章。
對比面向對象編程,我們可以總結一下,函數式編程的優點:
-
代碼更加簡明,流程更可控
-
流式處理數據
-
降低事件驅動代碼的複雜性
當然,函數式編程也存在一定的性能問題,在抽象層次往往因爲過度包裝,導致上下文切換的性能開銷;同時由於數據不可變的特點,中間變量也會消耗更多內存空間。
在日常業務開發中,函數式編程應是與面向對象編程以互補的形式存在,根據具體的需求選擇合適的編程範式。在面對一種新技術或新的編程方式時,若其優點值得我們學習和借鑑時,並不應該因爲某個缺陷就一味的拒絕它,更多時候是應該能夠想到與其互補的更優解。不以優而喜,不以劣而悲,與君共勉~
推薦資料
編程範式 (https://zh.wikipedia.org/wiki/%E7%BC%96%E7%A8%8B%E8%8C%83%E5%9E%8B)
functional light JS(https://frontendmasters.com/courses/functional-javascript-v3/)
Functional-Light-JS - github(https://github.com/getify/Functional-Light-JS)
redux-middleware(https://www.redux.org.cn/docs/api/applyMiddleware.html)
函數式編程淺析 (https://zhuanlan.zhihu.com/p/74777206)
函數式編程在 Redux/React 中的應用 (https://tech.meituan.com/2017/10/12/functional-programming-in-redux.html)
函數式編程指北 (https://llh911001.gitbooks.io/mostly-adequate-guide-chinese/content/ch5.html)
JavaScript 函數式編程指南 (https://book.douban.com/subject/30283769/)
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/BzMHd4KNbuSn1VqDTT9Rtg