淺析 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)

大部分命令式編程語言都支持四種基本的語句:

  1. 運算語句;

  2. 循環語句(for、while);

  3. 條件分支語句(if else、switch);

  4. 無條件分支語句(return、break、continue)。

計算機執行的每一個步驟都是程序員控制的,所以可以更加精細嚴謹的控制代碼,提高應用程序的性能;但是由於存在大量的流程控制語句,在處理多線程、併發問題時,容易造成邏輯紊亂

聲明式編程

聲明式編程描述的是目標的性質,讓計算機明白目標,而非流程。通過定義具體的規則,以便系統底層可以自動實現具體功能。(代表:Haskell)

相較於命令式編程範式,不需要流程控制語言,沒有冗餘的操作步驟,使得代碼更加語義化,降低了代碼的複雜性;但是其底層實現的邏輯並不可控,不適合做更加精細的代碼優化。

總結下來,這兩種編程範式最大的不同就是:

  1. How:命令式編程告訴計算機如何 計算,關心解決問題的步驟;

  2. 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 寫法~~),從而避免產生副作用。純函數除了無副作用外,還有其他好處:

  1. 可緩存性正是因爲函數式聲明的無狀態特點,即:相同輸入總能得到相同的輸出。所以我們可以提前緩存函數的執行結果,實現更多功能。例如:優化斐波拉契數列的遞歸解法。

  2. 可移植性 / 自文檔化純函數的依賴很明確,更易於觀察和理解,配合類型簽名可以使程序更加簡單易讀。

// get :: a -> a
const get = function (id) { return id}
// map :: (a -> b) -> [a] -> [b]
const map = curry(function (f, res){
    return res.map(f)
})
  1. 可測試性純函數讓測試更加簡單,只需簡單地給函數一個輸入,然後斷言輸出就可以了。
副作用

函數的副作用是指在調用函數時,除了返回函數值外還產生了額外的影響。例如修改上個例子中的修改參數或者全局變量。除此之外,以下副作用也都有可能會發生:

副作用往往會影響代碼的可讀性和複雜性,從而導致意想不到的 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 的部分代碼即可實現:

也可以使用 Ramda 中提供的組合方式:管道(pipe)。

R.pipe(fn1, fn2, fn3)

函數組合不僅讓代碼更富有可讀性,數據流的整體流向也更加清晰,程序更加可控。接下來,我們看下函數式編程在具體業務中的實踐。

編程實踐

數據處理

業務開發過程中,我們更多的時候是對接口請求數據或表單提交數據的處理,尤其是經常開發 B 端的同學更是深有體會。筆者之前就做過針對大量表單數據的處理需求,例如:針對用戶提交的表單數據做一定的處理:1. 清除空格;2. 全部轉爲大寫。

首先我們站在函數式編程的思維上分析一下整個需求:

  1. 抽象:每個處理過程都是一個純函數

  2. 組合:通過 compose 組合每一個處理函數

  3. 擴展:只需刪除或添加對應的處理純函數即可

接下來,我們看一下整體的實現:

// 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(函子)MonadApplication 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