前端開發函數式編程入門

函數式編程是一門古老的技術,從上個世紀 60 年代 Lisp 語言誕生開始,各種方言層出不窮。各種方言帶來欣欣向榮的生態的同時,也給兼容性帶來很大麻煩。於是更種標準化工作也在不斷根據現有的實現去整理,比如 Lisp 就定義了 Common Lisp 規範,但是一大分支 scheme 是獨立的分支。另一種函數式語言 ML,後來也標準化成 Standard ML,但也攔不住另一門方言 ocaml。後來的實踐乾脆成立一個委員會,定義一個通用的函數式編程語言,這就是 Haskell。後來 Haskell 被函數式原教旨主義者認爲是純函數式語言,而 Lisp, ML 系都有不符合純函數式的地方。

不管純不純,函數式編程語言因爲性能問題,一直影響其廣泛使用。直到單核性能在 Pentium 4 時代達到頂峯,單純靠提升單線程性能的免費午餐結束,函數式編程語言因爲其多線程安全性再次火了起來,先有 Erlang,後來還有 Scala, Clojure 等。

函數式編程的思想也不斷影響着傳統編程語言,比如 Java 8 開始支持 lambda 表達式,而函數式編程的大廈最初就是基於 lambda 計算構建起來的。

不過比起後端用 Java 的同學對於函數式編程思想是可選的,對於前端同學變成了必選項。

前端同學爲什麼要學習函數式編程思想?

React 框架的組件從很早開始就是不僅支持類式組件,也支持函數式的組件。

比如下面的類繼承的方式更符合大多數學過面向對象編程思想同學的心智:

class Welcome extends React.Component {
  render() {
    return <h1>Hello, {this.props.name}</h1>;
  }
}

但是,完全可以寫成下面這樣的函數式的組件:

function Welcome(props) {
  return <h1>Hello, {props.name}</h1>;
}

從 React 16.8 開始,React Hooks 的出現,使得函數式編程思想越來越變得不可或缺。

比如通過 React Hooks,我們可以這樣爲函數組件增加一個狀態:

