函數語言程式設計及其在react中的應用

沒有色彩的FE發表於2019-03-02

開頭:初衷是想寫一篇介紹redux的分享,結果閱讀原始碼時發現看懂原始碼還必須先對函數語言程式設計有一點的瞭解,結果寫著寫著就變成了一篇介紹函數語言程式設計的文章,也罷…

函數語言程式設計與js

1. ES6+ 語法對於函數語言程式設計更為友好

2. RxJS 、redux等青睞函式式的庫的開始流行,

3. 帶有‘函式式’標籤的框架開始流行,諸如react.

從react認識函數語言程式設計

這篇文章裡我將略去一大堆形式化的概念介紹,重點展示在 JavaScript 中一些常見的寫法,從例子講述到底什麼是函式式的程式碼、函式式程式碼與一般寫法有什麼區別、函式式的程式碼能給我們帶來什麼好處以及在react全家桶中常見的一些函式式模型都有哪些。

枯燥的概念

“函數語言程式設計”是一種”程式設計正規化”,也就是如何編寫程式的方法論,就像我們熟知的物件導向程式設計一樣。

我眼中的函數語言程式設計

以函式作為主要載體的程式設計方式,用函式去拆解、抽象一般的表示式,最小‘可視’單位是函式的一種編碼規範,重點體現‘函式式’,

理解命令式or宣告式開始

命令式程式碼的意思就是,我們通過編寫一條又一條指令去讓計算機執行一些動作,這其中一般都會涉及到很多繁雜的細節,面向的是命令的過程。 

與命令式不同,宣告式意味著我們要寫表示式(命令的意圖),而不是一步一步的具體的指示

注:表示式”是一個單純的運算過程,總是有返回值;”語句”是執行某種操作,沒有返回值。函數語言程式設計要求,只使用表示式,不使用語句。也就是說,每一步都是單純的運算,而且都有返回值。

// 命令式
const arr = [`apple`, `pen`, `apple-pen`];
for(const i in arr){
  const c = arr[i][0];
  arr[i] = c.toUpperCase() + arr[i].slice(1);
}
// 宣告式  
// 函式式寫法
function upperFirst(word) {
  return word[0].toUpperCase() + word.slice(1);
}

function wordToUpperCase(arr) {
  return arr.map(upperFirst);
}

console.log(wordToUpperCase([`apple`, `pen`, `apple-pen`]));
複製程式碼

宣告式的寫法是一個表示式(這裡是一個函式表示式),如何進行計數器迭代,返回的陣列如何收集,這些細節都隱藏了起來。它指明的是做什麼,而不是怎麼做。更加清晰和簡潔之外,我們可以通過函式命名一眼就能知道它大概做了些什麼,而不需要關注它內部的實現

這裡(宣告式)的一些明顯的好處

  1. 語義更加清晰(依賴簡潔有效的命名)
  2. 可複用性更高(函式為可呼叫的最小單位)
  3. 可維護性更好(只需關注表示式的內部的實現,更易定位bug)
  4. 作用域侷限,副作用少(es6之後區域性作用域的支援)

為什麼稱react元件為‘宣告式’UI?

function TodoListComponent(props) {
  return (
    <ul>
      {props.todos.map((message) => <Item key={message} message={message} />)}
    </ul>
  );
}
class Demo extends Component {
render() {
    return (
      <View></View>
  )
}}

複製程式碼

很簡單的判定方式,每次都有返回值(注意return)

函式是‘一等公民’

這句話解釋起來就是:函式和其他js其他資料型別都一樣…

再詳細一點:你可以像對待任何其 他資料型別一樣對待它們——把它們存在陣列裡,當作引數傳遞,賦值給變數…等 等。

所以我們將以函式為引數並且返回了另一個函式的函式稱之為高階函式:

  • 接受一個或多個函式作為輸入
  • 輸出一個函式

大概的模樣:

function gaojiefn(fn) {
    //...
    return enhandceFn // 返回一個增強過的函式
}複製程式碼

作用:函式功能的增強

在react中對應高階函式就是高階元件(hoc),顧名思義就是一個接受元件為引數並且返回一個元件的函式,它的強大之處在於能夠給任意數量的元件提供資料來源,並且可以被用來實現邏輯複用。 最最常見的例子:react-redux中的connect函式

connect(
    state => state.user,
    { action }
)(App)
複製程式碼

 其作用就是在元件APP外層包裹了一個用以獲取redux儲存的state和action的容器,我們通常稱他們為容器元件。
另外一個典型的例子就是react-router-v4提供的withRouter(),用來包裹任何需要獲取路由資訊的元件。 當然我們也可以發揮想象,自由發揮,自定義我們的hoc,來公用我們想共享的邏輯:

const newCom = (WrapedCom) => {
    //...公用的邏輯
    return <WrapedCom  {...props} />   
}複製程式碼

總結:高階元件在react中非常重要,配合es7支援的裝飾器寫法,強大而優美

沒有副作用的純函式

