淺析 JavaScript 函數(shù)式編程
前言
隨著React的流行,函數(shù)式編程在前端領(lǐng)域備受關(guān)注。尤其近幾年,越來越多的類庫偏向于函數(shù)式開發(fā):lodash/fp,Rx.js、Redux的純函數(shù),React16.8推出的hooks,Vue3.0的composition Api...同時(shí)在ES5/ES6標(biāo)準(zhǔn)中也有體現(xiàn),例如:箭頭函數(shù)、迭代器、map、filter、reduce等。
那么為什么要使用函數(shù)式編程呢?我們通過一個(gè)例子感受一下:在業(yè)務(wù)需求開發(fā)中,我們更多時(shí)候是對(duì)數(shù)據(jù)的處理,例如:將字符串?dāng)?shù)組進(jìn)行分類,轉(zhuǎn)為字符串對(duì)象格式。
// jsList => jsObj
const jsList = [
'es5:forEach',
'es5:map',
'es5:filter',
'es6:find',
'es6:findIndex',
'add'
]
const jsObj = {
es5: ["forEach", "map", "filter"],
es6: ["find", "findIndex"]
}
先通過我們最常用的命令式實(shí)現(xiàn)一遍:
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);
}
}
接下來再看函數(shù)式的實(shí)現(xiàn):
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]
}
}, {})
兩段代碼對(duì)比下來,會(huì)發(fā)現(xiàn)命令式的實(shí)現(xiàn)過程中會(huì)產(chǎn)生大量的臨時(shí)變量,還參雜大量的邏輯處理,通常只有讀完整段代碼才會(huì)明白具體做了什么。如果后續(xù)需求變更,又會(huì)添加更多的邏輯處理,想想腦殼都痛...
反觀函數(shù)式的實(shí)現(xiàn):單看每個(gè)函數(shù),就可以知道在做什么,代碼更加語義化,可讀性更高。整個(gè)過程就像一條完整的流水線,數(shù)據(jù)從一個(gè)函數(shù)輸入,處理完成后流入下一個(gè)處理函數(shù)...每個(gè)函數(shù)都是各司其職。
接下來,讓我們?cè)诟Q探函數(shù)式編程的世界之前,先簡單了解一下上面提到的編程范式。
編程范式
編程范式是指軟件工程中的一類典型的編程風(fēng)格,編程范式提供并決定了程序員對(duì)程序的看法。
例如在面向?qū)ο缶幊讨?,程序員認(rèn)為程序是一系列相互作用的對(duì)象;而在函數(shù)式編程中,程序會(huì)被當(dāng)做一個(gè)無狀態(tài)的函數(shù)計(jì)算的序列。常見的編程范式如下:
命令式編程
命令式編程是一種描述電腦所需作出的行為的編程范式,也是目前使用最廣的編程范式,其主要思想就是站在計(jì)算機(jī)的角度思考問題,關(guān)注計(jì)算執(zhí)行步驟,每一步都是指令。(代表:C、C++、Java)
大部分命令式編程語言都支持四種基本的語句:
運(yùn)算語句; 循環(huán)語句(for、while); 條件分支語句(if else、switch); 無條件分支語句(return、break、continue)。
計(jì)算機(jī)執(zhí)行的每一個(gè)步驟都是程序員控制的,所以可以更加精細(xì)嚴(yán)謹(jǐn)?shù)目刂拼a,提高應(yīng)用程序的性能;但是由于存在大量的流程控制語句,在處理多線程、并發(fā)問題時(shí),容易造成邏輯紊亂。
聲明式編程
聲明式編程描述的是目標(biāo)的性質(zhì),讓計(jì)算機(jī)明白目標(biāo),而非流程。通過定義具體的規(guī)則,以便系統(tǒng)底層可以自動(dòng)實(shí)現(xiàn)具體功能。(代表:Haskell)
相較于命令式編程范式,不需要流程控制語言,沒有冗余的操作步驟,使得代碼更加語義化,降低了代碼的復(fù)雜性;但是其底層實(shí)現(xiàn)的邏輯并不可控,不適合做更加精細(xì)的代碼優(yōu)化。
總結(jié)下來,這兩種編程范式最大的不同就是:
How:命令式編程告訴計(jì)算機(jī) 如何計(jì)算,關(guān)心解決問題的步驟;What:聲明式編程告訴計(jì)算機(jī)需要計(jì)算 什么,關(guān)心解決問題的目標(biāo)。
函數(shù)式編程
聲明式編程是一個(gè)大的概念,其下包含一些有名的子編程范式:約束式編程、領(lǐng)域?qū)僬Z言、邏輯式編程、函數(shù)式編程。其中領(lǐng)域?qū)僬Z言(DSL)和函數(shù)式編程(FP)在前端領(lǐng)域的應(yīng)用更加廣泛,接下來開始我們今天的主角--函數(shù)式編程。
函數(shù)式編程并不是一種工具,而是一種可以適用于任何環(huán)境的編程思想,它是一種以函數(shù)使用為主的軟件開發(fā)風(fēng)格。這與大家都熟悉的面向?qū)ο缶幊痰乃季S方式完全不同,函數(shù)式的目的是通過函數(shù)抽象作用在數(shù)據(jù)流的操作,從而在系統(tǒng)中消除副作用并減少對(duì)狀態(tài)的改變。
為了充分理解函數(shù)式編程,我們先來看下它有哪些基本概念?
概念
函數(shù)是一等公民
函數(shù)與其他數(shù)據(jù)類型一樣,不僅可以賦值給變量,也可以當(dāng)作參數(shù)傳遞,或者做為函數(shù)的返回值。例如:
// 做為變量
fn = () => {}
// 做為參數(shù)
function fn1(fn){fn()}
// 做為函數(shù)返回值
function fn2(){return () => {} }
正是函數(shù)是‘一等公民’的前提,函數(shù)式編程才得以實(shí)現(xiàn),而在JavaScript中,閉包和高階函數(shù)成了中堅(jiān)力量。
純函數(shù)
純函數(shù)是這樣一種函數(shù),即相同的輸入,永遠(yuǎn)會(huì)得到相同的輸出,而且沒有任何可觀察的副作用。
提到純函數(shù),熟悉redux的同學(xué)可能再熟悉不過了,在redux中所有的修改都需要使用純函數(shù)。純函數(shù)具有以下特點(diǎn):
無狀態(tài):函數(shù)的輸出僅取決于輸入,而不依賴外部狀態(tài); 無副作用:不會(huì)造成超出其作用域的變化,即不修改函數(shù)參數(shù)或全局變量等。
function add(obj) {
obj.num += 1
return obj
}
const obj = {num: 1}
add(obj)
console.log(obj)
// { num: 2 }
這個(gè)函數(shù)不是純的,因?yàn)閖s對(duì)象傳遞的是引用地址,函數(shù)內(nèi)部的修改會(huì)直接影響外部變量,最后產(chǎn)生了預(yù)料之外的結(jié)果。接下來,我們改成純函數(shù)的寫法:
function add(obj) {
const _obj = {...obj}
_obj.num += 1
return _obj
}
const obj = {num: 1}
add(obj)
console.log(obj);
// { num: 1 }
通過在函數(shù)內(nèi)部創(chuàng)建新的變量進(jìn)行更改(是不是有想起redux的reducer寫法~~),從而避免產(chǎn)生副作用。純函數(shù)除了無副作用外,還有其他好處:
可緩存性正是因?yàn)楹瘮?shù)式聲明的無狀態(tài)特點(diǎn),即:相同輸入總能得到相同的輸出。所以我們可以提前緩存函數(shù)的執(zhí)行結(jié)果,實(shí)現(xiàn)更多功能。例如:優(yōu)化斐波拉契數(shù)列的遞歸解法。 可移植性/自文檔化純函數(shù)的依賴很明確,更易于觀察和理解,配合類型簽名可以使程序更加簡單易讀。
// get :: a -> a
const get = function (id) { return id}
// map :: (a -> b) -> [a] -> [b]
const map = curry(function (f, res){
return res.map(f)
})
可測(cè)試性純函數(shù)讓測(cè)試更加簡單,只需簡單地給函數(shù)一個(gè)輸入,然后斷言輸出就可以了。
副作用
函數(shù)的副作用是指在調(diào)用函數(shù)時(shí),除了返回函數(shù)值外還產(chǎn)生了額外的影響。例如修改上個(gè)例子中的修改參數(shù)或者全局變量。除此之外,以下副作用也都有可能會(huì)發(fā)生:
更改全局變量 處理用戶輸入 屏幕打印或打印log日志 DOM查詢以及瀏覽器cookie、localstorage查詢 發(fā)送http請(qǐng)求 拋出異常,未被當(dāng)前函數(shù)捕獲 ...
副作用往往會(huì)影響代碼的可讀性和復(fù)雜性,從而導(dǎo)致意想不到的bug。在實(shí)際開發(fā)中,我們是離不開副作用的,那么在函數(shù)式編程中應(yīng)盡量減少副作用,盡量書寫純函數(shù)。
引用透明
如果一個(gè)函數(shù)對(duì)于相同輸出始終產(chǎn)生同一個(gè)輸出結(jié)果,完全不依賴外部環(huán)境的變化,那么就可以說它是引用透明的。
數(shù)據(jù)不可變
所有數(shù)據(jù)被創(chuàng)建后不可更改,如果想要修改變量,需要新建一個(gè)新的對(duì)象進(jìn)行修改(例如上面純函數(shù)提到的例子)。
說完這些概念,我們?cè)賮砜匆幌略诤瘮?shù)式編程中又有哪些常見的操作。
柯里化(curry)
把接受多個(gè)參數(shù)的函數(shù)變換成接受一個(gè)單一參數(shù)的函數(shù),并返回接受剩余參數(shù)而且返回結(jié)果的新函數(shù)。
F(a,b,c) => F(a)(b)(c)
接下來我們實(shí)現(xiàn)一版簡單的curry函數(shù)。
function curry(targetFunc) {
// 獲取目標(biāo)函數(shù)的參數(shù)個(gè)數(shù)
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
仔細(xì)的同學(xué)可能已經(jīng)看出來,上面實(shí)現(xiàn)的curry函數(shù)并不是單純柯里化函數(shù),因?yàn)榭吕锘瘡?qiáng)調(diào)的是生成單元函數(shù),但是單次傳入多個(gè)參數(shù)也可以,更像是柯里化和偏函數(shù)的綜合應(yīng)用。那偏函數(shù)又是怎么定義的呢?
偏函數(shù)(Partial)是指固定一個(gè)函數(shù)的一些參數(shù),然后產(chǎn)生另一個(gè)更小元的函數(shù)。
偏函數(shù)在創(chuàng)建的時(shí)候還可以傳入預(yù)設(shè)的partials參數(shù),類似bind的使用。通常情況下,我們不會(huì)自己寫curry函數(shù),像Lodash、Ramda這些庫都實(shí)現(xiàn)了curry函數(shù),這些庫實(shí)現(xiàn)的curry函數(shù)和柯里化的定義也是不太一樣的。
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)
// 還實(shí)現(xiàn)了附加參數(shù)的占位符
curried(1)(_, 3)(2)
組合(compose)
compose在函數(shù)式編程中也是一個(gè)很重要的思想。把復(fù)雜的邏輯拆分成一個(gè)個(gè)簡單任務(wù),最后組合起來完成任務(wù),使得整個(gè)過程的數(shù)據(jù)流更明確、可控、可讀。這也印證了上面我們提到過:函數(shù)式編程像一條流水線,初始數(shù)據(jù)通過多個(gè)函數(shù)依次處理,最后完成整體輸出。
// 整個(gè)過程處理
a => fn => b
// 拆分成多段處理
a => fn1 => fn2 => fn3 => b
接下來,我們實(shí)現(xiàn)一般簡單的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的實(shí)現(xiàn),可以看出fn3是先于fn2執(zhí)行,fn2先于fn1執(zhí)行,也就是說:compose創(chuàng)建了一個(gè)從右向左執(zhí)行的數(shù)據(jù)流。如果要實(shí)現(xiàn)從左到右的數(shù)據(jù)流,可以直接更改compose的部分代碼即可實(shí)現(xiàn):
更換Api接口:把 reduce改為reduceRight交互包裹位置:把 a(b(...args))改為b(a(...args))。
也可以使用Ramda中提供的組合方式:管道(pipe)。
R.pipe(fn1, fn2, fn3)
函數(shù)組合不僅讓代碼更富有可讀性,數(shù)據(jù)流的整體流向也更加清晰,程序更加可控。接下來,我們看下函數(shù)式編程在具體業(yè)務(wù)中的實(shí)踐。
編程實(shí)踐
數(shù)據(jù)處理
業(yè)務(wù)開發(fā)過程中,我們更多的時(shí)候是對(duì)接口請(qǐng)求數(shù)據(jù)或表單提交數(shù)據(jù)的處理,尤其是經(jīng)常開發(fā)B端的同學(xué)更是深有體會(huì)。筆者之前就做過針對(duì)大量表單數(shù)據(jù)的處理需求,例如:針對(duì)用戶提交的表單數(shù)據(jù)做一定的處理:1. 清除空格;2. 全部轉(zhuǎn)為大寫。
首先我們站在函數(shù)式編程的思維上分析一下整個(gè)需求:
抽象:每個(gè)處理過程都是一個(gè)純函數(shù) 組合:通過compose組合每一個(gè)處理函數(shù) 擴(kuò)展:只需刪除或添加對(duì)應(yīng)的處理純函數(shù)即可
接下來,我們看一下整體的實(shí)現(xiàn):
// 1. 實(shí)現(xiàn)遍歷函數(shù)
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. 實(shí)現(xiàn)具體業(yè)務(wù)處理的純函數(shù)
function toUpperCase(str) {
return str.toUpperCase() // 轉(zhuǎn)為大寫
}
function toTrim(str) {
return str.trim() // 刪除前后空格
}
// 3. 通過compose執(zhí)行
// 用戶提交數(shù)據(jù)如下:
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中間件實(shí)現(xiàn)
說到函數(shù)式在JavaScript中的實(shí)踐,那就不得不聊一下redux。首先我們先實(shí)現(xiàn)一版簡單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
}
}
// 應(yīng)用實(shí)例如下:
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,后續(xù)事件產(chǎn)生時(shí),通過dispatch更新store狀態(tài),同時(shí)通過getState獲取store的最新狀態(tài)。
redux規(guī)范了單向數(shù)據(jù)流,action只能由dispatch函數(shù)派發(fā),并通過純函數(shù)reducer更新狀態(tài)state,然后繼續(xù)等待下一次的事件。這種單向數(shù)據(jù)流的機(jī)制進(jìn)一步簡化事件管理的復(fù)雜度,并且還可以在事件流程中插入中間件(middleware)。通過中間件,可以實(shí)現(xiàn)日志記錄、thunk、異步處理等一系列擴(kuò)展處理,大大得增強(qiáng)事件處理的靈活性。
接下來對(duì)上面的redux進(jìn)一步增強(qiáng)優(yōu)化:
// 擴(kuò)展createStore
function createStore(reducer, enhancer){
if (enhancer) {
return enhancer(createStore)(reducer)
}
...
}
// 中間件的實(shí)現(xiàn)
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)
}
// 獲取中間件數(shù)組:[mid1, mid2]
// mid1 = next1 => action1 => {}
// mid2 = next2 => action2 => {}
const midChain = middlewares.map(mid => mid(middlewareApi))
// 通過compose組合中間件:mid1(mid2(mid3())),得到最終的dispatch
// 1. compse執(zhí)行順序:next2 => next1
// 2. 最終dispatch:action1 (action1中調(diào)用next時(shí),回到上一個(gè)中間件action2; action2中調(diào)用next時(shí),回到最原始的dispatch)
_dispatch = compose(...midChain)(store.dispatch)
return {
...store,
dispatch: _dispatch
}
}
}
}
// 自定義中間件模板
const middleaware = store => next => action => {
// ...邏輯處理
next(action)
}
通過compose組合所有的middleware,然后返回包裝過的dispatch。接下來,在每次dispatch時(shí),action會(huì)經(jīng)過全部中間件進(jìn)行一系列操作,最后透傳給純函數(shù)reducer進(jìn)行真正的狀態(tài)更新。任何middleware能夠做到的事情,我們都可以通過手動(dòng)包裝dispatch調(diào)用實(shí)現(xiàn),但是放在同一個(gè)地方統(tǒng)一管理使得整個(gè)項(xiàng)目的擴(kuò)展變得更加容易。
// 1. 手動(dòng)包裝dispatch調(diào)用,實(shí)現(xiàn)logger功能
function dispatchWithLog(store, action) {
console.log('dispatching', action)
store.dispatch(action)
console.log('next state', store.getState())
}
dispatchWithLog(store, {type: 'ADD'})
// 2. 中間件方式包裝dispatch調(diào)用
const store = new Store(reducer, applyMiddleware(thunkMiddleware, loggerMiddleware))
store.dispatch(() => {
setTimeout(() => {
store.dispatch({type: 'ADD'})
}, 2000)
})
// 中間件執(zhí)行過程
thunk => logger => store.dispatch
RxJS
提到Rxjs,更多人想到應(yīng)該是響應(yīng)式編程(Reactive Programming, RP),即使用異步數(shù)據(jù)流進(jìn)行編程。響應(yīng)式編程使用Rx.Observale為異步數(shù)據(jù)提供統(tǒng)一的名為可觀察的流(observeale stream)的概念,可以說響應(yīng)式編程的世界就是流的世界。想要提取其值,就必須先訂閱它。例如:
Rx.observale.of(1, 2, 3, 4, 5)
.filter(x => x%2 !== 0)
.map(x => x * x)
.subscrible(x => console.log(`ext: ${x}`))
通過上面的例子,可以發(fā)現(xiàn)響應(yīng)式編程就是讓整個(gè)編程過程流式化,就像一條流水線,同時(shí)以函數(shù)式編程為主,即流水線的每條工序都是無副作用的(純函數(shù))。所以更準(zhǔn)確的說Rxjs應(yīng)該是函數(shù)響應(yīng)式編程(Functional Reactive Programming,F(xiàn)RP),顧名思義,FRP同時(shí)具有函數(shù)式編程和響應(yīng)式編程的特點(diǎn)。(今天主要是講函數(shù)式編程,更多Rxjs部分的內(nèi)容,感興趣的同學(xué)可以自行了解一下。筆者還是很推薦學(xué)習(xí)一下Rxjs在異步數(shù)據(jù)流上的處理~)
總結(jié)
函數(shù)式編程是一個(gè)很大的話題,今天我們主要是介紹了一下函數(shù)式編程的基礎(chǔ)概念,當(dāng)然還有更高級(jí)的概念:Functor(函子)、Monad、Application Functor等還沒有提到,真正掌握這些東西還是需要一定練習(xí)積累,感興趣的同學(xué)可以自行了解一下,或者期待筆者后續(xù)的文章。
對(duì)比面向?qū)ο缶幊?,我們可以總結(jié)一下,函數(shù)式編程的優(yōu)點(diǎn):
代碼更加簡明,流程更可控 流式處理數(shù)據(jù) 降低事件驅(qū)動(dòng)代碼的復(fù)雜性
當(dāng)然,函數(shù)式編程也存在一定的性能問題,在抽象層次往往因?yàn)檫^度包裝,導(dǎo)致上下文切換的性能開銷;同時(shí)由于數(shù)據(jù)不可變的特點(diǎn),中間變量也會(huì)消耗更多內(nèi)存空間。
在日常業(yè)務(wù)開發(fā)中,函數(shù)式編程應(yīng)是與面向?qū)ο缶幊桃曰パa(bǔ)的形式存在,根據(jù)具體的需求選擇合適的編程范式。在面對(duì)一種新技術(shù)或新的編程方式時(shí),若其優(yōu)點(diǎn)值得我們學(xué)習(xí)和借鑒時(shí),并不應(yīng)該因?yàn)槟硞€(gè)缺陷就一味的拒絕它,更多時(shí)候是應(yīng)該能夠想到與其互補(bǔ)的更優(yōu)解。不以優(yōu)而喜,不以劣而悲,與君共勉~
推薦資料
編程范式(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)
函數(shù)式編程淺析(https://zhuanlan.zhihu.com/p/74777206)
函數(shù)式編程在Redux/React中的應(yīng)用 (https://tech.meituan.com/2017/10/12/functional-programming-in-redux.html)
函數(shù)式編程指北(https://llh911001.gitbooks.io/mostly-adequate-guide-chinese/content/ch5.html)
JavaScript函數(shù)式編程指南(https://book.douban.com/subject/30283769/)
感謝你的閱讀,有任何問題,歡迎評(píng)論區(qū)留言討論,互相學(xué)習(xí)。
END


“分享、點(diǎn)贊、在看” 支持一波