import React, { useState } from 'react';
function Example() {
  // Declare a new state variable, which we'll call "count"
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

同樣我們可以使用 useEffect 來處理生命週期相關的操作,相當於是處理 ComponentDidMount:

import React, { useState, useEffect } from 'react';
function Example() {
  const [count, setCount] = useState(0);
  // Similar to componentDidMount and componentDidUpdate:
  useEffect(() => {
    // Update the document title using the browser API
    document.title = `You clicked ${count} times`;
  });
  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

那麼,useState, useEffect 之類的 API 跟函數式編程有什麼關係呢?

我們可以看下 useEffect 的 API 文檔:

Mutations, subscriptions, timers, logging, and other side effects are not allowed inside the main body of a function component (referred to as React’s render phase). Doing so will lead to confusing bugs and inconsistencies in the UI.

Instead, use useEffect. The function passed to useEffect will run after the render is committed to the screen. Think of effects as an escape hatch from React’s purely functional world into the imperative world.

所有的可變性、消息訂閱、定時器、日誌等副作用不能使用在函數組件的渲染過程中。useEffect 就是 React 純函數世界與命令式世界的通道。

當我們用 React 寫完了前端,現在想寫個 BFF 的功能,發現 serverless 也從原本框架套類的套娃模式變成了一個功能只需要一個函數了。下面是阿里雲 serverless HTTP 函數的官方例子:

var getRawBody = require('raw-body')
module.exports.handler = function (request, response, context) {
    // get requset header
    var reqHeader = request.headers
    var headerStr = ' '
    for (var key in reqHeader) {
        headerStr += key + ':' + reqHeader[key] + '  '
    };
    // get request info
    var url = request.url
    var path = request.path
    var queries = request.queries
    var queryStr = ''
    for (var param in queries) {
        queryStr += param + "=" + queries[param] + '  '
    };
    var method = request.method
    var clientIP = request.clientIP
    // get request body
    getRawBody(request, function (err, data) {
        var body = data
        // you can deal with your own logic here
        // set response
        var respBody = new Buffer('requestHeader:' + headerStr + '\n' + 'url: ' + url + '\n' + 'path: ' + path + '\n' + 'queries: ' + queryStr + '\n' + 'method: ' + method + '\n' + 'clientIP: ' + clientIP + '\n' + 'body: ' + body + '\n')
        response.setStatusCode(200)
        response.setHeader('content-type', 'application/json')
        response.send(respBody)
    })
};

雖然沒有需要關注副作用之類的要求,但是既然是用函數來寫了,用函數式思想總比命令式的要好。

學習函數式編程的方法和誤區

如果在網上搜 “如何學習函數式編程”,十有八九會找到要學習函數式編程最好從學習 Haskell 開始的觀點。

然後很可能你就瞭解到那句著名的話”A monad is just a monoid in the category of endofunctors, what's the problem?“。

翻譯過來可能跟沒翻譯差不多:” 一個單子(Monad)說白了不過就是自函子範疇上的一個幺半羣而已 “。

別被這些術語嚇到,就像 React 在純函數式世界外給我們提供了 useState, useEffect 這些 Hooks,就是幫我們解決產生副作用操作的工具。而函子 Functor, 單子 Monad 也是這樣的工具,或者可以認爲是設計模式。

Monad 在 Haskell 中的重要性在於,對於 IO 這樣雖然基礎但是有副作用的操作,純函數的 Haskell 是無法用函數式方法來處理掉的,所以需要藉助 IO Monad。大部分其它語言沒有這麼純,可以用非函數式的方法來處理 IO 之類的副作用操作,所以上面那句話被笑稱是 Haskell 用戶羣的接頭暗號。

有範疇論和類型論等知識做爲背景,當然會有助於從更高層次理解函數式編程。但是對於大部分前端開發同學來講,這筆技術債可以先欠着,先學會怎麼寫代碼去使用可能是更好的辦法。前端開發的計劃比較短,較難有大塊時間學習,但是我們可以迭代式的進步,最終是會殊途同歸的。

先把架式練好,用於代碼中解決實際業務問題,比被困難嚇住還停留在命令式的思想上還是要強的。

函數式編程的精髓:無副作用

前端同學學習函數式編程的優勢是 React Hooks 已經將副作用擺在我們面前了,不用再解釋爲什麼要寫無副用的代碼了。

無副作用的函數應該符合下面的特點:

  1. 要有輸入參數。如果沒有輸入參數,這個函數拿不到任意外部信息,也就不用運行了。

  2. 要有返回值。如果有輸入沒有返回值,又沒有副作用,那麼這個函數白調了。

  3. 對於確定的輸入,有確定的輸出

做到這一點,說簡單也簡單,只要保持功能足夠簡單就可以做到;說困難也困難,需要改變寫慣了命令行代碼的思路。

比如數學函數一般就是這樣的好例子,比如我們寫一個算平方的函數:

let sqr2 = function(x){
    return x * x; 
}
console.log(sqr2(200));

無副作用函數擁有三個巨大的好處:

  1. 可以進行緩存。我們就可以採用動態規劃的方法保存中間值,用來代替實際函數的執行結果,大大提升效率。

  2. 可以進行高併發。因爲不依賴於環境,可以調度到另一個線程、worker 甚至其它機器上,反正也沒有環境依賴。

  3. 容易測試,容易證明正確性。不容易產生偶現問題,也跟環境無關,非常利於測試。

即使是跟有副作用的代碼一起工作,我們也可以在副作用代碼中緩存無副作用函數的值,可以將無副作用函數併發執行。測試時也可以更重點關注有副作用的代碼以更有效地利用資源。

用函數的組合來代替命令的組合

會寫無副作用的函數之後,我們要學習的新問題就是如何將這些函數組合起來。

比如上面的 sqr2 函數有個問題,如果不是 number 類型,計算就會出錯。按照命令式的思路,我們可能就直接去修改 sqr2 的代碼,比如改成這樣:

let sqr2 = function(x){
    if (typeof x === 'number'){
        return x * x;
    }else{
        return 0;
    }
}

但是,sqr2 的代碼已經測好了,我們能不能不改它,只在它外面進行判斷?

是的,我們可以這樣寫:

let isNum = function(x){
    if (typeof x === 'number'){
        return x;
    }else{
        return 0;
    }
}
console.log(sqr2(isNum("20")));

或者是我們在設計 sqr2 的時候就先預留出來一個預處理函數的位置,將來要升級就換這個預處理函數,主體邏輯不變:

let sqr2_v3 = function(fn, x){
    let y = fn(x);
    return y * y; 
}
console.log((sqr2_v3(isNum,1.1)));

嫌每次都寫 isNum 煩,可以定義個新函數,把 isNum 給寫死進去:

let sqr2_v4 = function(x){
    return sqr2_v3(isNum,x);
}
console.log((sqr2_v4(2.2)));

用容器封裝函數能力

現在,我們想重用這個 isNum 的能力,不光是給 sqr2 用,我們想給其它數學函數也增加這個能力。

比如,如果給 Math.sin 計算 undefined 會得到一個 NaN:

console.log(Math.sin(undefined));

這時候我們需要用面向對象的思維了,將 isNum 的能力封裝到一個類中:

class MayBeNumber{
    constructor(x){
        this.x = x;
    }
    map(fn){
        return new MayBeNumber(fn(isNum(this.x)));
    }
    getValue(){
        return this.x;
    }
}

這樣,我們不管拿到一個什麼對象,用其構造一個 MayBeNumber 對象出來,再調用這個對象的 map 方法去調用數學函數,就自帶了 isNum 的能力。

我們先看調用 sqr2 的例子:

let num1 = new MayBeNumber(3.3).map(sqr2).getValue();
console.log(num1);
let notnum1 = new MayBeNumber(undefined).map(sqr2).getValue();
console.log(notnum1);

我們可以將 sqr2 換成 Math.sin:

let notnum2 = new MayBeNumber(undefined).map(Math.sin).getValue();
console.log(notnum2);

可以發現,輸出值從 NaN 變成了 0.

封裝到對象中的另一個好處是我們可以用 "." 多次調用了,比如我們想調兩次算 4 次方,只要在. map(sqr2) 之後再來一個. map(sqr2)

let num3 = new MayBeNumber(3.5).map(sqr2).map(sqr2).getValue();
console.log(num3);

使用對象封裝之後的另一個好處是,函數嵌套調用跟命令式是相反的順序,而用 map 則與命令式一致。

如果不理解的話我們來舉個例子,比如我們想求 sin(1) 的平方,用函數調用應該先寫後執行的 sqr2,後寫先執行的 Math.sin:

console.log(sqr2(Math.sin(1)));

而調用 map 就跟命令式一樣了:

let num4 = new MayBeNumber(1).map(Math.sin).map(sqr2).getValue();
console.log(num4);

用 of 來封裝 new

封裝到對象中,看起來還不錯,但是函數式編程還搞出來 new 對象再 map,爲什麼不能構造對象時也用個函數呢?

這好辦,我們給它定義個 of 方法吧:

MayBeNumber.of = function(x){
    return new MayBeNumber(x);
}

下面我們就可以用 of 來構造 MayBeNumber 對象啦:

let num5 = MayBeNumber.of(1).map(Math.cos).getValue();
console.log(num5);
let num6 = MayBeNumber.of(2).map(Math.tan).map(Math.exp).getValue();
console.log(num6);

有了 of 之後,我們也可以給 map 函數升升級。

之前的 isNum 有個問題,如果是非數字的話,其實沒必要賦給個 0 再去調用函數,直接返回個 0 就好了。

之前我們一直沒寫過箭頭函數,順手寫一寫:

isNum2 = x => typeof x === 'number';

map 用 isNum2 和 of 改寫下:

    map(fn){
        if (isNum2(this.x)){
            return MayBeNumber.of(fn(this.x));
        }else{
            return MayBeNumber.of(0);
        }
    }

我們再來看下另一種情況,我們處理返回值的時候,如果有 Error,就不處理 Ok 的返回值,可以這麼寫:

class Result{
    constructor(Ok, Err){
        this.Ok = Ok;
        this.Err = Err;
    }
    isOk(){
        return this.Err === null || this.Err === undefined;
    }
    map(fn){
        return this.isOk() ? Result.of(fn(this.Ok),this.Err) : Result.of(this.Ok, fn(this.Err));
    }
}
Result.of = function(Ok, Err){
    return new Result(Ok, Err);
}
console.log(Result.of(1.2,undefined).map(sqr2));

輸出結果爲:

Result { Ok: 1.44, Err: undefined }

我們來總結下前面這種容器的設計模式:

  1. 有一個用於存儲值的容器

  2. 這個容器提供一個 map 函數,作用是 map 函數使其調用的函數可以跟容器中的值進行計算,最終返回的還是容器的對象

我們可以把這個設計模式叫做 Functor 函子。

如果這個容器還提供一個 of 函數將值轉換成容器,那麼它叫做 Pointed Functor.

比如我們看下 js 中的 Array 類型:

let aa1 = Array.of(1);
console.log(aa1);
console.log(aa1.map(Math.sin));

它支持 of 函數,它還支持 map 函數調用 Math.sin 對 Array 中的值進行計算,map 的結果仍然是一個 Array。

那麼我們可以說,Array 是一個 Pointed Functor.

簡化對象層級

有了上面的 Result 結構了之後,我們的函數也跟着一起升級。如果是數值的話,Ok 是數值,Err 是 undefined。如果非數值的話,Ok 是 undefined,Err 是 0:

let sqr2_Result = function(x){
    if (isNum2(x)){
        return Result.of(x*x, undefined);
    }else{
        return Result.of(undefined,0);
    }
}

我們調用這個新的 sqr2_Result 函數:

console.log(Result.of(4.3,undefined).map(sqr2_Result));

返回的是一個嵌套的結果:

Result { Ok: Result { Ok: 18.49, Err: undefined }, Err: undefined }

我們需要給 Result 對象新加一個 join 函數,用來獲取子 Result 的值給父 Result:

    join(){
        if (this.isOk()) {
            return this.Ok;
        }else{
            return this.Err;
        }
    }

我們調用的時候最後加上調用這個 join:

console.log(Result.of(4.5,undefined).map(sqr2_Result).join());

嵌套的結果變成了一層的:

Result { Ok: 20.25, Err: undefined }

每次調用 map(fn).join() 兩個寫起來麻煩,我們定義一個 flatMap 函數一次性處理掉:

    flatMap(fn){
        return this.map(fn).join();
    }

調用方法如下:

console.log(Result.of(4.7,undefined).flatMap(sqr2_Result));

結果如下:

Result { Ok: 22.090000000000003, Err: undefined }

我們最後完整回顧下這個 Result:

class Result{
    constructor(Ok, Err){
        this.Ok = Ok;
        this.Err = Err;
    }
    isOk(){
        return this.Err === null || this.Err === undefined;
    }
    map(fn){
        return this.isOk() ? Result.of(fn(this.Ok),this.Err) : Result.of(this.Ok, fn(this.Err));
    }
    join(){
        if (this.isOk()) {
            return this.Ok;
        }else{
            return this.Err;
        }
    }
    flatMap(fn){
        return this.map(fn).join();
    }
}
Result.of = function(Ok, Err){
    return new Result(Ok, Err);
}

不嚴格地講,像 Result 這種實現了 flatMap 功能的 Pointed Functor,就是傳說中的 Monad.

偏函數和高階函數

在前面各種函數式編程模式中對函數的用法熟悉了之後,回來我們總結下函數式編程與命令行編程體感上的最大區別:

  1. 函數是一等公式,我們應該熟悉變量中保存函數再對其進行調用

  2. 函數可以出現在返回值裏,最重要的用法就是把輸入是 n(n>2) 個參數的函數轉換成 n 個 1 個參數的串聯調用,這就是傳說中的柯里化。這種減少了參數的新函數,我們稱之爲偏函數

  3. 函數可以用做函數的參數,這樣的函數稱爲高階函數

偏函數可以當作是更靈活的參數默認值。

比如我們有個結構叫 spm,由 spm_a 和 spm_b 組成。但是一個模塊中 spm_a 是固定的,大部分時候只需要指定 spm_b 就可以了,我們就可以寫一個偏函數:

const getSpm = function(spm_a, spm_b){
    return [spm_a, spm_b];
}
const getSpmb = function(spm_b){
    return getSpm(1000, spm_b);
}
console.log(getSpmb(1007));

高階函數我們在前面的 map 和 flatMap 裏面已經用得很熟了。但是,其實高階函數值得學習的設計模式還不少。

比如給大家出一個思考題,如何用函數式方法實現一個只執行一次有效的函數?

不要用全局變量啊,那不是函數式思維,我們要用閉包。

once 是一個高階函數,返回值是一個函數,如果 done 是 false,則將 done 設爲 true,然後執行 fn。done 是在返回函數的同一層,所以會被閉包記憶獲取到:

const once = (fn) => {
    let done = false;
    return function() {
        return done ? undefined : ((done=true), fn.apply(this,arguments));
    }
}
let init_data = once(
    () => {
        console.log("Initialize data");
    }
);
init_data();
init_data();

我們可以看到,第二次調用 init_data() 沒有發生任何事情。

遞歸與記憶

前面介紹了這麼多,但是函數編程其實還蠻複雜的,比如說涉及到遞歸。

遞歸中最簡單的就是階乘了吧:

let factorial = (n) => {
    if (n===0){
        return 1;
    }
    return n*factorial(n-1);
}
console.log(factorial(10));

但是我們都知道,這樣做效率很低,會重複計算好多次。應該採用動態規劃的辦法。

那麼如何在函數式編程中使用動態規劃,換句話說我們如何保存已經計算過的值?

想必經過上一節學習,大家肯定想到要用閉包,沒錯,我們可以封裝一個叫 memo 的高階函數來實現這個功能:

const memo = (fn) => {
    const cache = {};
    return (arg) => cache[arg] || (cache[arg] = fn(arg));
}

邏輯很簡單,返回值是 lamdba 表達式,它仍然支持閉包,所以我們在其同層定義一個 cache,然後如果 cache 中的某項爲空則計算並保存之,如果已經有了就直接使用。

這個高階函數很好用,階乘的邏輯不用改,只要放到 memo 中就好了:

let fastFact = memo(
    (n) => {
        if (n<=0){
            return 1;
        }else{
            return n * fastFact(n-1);
        }
    }
);

在本文即將結尾的時候,我們再回歸到前端,React Hooks 裏面提供的 useMemo,就是這樣的記憶機制:

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

小結

綜上,我們希望大家能記住幾點:

  1. 函數式編程的核心概念很簡單,就是將函數存到變量裏,用在參數裏,用在返回值裏

  2. 在編程時要時刻記住將無副作用與有副作用代碼分開

  3. 函數式編程的原理雖然很簡單,但是因爲大家習慣了命令式編程,剛開始學習時會有諸多不習慣,用多了就好了

  4. 函數式編程背後有其數學基礎,在學習時可以先不要管它,當成設計模式學習。等將來熟悉之後,還是建議去了解下背後的真正原理

關注「Alibaba F2E」微信公衆號把握阿里巴巴前端新動向

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/nHyDm_JWyuys245PBXpkqQ