拿陣列操作的 slice 和 splice做比較

var xs = [1,2,3,4,5];
// 純的
xs.slice(0,3);
//=> [1,2,3]
xs.slice(0,3);
//=> [1,2,3]
xs.slice(0,3);
//=> [1,2,3]
// 不純的
xs.splice(0,3);
//=> [1,2,3]
xs.splice(0,3);
//=> [4,5]
xs.splice(0,3)
//=>[]
複製程式碼

這兩個函式的作用並無二致——但是注意,它們各自 的方式卻大不同。我們說 slice 符合純函式的定 義是因為對相同的輸入它保證能返回相同的輸出(每次返回新陣列)。而 splice 則會在原先的陣列操作,並修改原先的陣列。

通過上面的例子 我們大概知道,純函式大概就是這樣子

對於相同的輸入,永遠會得到相同的輸出,而且沒有任何可觀察的副作用,也不依賴外部環境的狀態。

在函式中要怎麼做?

不依賴不改變外界環境

// 不純的
var minimum = 21;
var checkAge = function(age) {
return age >= minimum;
};
// 純的
var checkAge = function(age) {
var minimum = 21;
return age >= minimum;
};

// 不純的
var count = 1
function add() {
    return count + 1
}
// 純的
function add() {
    var count = 1
    return count + 1
}複製程式碼

在我們從依賴性和改變外界環境的兩個角度舉了兩組例子

不純的版本中,函式 的結果將取決於 外部變數 甚至 修改外部變數。從而增加了認知負荷,有時候甚至引發bug,這就是為什麼我們剛開始寫js時,總有人警告我們不要設定全域性變數。
在純函式中他們總只著眼於自己的一畝三分地,不會給別人(程式設計師)添麻煩。

當然這並不是要求我們不要‘副作用’,一個有完整的程式總會產生‘副作用’,關鍵是我們怎麼去優雅的處理它們(在函式式的理念中可以定義一種有用的‘functor’去包裹‘副作用’)

優點:

1.可快取性
import _ from `lodash`;
var sin = _.memorize(x => Math.sin(x));

//第一次計算的時候會稍慢一點
var a = sin(1);

//第二次有了快取,速度極快
var b = sin(1);
複製程式碼

利用了lodash的memorize函式,第一次會在記憶體在記憶住了sin(1)的值,第二次的時候直接從記憶體中提取以來加快了執行速度,提升了效能(切記只有純函式才能這樣子儲存起來,因為他們對相同的輸入有相同的輸出)
不知道你有沒有用過 reselect 這個庫

redux state的任意改變都會導致所有容器元件的mapStateToProps的重新呼叫,進而導致使用到selectors重新計算,但state的一次改變只會影響到部分seletor的計算值,只要這個selector使用到的state的部分未發生改變,selector的計算值就不會發生改變,理論上這部分分計算時間是可以被節省的。 reselect正是用來解決這個問題的,它可以建立一個具有記憶功能的selector,但他們的計算引數並沒有發生改變時,不會再次計算,而是直接使用上次快取的結果。從而優化了效能。

2.可測試性

相同輸入=>相同輸出 這簡直是單元測試夢寐以求的

3.引用透明

純函式是完全自給自足的,它需要的所有東西明確表示。仔細思考思考這一 點…這種自給自足的好處是什麼呢?純函式的依賴很明確,因此更易於觀察 和理解,更加容易定位bug的位置

再談柯里化

由一道經典面試題入手:

add(1)(2)(3) = 6
add(1, 2, 3)(4) = 10
add(1)(2)(3)(4)(5) = 15
實現一個通用的add函式?複製程式碼

function add() {
        var _args = [];
        return function(){ 
            if(arguments.length === 0) { 
                return _args.reduce(function(a,b) {
                    return a + b;
                });
            }
            [].push.apply(_args, [].slice.call(arguments));
            return arguments.callee;
        }
    }
複製程式碼

上面的實現,利用閉包的特性,主要目的是想通過陣列操作的方法將所有的引數收集在一個陣列裡,並最終傳入reduce將陣列裡的所有項加起來。因此我們在呼叫add方法的時候,引數就顯得非常靈活(隨意組合,無關順序)。

柯里化==顆粒化

柯里化通常也稱部分求值,其含義是給函式分步傳遞引數,每次傳遞引數後,部分應用引數,並返回一個更具體的函式接受剩下的引數,中間可巢狀多層這樣的接受部分引數函式,逐步縮小函式的適用範圍,逐步求解,直至返回最後結果。

通俗的講:

函式柯里化允許和鼓勵你分隔複雜功能變成更小更容易分析的部分。這些小的邏輯單元顯然是更容易理解和測試的,然後你的應用就會變成乾淨而整潔的組合,由一些小單元組成的組合。

優點:

1.提高通用性

當我們把一個複雜邏輯的函式拆分成,一個個更小的邏輯單元時,函式組合(程式碼服用)將變得更加的靈活且維護測試更簡單。

2.延遲執行

3.固定易變因素(bind)

一道面試題,如何用call,apply來實現bind的功能

Object.prototype.bind = function(context) {
    var _this = this;
    var args = [].prototype.slice.call(arguments, 1);

    return function() {
        return _this.apply(context, args)
    }
}
複製程式碼

bind與call,apply的區別就是bind只繫結,不立即執行,而call,apply則立即回執行,所以這裡利用了延遲執行的特性實現了一個bind

下面是redux中關於中介軟體的應用

這是從生慶哥寫的一個自定義中介軟體

export default store => next => action => {
  //...省略
  return callApi({
    url,
    params,
    schema,
    method,
    json,
    customHeaders
  }).then((response) => {
    if (showLoading) {
      next(actionWith({
        response,
        type: successType,
        showLoading: false
      }));
    } else {
      next(actionWith({
        response,
        type: successType
      }));
    }
    Toast.hide();
    if (response.result.status !== `success`) {
      Toast.info(`  ${response.result.message}  `, 3, null, false);
    }
    
    /**
     * {
      errCode,
      errMsg,
      result
    };
     */
    return response;
  }
    next(actionWith(actionObj));
    
  });
};
複製程式碼

除去邏輯部分,大概就是這樣:

export default store => next => action => {
     //...邏輯部分
     next(action));
}複製程式碼

這就是redux中介軟體的實現,巢狀了三層函式,分別傳遞了store、next、action這三個引數,最後返回next(action),現在我們來看看柯里化在其中怎樣大顯身手的。

為何不在一層函式中同時傳遞三個引數呢?

當然如果只為了傳遞store、next、action這三個引數我們直接可以寫成一層,可是這裡中介軟體的每一層函式將來都會單獨執行,所以利用curry函式延遲執行的特性,記憶住每一層函式的返回,形成三個單獨函式。
我們再來解釋一下為什麼要這麼麻煩延遲執行?首先先來看一下redux對中介軟體處理的applymiddleware的原始碼:

function applyMiddleware() {
  for (var _len = arguments.length, middlewares = Array(_len), _key = 0; _key < _len; _key++) {
    middlewares[_key] = arguments[_key];
  }

  return function (createStore) {
    return function (reducer, preloadedState, enhancer) {
      var store = createStore(reducer, preloadedState, enhancer);
      var _dispatch = store.dispatch;
      var chain = [];

      var middlewareAPI = {
        getState: store.getState,
        dispatch: function dispatch(action) {
          return _dispatch(action);
        }
      };
      chain = middlewares.map(function (middleware) {
        return middleware(middlewareAPI);
      });
      _dispatch = _compose2[`default`].apply(undefined, chain)(store.dispatch);

      return _extends({}, store, {
        dispatch: _dispatch
      });
    };
  };
}
複製程式碼
  1. -中介軟體執行的第一層從這裡開始:
var middlewareAPI = {
    getState: store.getState,
    dispatch: function dispatch(action) {
      return _dispatch(action);
    }
  };
 chain = middlewares.map(function (middleware) {
    return middleware(middlewareAPI);
  });
複製程式碼

這裡生成一箇中介軟體函式陣列,並將middlewareAPI傳入,這裡其實就是中介軟體形成的第一步,將store傳入,這裡還有一個點就是在利用了閉包的原理,中介軟體的執行過程中若是有改變store的操作,會同步更新middlewareAPI,使得傳入每個middleware的store都是最新的

  1. -第二層的next執行在這裡:
_dispatch = _compose2[`default`].apply(undefined, chain)(store.dispatch);
複製程式碼

compose是函數語言程式設計中一個重要的功能:‘函式組合’,‘函式組合’也是函數語言程式設計的一個重要且強大的應用

export default function compose(...funcs) {
  if (funcs.length === 0) {
    return arg => arg
  }
  if (funcs.length === 1) {
    return funcs[0]
  }
  return funcs.reduce((a, b) => (...args) => a(b(...args)))
}複製程式碼

利用reduce函式將middleware組合成巢狀函式,最後結果是這樣:_dispatch=mid1(mid2(mid3(…(store.dispathch)))),形成pipe(管道),對最原始的dispatch進行了一個功能的增強

3.-第三層的實現,大家應該都使用過,就像這樣:

dispatch(action)
複製程式碼

觸發一個action

柯里化真的很強大

總結:函式式的基礎的概念大概就是這些,當然還有一些晦澀難懂卻很有用的思想沒有介紹(比如函子(functor),型別簽名(類似ts的型別定義)等等)。因為實在太晦澀難懂。

個人的看法:函數語言程式設計是一種以函式為‘最小可視’的程式設計思想,思考方式更加貼近人的大腦思考模式(或者稱之為 擬人化),但是函數語言程式設計並不是必須的,因為 函數語言程式設計是一種理念大於實踐的編碼理論知識,並不是所有的理念都適用於現在(比如現在的js),也許將來隨著語言的不斷髮展,會慢慢加深對函式式的支援,到時才能慢慢轉理論為實踐。

相關文章