前端基礎—帶你理解什么是函數(shù)式編程


框架總覽

  • ?? 引言

  • ?? 什么是函數(shù)式編程?

  • ?? 函數(shù)是純函數(shù)

    • ?? 什么是純函數(shù)
    • ?? 函數(shù)的副作用
    • ?? 使用純函數(shù)的優(yōu)點
      • ?? 可測試性
      • ?? 可緩存性
      • ?? 可移植性
    • ?? 純函數(shù)的特點
      • ?? 不可變性
      • ?? 引用透明
      • ?? 聲明式編程
  • ?? 函數(shù)是第一等公民

    • ?? 閉包
    • ?? 高階函數(shù)
    • ?? 函數(shù)柯里化(Currying)
    • ?? 函數(shù)合成(compose)
  • ?? 結(jié)合使用

  • ?? 擴展


1.引言

函數(shù)式編程的歷史已經(jīng)很悠久了,但是最近幾年卻頻繁的出現(xiàn)在大眾的視野,很多不支持函數(shù)式編程的語言也在積極加入閉包,匿名函數(shù)等非常典型的函數(shù)式編程特性。大量的前端框架也標榜自己使用了函數(shù)式編程的特性,好像一旦跟函數(shù)式編程沾邊,就很高大上一樣,而且還有一些專門針對函數(shù)式編程的框架和庫,比如:RxJS、cycleJS、ramdaJS、lodashJS、underscoreJS等。函數(shù)式編程變得越來越流行,掌握這種編程范式對書寫高質(zhì)量和易于維護的代碼都大有好處,所以我們有必要掌握它。


2.什么是函數(shù)式編程?

2.1維基百科定義:

函數(shù)式編程(英語:functional programming),又稱泛函編程,是一種編程范式,它將電腦運算視為數(shù)學上的函數(shù)計算,避免了狀態(tài)的變化和數(shù)據(jù)的可變。

2.2 簡單理解函數(shù)式編程的思想

在函數(shù)式編程之前,我們一直接觸的是面向?qū)ο蟮木幊趟枷?,兩者的區(qū)別:

  • 面向?qū)ο?/code>的世界里我們是把事物抽象成對象,然后通過封裝、繼承多態(tài)來演示他們之間的關系。
  • 函數(shù)式的世界里把世界抽象成事物事物之間的關系,用這種方式實現(xiàn)世界的模型。

面向?qū)ο蟮木幊谈腥饲槲兑恍└鐣?。比如你想要買一輛汽車,想要托人幫忙砍價。恰好你同學的朋友的大表哥的二嫂子在4s店工作。你就需要:

import "同學"
import "朋友"
import "大表哥"
import "二嫂子"

然后再調(diào)用二嫂子.砍價();

函數(shù)式編程則更“冰冷”一些,像是工廠里的流水線不停的運轉(zhuǎn),只要你放入材料就可以在出口處拿到產(chǎn)品。而且它對外部環(huán)境沒有依賴,只需要將流水線搬走就可以在任何地方生產(chǎn)。不需要找同學的朋友的大表哥的二嫂子來幫忙了。

相對于面向?qū)ο缶幊蹋∣bject-oriented programming)關注的是數(shù)據(jù)而言,函數(shù)式編程關注的則是動作,其是一種過程抽象的思維,就是對當前的動作去進行抽象。

比如說我要計算一個數(shù) 加上 4 再乘以 4 的值,按照正常寫代碼的邏輯,我們可能會這么去實現(xiàn)

function calculate(x){
    return (x + 4) * 4;
}
console.log(calculate(1))  // 20

這是沒有任何問題的,我們在平時開發(fā)的過程中會經(jīng)常將需要重復的操作封裝成函數(shù)以便在不同的地方能夠調(diào)用。但從函數(shù)式編程的思維來看的話,我們關注的則是這一系列操作的動作,先「加上 4」再「乘以 4」。
如何封裝函數(shù)才是最佳實踐呢?如何封裝才能使函數(shù)更加通用,使用起來讓人感覺更加舒服呢?函數(shù)式編程中的合成(compose)或許能給我們一些啟發(fā)。之后會講到。

函數(shù)式編程具有兩個基本特征

  • 函數(shù)是純函數(shù)
  • 函數(shù)是第一等公民

函數(shù)式編程具有兩個最基本的運算

  • 柯里化(Currying)
  • 合成(compose)

3.函數(shù)是純函數(shù)

3.1 什么是純函數(shù)

當我們想要理解函數(shù)式編程時,需要知道的第一個基本概念是純函數(shù),但純函數(shù)又是什么鬼?

咱們怎么知道一個函數(shù)是否是純函數(shù)?這里有一個非常嚴格的定義:

  • 1.此函數(shù)在相同的輸入值時,總是產(chǎn)生相同的輸出。(引用透明)
  • 2.它不會引起任何副作用。(不可變性)

首先來看第一點:函數(shù)的返回結(jié)果只依賴于它的參數(shù)

let xs = [1,2,3,4,5]
// 純函數(shù)
xs.slice(0,3) //[1,2,3]
xs.slice(0,3) //[1,2,3]
xs.slice(0,3) //[1,2,3]
// 不純函數(shù)
xs.splice(0,3) //[1,2,3]
xs.splice(0,3) //[4,5]
xs.splice(0,3) //[]

將一個函數(shù)反復執(zhí)行,其中的值已經(jīng)被改變了,從而影響后面的函數(shù)操作。一個純函數(shù)是無論什么輸入,都只對應輸出一個唯一值。

這就是純函數(shù)的第一個條件: 函數(shù)的返回結(jié)果只依賴于它的參數(shù)

接下來解釋第2點:函數(shù)執(zhí)行中沒有副作用

副作用指的是: 在計算結(jié)果的過程中,系統(tǒng)狀態(tài)的一種變化, 或者與外部事件進行觀察的交互。(通俗的說就是改變函數(shù)外面的變量或者計算依賴外部的變量) 再看一個例子:

var min = 21;

// 不純函數(shù)
var ckeck = function(age){
    return age >= min;
}

在上面的代碼中, 由于我們定義的變量在我們的函數(shù)作用域之外,導致這個函數(shù)稱為“不純”函數(shù)

// 純函數(shù)
var check = function(age){
    var min = 21;
    return age >= min;
}

上面的代碼,我們只計算了作用域內(nèi)的局部變量, 沒有任何作用域外部的變量被改變, 因此這個函數(shù)是 “純函數(shù)”

除了修改外部的變量, 一個函數(shù)在執(zhí)行過程中還有很多方式產(chǎn)生外部可觀察的變化,比如說調(diào)用 DOM API修改頁面, 或者你發(fā)送了Ajax請求, 還有調(diào)用window.reload刷新瀏覽器, 甚至是console.log往控制臺打印數(shù)據(jù)也是副作用。

3.2 什么是函數(shù)副作用?

函數(shù)副作用是指當調(diào)用函數(shù)時,除了返回函數(shù)值之外,還對主調(diào)用函數(shù)產(chǎn)生附加的影響。副作用的函數(shù)不僅僅只是返回了一個值,而且還做了其他的事情,比如:

  • 1、修改了一個變量
  • 2、直接修改數(shù)據(jù)結(jié)構(gòu)
  • 3、設置一個對象的成員
  • 4、拋出一個異?;蛞砸粋€錯誤終止
  • 5、打印到終端或讀取用戶輸入
  • 6、讀取或?qū)懭胍粋€文件
  • 7、在屏幕上畫圖

函數(shù)副作用會給程序設計帶來不必要的麻煩,給程序帶來十分難以查找的錯誤,并且降低程序的可讀性,嚴格的函數(shù)式語言要求函數(shù)必須無副作用。

3.3 使用純函數(shù)的優(yōu)點

為什么要煞費苦心地構(gòu)建純函數(shù)?因為純函數(shù)非?!翱孔V”,執(zhí)行一個純函數(shù)你不用擔心它會干什么壞事,它不會產(chǎn)生不可預料的行為,也不會對外部產(chǎn)生影響。不管何時何地,你給它什么它就會乖乖地吐出什么。如果你的應用程序大多數(shù)函數(shù)都是由純函數(shù)組成,那么你的程序測試、調(diào)試起來會非常方便。

3.3.1 ?? 可測試性

綜上所述,這個就很簡單了,我們不需要關心其它外部的信息,只需要給函數(shù)特定的輸入,再斷言其輸出就好了
一個簡單的例子是接收一組數(shù)字,并對每個數(shù)進行加 1 這種沙雕的操作。

let list = [1, 2, 3, 4, 5];
const incrementNumbers = (list) => list.map(number => number + 1);

接收numbers數(shù)組,使用map遞增每個數(shù)字,并返回一個新的遞增數(shù)字列表。

incrementNumbers(list); // [2, 3, 4, 5, 6]

對于輸入[1,2,3,4,5],預期輸出是[2,3,4,5,6]。

3.3.2 ?? 可緩存性

純函數(shù)可以根據(jù)輸入來做緩存

// 下面的代碼我們可以發(fā)現(xiàn)相同的輸入,再第二次調(diào)用的時候都是直接取的緩存
let squareNumber  = memoize((x) => { return x*x; });
squareNumber(4);
//=> 16
squareNumber(4); // 從緩存中讀取輸入值為 4 的結(jié)果
//=> 16
squareNumber(5);
//=> 25
squareNumber(5); // 從緩存中讀取輸入值為 5 的結(jié)果
//=> 25

怎么實現(xiàn)呢? 我們接著看下面的代碼

const memoize = (f) => {
  // 由于使用了閉包,所以函數(shù)執(zhí)行完后cache不會立刻被回收
  const cache = {};
  return () => {
    var arg_str = JSON.stringify(arguments);
    // 關鍵就在這里,我們利用純函數(shù)相同輸入相同輸出的邏輯,在這里利用cache做一個簡單的緩存,當這個參數(shù)之前使用過時,我們立即返回結(jié)果就行
    cache[arg_str] = cache[arg_str] || f.apply(f, arguments);
    return cache[arg_str];
  };
};

3.3.3 ?? 可移植性

由于純函數(shù)是自給自足的,它需要的東西都在輸入?yún)?shù)中已經(jīng)聲明,所以它可以任意移植到任何地方。

并且純函數(shù)對于自己的依賴是 誠實的,這一點你看它的 形參 就知道啦~正所謂 形參起的好,注釋不用搞(雙押!)純函數(shù)就是這么個正直的小可愛

// 不純的
var signUp = function(attrs){
    // 一些副作用操作
    var user = saveUser(attrs);
    welcomeUser(user);
}
// 純的
// 相同的輸入總是返回一個函數(shù)
var signUp = function(Db, Email, attrs){
    return function(){
        //一些副作用操作
        var user = saveUser(Db, attrs);
        welcomeUser(Email, user);
    }
}
3.4 純函數(shù)的特點
3.4.1?? 不可變性

不可變性是純函數(shù)的一個特點: 當數(shù)據(jù)為不可變數(shù)據(jù)(純函數(shù)希望數(shù)據(jù)為不可變數(shù)據(jù))時,他的狀態(tài)在純函數(shù)創(chuàng)建之后不能更改。

作為前端開發(fā)者,你會感受到JS中對象(Object)這個概念的強大。我們說“JS中一切皆對象”。最核心的特性,例如從String,到數(shù)組,再到瀏覽器的APIs,“對象”這個概念無處不在。

JS中的對象是那么美妙:我們可以隨意復制他們,改變并刪除他們的某項屬性等。但是要記住一句話:

“伴隨著特權(quán),隨之而來的是更大的責任?!?br> (With great power comes great responsibility)

這樣所以修改對象,隨之而來的就是副作用。這與函數(shù)式編程的思想是相違背的,所以函數(shù)式編程提出了不可變數(shù)據(jù)的概念。

不可變數(shù)據(jù)是指那些創(chuàng)建后不能更改的數(shù)據(jù)。與許多其他語言一樣,JavaScript 里有一些基本類型(String,Number 等)從本質(zhì)上是不可變的,但是對象就是在任意的地方可變,比如:

let arr = [1, 2, 3, 4, 5];
arr.splice(1, 1); // 返回[2];
console.log(arr); // [1, 3, 4, 5];

這是我們常用的“刪除數(shù)組某一項”的操作。好吧,他一點問題也沒有。

問題其實出現(xiàn)在“濫用”可變性上,這樣會給你的程序帶來“副作用”。先不必關心什么是“副作用”,他又是一個函數(shù)式編程的概念。

我們先來看一下代碼實例:

const student1 = {
    school: 'Baidu',
    name: 'HOU Ce',
    birthdate: '1995-12-15',
}

const changeStudent = (student, newName, newBday) => {
    const newStudent = student;
    newStudent.name = newName;
    newStudent.birthdate = newBday;
    return newStudent;
}

const student2 = changeStudent(student1, 'YAN Haijing', '1990-11-10');

// both students will have the name properties
console.log(student1, student2);
// Object {school: "Baidu", name: "YAN Haijing", birthdate: "1990-11-10"} 
// Object {school: "Baidu", name: "YAN Haijing", birthdate: "1990-11-10"}

我們發(fā)現(xiàn),盡管創(chuàng)建了一個新的對象student2,但是老的對象student1也被改動了。這是因為JS對象中的賦值是“引用賦值”,即在賦值過程中,傳遞的是在內(nèi)存中的引用(memory reference)。具體說就是“棧存儲”和“堆存儲”的問題。

不可變數(shù)據(jù)的強大和實現(xiàn)

我們說的“不可變”,其實是指保持一個對象狀態(tài)不變。這樣做的好處是使得開發(fā)更加簡單,可回溯,測試友好,減少了任何可能的副作用。

那么我們避免副作用,創(chuàng)建不可變數(shù)據(jù)的主要實現(xiàn)思路就是:一次更新過程中,不應該改變原有對象,只需要新創(chuàng)建一個對象用來承載新的數(shù)據(jù)狀態(tài)。

我們使用純函數(shù)(pure functions)來實現(xiàn)不可變性。純函數(shù)指無副作用的函數(shù)。
那么,具體怎么構(gòu)造一個純函數(shù)呢?我們可以看一下代碼實現(xiàn),我對上例進行改造:

const student1 = {
    school: "Baidu", 
    name: 'HOU Ce',
    birthdate: '1995-12-15',
}

const changeStudent = (student, newName, newBday) => {
    return {
        ...student, // 使用解構(gòu)
        name: newName, // 覆蓋name屬性
        birthdate: newBday // 覆蓋birthdate屬性
    }
}

const student2 = changeStudent(student1, 'YAN Haijing', '1990-11-10');

// both students will have the name properties
console.log(student1, student2);
// Object {school: "Baidu", name: "HOU Ce", birthdate: "1995-12-15"} 
// Object {school: "Baidu", name: "YAN Haijing", birthdate: "1990-11-10"}

需要注意的是,我使用了ES6中的解構(gòu)(destructuring)賦值。
這樣,我們達到了想要的效果:根據(jù)參數(shù),產(chǎn)生了一個新對象,并正確賦值,最重要的就是并沒有改變原對象。也可以使用Object.assign來實現(xiàn)。

同樣,如果是處理數(shù)組相關的內(nèi)容,我們可以使用:.map, .filter或者.reduce去達成目標。這些APIs的共同特點就是不會改變原數(shù)組,而是產(chǎn)生并返回一個新數(shù)組。這和純函數(shù)的思想不謀而合。

但是,再說回來,使用Object.assign請務必注意以下幾點:
1)他的復制,是將所有可枚舉屬性,復制到目標對象。換句話說,不可枚舉屬性是無法完成復制的。
2)對象中如果包含undefined和null類型內(nèi)容,會報錯。
3)最重要的一點:Object.assign方法實行的是淺拷貝,而不是深拷貝。
第三點很重要,也就是說,如果源對象某個屬性的值是對象,那么目標對象拷貝得到的是這個屬性對象的引用。這也就意味著,當對象存在嵌套時,還是有問題的。比如下面代碼:

const student1 = {
    school: "Baidu", 
    name: 'HOU Ce',
    birthdate: '1995-12-15',
    friends: {
        friend1: 'ZHAO Wenlin',
        friend2: 'CHENG Wen'
    }
}

const changeStudent = (student, newName, newBday, friends) => Object.assign({}, student, {name: newName, birthdate: newBday})

const student2 = changeStudent(student1, 'YAN Haijing', '1990-11-10');

// both students will have the name properties
console.log(student1, student2); 
// Object {school: "Baidu", name: "HOU Ce", birthdate: "1995-12-15", friends: Object}
// Object {school: "Baidu", name: "YAN Haijing", birthdate: "1990-11-10", friends: Object}

student2.friends.friend1 = 'MA xiao';
console.log(student1.friends.friend1); // "MA xiao"

對student2 friends列表當中的friend1的修改,同時也影響了student1 friends列表當中的friend1。這個時候就需要進行深拷貝了。上面提到的Object.assign就是典型的淺拷貝。如果遇到嵌套很深的結(jié)構(gòu),我們就需要手動遞歸。這樣做呢,又會存在性能上的問題。

比如我自己動手用遞歸實現(xiàn)一個深拷貝,需要考慮循環(huán)引用的“死環(huán)”問題,另外,當使用大規(guī)模數(shù)據(jù)結(jié)構(gòu)時,性能劣勢盡顯無疑。我們熟悉的jquery extends方法,某一版本(最新版本情況我不太了解)的實現(xiàn)是進行了三層拷貝,也沒有達到完備的deep copy。

總之,實現(xiàn)不可變數(shù)據(jù),我們必然要關心性能問題。針對于此,我推薦一款已經(jīng)“大名鼎鼎”的——immutable.js類庫來處理不可變數(shù)據(jù)。

他的實現(xiàn)既保證了不可變性,又保證了性能大限度優(yōu)化。原理很有意思,下面這段話,我摘自camsong前輩的文章

Immutable實現(xiàn)的原理是Persistent Data Structure(持久化數(shù)據(jù)結(jié)構(gòu)),也就是使用舊數(shù)據(jù)創(chuàng)建新數(shù)據(jù)時,要保證舊數(shù)據(jù)同時可用且不變。
同時為了避免deepCopy把所有節(jié)點都復制一遍帶來的性能損耗,Immutable使用了Structural Sharing(結(jié)構(gòu)共享),即如果對象樹中一個節(jié)點發(fā)生變化,只修改這個節(jié)點和受它影響的父節(jié)點,其它節(jié)點則進行共享。

感興趣的讀者可以深入研究下,這是很有意思的。如果有需要,我也愿意再寫一篇immutable.js源碼分析。

3.4.2 ??引用透明

如果一個函數(shù)對于相同的輸入始終產(chǎn)生相同的結(jié)果,那么我們就說它是引用透明。

接著實現(xiàn)一個square 函數(shù):

const square = (n) => n * n;

給定相同的輸入,這個純函數(shù)總是有相同的輸出。

square(2); // 4
square(2); // 4
square(2); // 4
// ...

將2作為square函數(shù)的參數(shù)傳遞始終會返回4。這樣咱們可以把square(2)換成4,我們的函數(shù)就是引用透明的。

基本上,如果一個函數(shù)對于相同的輸入始終產(chǎn)生相同的結(jié)果,那么它可以看作透明的。

有了這個概念,咱們可以做的一件很酷的事情就是記住這個函數(shù)。假設有這樣的函數(shù)

const sum = (a, b) => a + b;

用這些參數(shù)來調(diào)用它

sum(3, sum(5, 8));
sum(5, 8) 總等于13,所以可以做些騷操作:

sum(3, 13);

這個表達式總是得到16,咱們可以用一個數(shù)值常數(shù)替換整個表達式,并把它記下來。

3.4.3 ?? 聲明式編程

先統(tǒng)一一下概念,我們有兩種編程方式:命令式和聲明式。

我們可以像下面這樣定義它們之間的不同:

  • 命令式編程:命令“機器”如何去做事情(how),這樣不管你想要的是什么(what),它都會按照你的命令實現(xiàn)。
    聲明式編程:告訴“機器”你想要的是什么(what),讓機器想出如何去做(how)。

聲明式編程和命令式編程的代碼例子

舉個簡單的例子,假設我們想讓一個數(shù)組里的數(shù)值翻倍。

我們用命令式編程風格實現(xiàn),像下面這樣:

var numbers = [1,2,3,4,5]
var doubled = []
for(var i = 0; i < numbers.length; i++) {
  var newNumber = numbers[i] * 2
  doubled.push (newNumber)
}
console.log (doubled) //=> [2,4,6,8,10]

我們直接遍歷整個數(shù)組,取出每個元素,乘以二,然后把翻倍后的值放入新數(shù)組,每次都要操作這個雙倍數(shù)組,直到計算完所有元素。

而使用聲明式編程方法,我們可以用 Array.map 函數(shù),像下面這樣:

var numbers = [1,2,3,4,5]
var doubled = numbers.map (function (n) {
  return n * 2
})
console.log (doubled) //=> [2,4,6,8,10]

map利用當前的數(shù)組創(chuàng)建了一個新數(shù)組,新數(shù)組里的每個元素都是經(jīng)過了傳入map的函數(shù)(這里是function (n) { return n*2 })的處理。

用上面的定義方式來分析:

  • 命令式:for循環(huán)遍歷數(shù)組,關注運行原理,強調(diào)How
  • 聲明式:Array.map遍歷數(shù)組,關注輸出結(jié)果,強調(diào)What

在一些具有函數(shù)式編程特征的語言里,對于 list 數(shù)據(jù)類型的操作,還有一些其他常用的聲明式的函數(shù)方法。例如,求一個list里所有值的和,命令式編程會這樣做:

var numbers = [1,2,3,4,5]
var total = 0 for(var i = 0; i < numbers.length; i++) {
  total += numbers[i]
}
console.log (total) //=> 15

而在聲明式編程方式里,我們使用reduce函數(shù):

var numbers = [1,2,3,4,5]
var total = numbers.reduce (function (sum, n) {
  return sum + n
});
console.log (total) //=> 15

reduce函數(shù)利用傳入的函數(shù)把一個list運算成一個值。它以這個函數(shù)為參數(shù),數(shù)組里的每個元素都要經(jīng)過它的處理。每一次調(diào)用,第一個參數(shù)(這里是sum)都是這個函數(shù)處理前一個值時返回的結(jié)果,而第二個參數(shù)(n)就是當前元素。這樣下來,每此處理的新元素都會合計到sum中,最終我們得到的是整個數(shù)組的和。

同樣,reduce函數(shù)歸納抽離了我們?nèi)绾伪闅v數(shù)組和狀態(tài)管理部分的實現(xiàn),提供給我們一個通用的方式來把一個list合并成一個值。我們需要做的只是指明我們想要的是什么。


4.函數(shù)是第一等公民

第一等公民是指函數(shù)跟其它的數(shù)據(jù)類型一樣處于平等地位,可以賦值給其他變量,可以作為參數(shù)傳入另一個函數(shù),也可以作為別的函數(shù)的返回值。

// 賦值
var a = function fn1() {  }
// 函數(shù)作為參數(shù)
function fn2(fn) {
    fn()
}   
// 函數(shù)作為返回值
function fn3() {
    return function() {}
}

下面這些術語都是圍繞這一特性的應用:

4.1 閉包

閉包是js開發(fā)慣用的技巧,什么是閉包?閉包就是一個函數(shù),這個函數(shù)能夠訪問其他函數(shù)的作用域中的變量

在函數(shù)式編程的過程中。函數(shù)內(nèi)定義了局部變量并且返回可緩存的函數(shù). 變量在返回的函數(shù)內(nèi)也是可被訪問的, 此處創(chuàng)建了一個閉包

// test1 是普通函數(shù)
 function test1() {
  var a = 1;
  // test2 是內(nèi)部函數(shù)
  // 它引用了 test1 作用域中的變量 a
  // 因此它是一個閉包
  return function test2() {
    return a + =1;
  }
 }
var result = test1();
console.log(result()); // 2 
console.log(result()); // 3
console.log(result()); // 4

變量a 被一直保存在內(nèi)存中,沒有被垃圾回收機制回收掉。函數(shù)式編程中兩個最基本的運算:柯里化和合成都使用到了閉包

關于閉包的詳細解釋,請看: 徹底搞懂JS閉包各種坑


4.2 高階函數(shù)

高階函數(shù):以函數(shù)作為參數(shù)的函數(shù),結(jié)果return一個函數(shù)。
上面關于高階函數(shù)的概念其實是錯誤的.高階函數(shù)只要滿足參數(shù)或返回值為函數(shù)就可以成為高階函數(shù),而非一定要同時滿足才成立)

簡單的例子:

function add(a,b,fn){
    return fn(a)+fn(b);
}
var fn=function (a){
  return a*a;
}
add(2,3,fn); //13

為什么要使用高階函數(shù)(使用高階函數(shù)的意義)

  • 高階函數(shù)是用來抽象通用問題的
  • 抽象可以幫我們屏蔽細節(jié),只需要關注我們的目標
  • 使代碼更簡潔

舉例:比如我們現(xiàn)在需要去遍歷一個數(shù)組,按照面向過程的方式時我們需要使用一個for循環(huán),定義一個循環(huán)變量,判斷循環(huán)條件等操作;如果此時我們使用高階函數(shù)對遍歷這個步驟進行抽象,如上邊實現(xiàn)的forEach函數(shù),我們此時只需要知道forEach內(nèi)部幫我們實現(xiàn)了循環(huán),然后傳遞數(shù)據(jù)給forEach。(前邊的filter函數(shù)也是如此)

編寫一個自己的高階函數(shù)

當我們玩了很多ES6自帶的高階函數(shù)后,就可以升級到自己寫高階函數(shù)的階段了,比如說用函數(shù)式的方式寫一個節(jié)流函數(shù),
節(jié)流函數(shù)說白了,就是一個控制事件觸發(fā)頻率的函數(shù),以前可以一秒內(nèi),無限次觸發(fā),現(xiàn)在限制成500毫秒觸發(fā)一次

function throttle(fn, wait=500) {
    if (typeof fn != "function") {
        // 必須傳入函數(shù)
        throw new TypeError("Expected a function")
    }
    
    // 定時器
    let timer,
    // 是否是第一次調(diào)用
    firstTime = true;
    
    // 這里不能用箭頭函數(shù),是為了綁定上下文
    return function (...args) {
        // 第一次
        if (firstTime) {
            firstTime = false;
            fn.apply(this,args);
        }
        
        if (timer) {
            return;
        }else {
            timer = setTimeout(() => {
                clearTimeout(timer);
                timer = null;
                fn.apply(this, args);
            },wait)
        }

    }
}

// 單獨使用,限制快速連續(xù)不停的點擊,按鈕只會有規(guī)律的每500ms點擊有效
button.addEventListener('click', throttle(() => {
    console.log('hhh')
}))
常用的高階函數(shù)
  • forEach
  • map
  • filter
  • every
  • some
  • find/findIndex
  • reduce
  • sort
    ......

以及現(xiàn)在常用的redux中的connect方法也是高階函數(shù)。

拿map展示下

var pow = function square(x) {
    return x * x;
};

var array = [1, 2, 3, 4, 5, 6, 7, 8];
var newArr = array.map(pow); //直接傳入一個函數(shù)方法
console.log(newArr); //  [1, 4, 9, 16, 25, 36, 49, 64]
console.log(array);  // [1, 2, 3, 4, 5, 6, 7, 8]

map實現(xiàn)

var arr = [1,2,3];

var fn = function(item,index,arr){
  console.log(index);
  console.log(arr);
  return item*2;
};

Array.prototype.maps = function(fn) {
  let newArr = [];
  for (let index = 0; index < this.length; index++) {
    newArr.push(fn.call(this,this[index],index,this));
  }
  return newArr;
}

var result = arr.maps(fn);
4.3 函數(shù)柯里化(Currying)

函數(shù)柯里化是函數(shù)式編程中高階函數(shù)的一個重要用法

柯里化是指這樣一個函數(shù)(假設叫做createCurry),他接收函數(shù)A作為參數(shù),運行后能夠返回一個新的函數(shù)。并且這個新的函數(shù)能夠處理函數(shù)A的剩余參數(shù)。

這樣的定義不太好理解,我們可以通過下面的例子配合解釋。

有一個接收三個參數(shù)的函數(shù)A。

function A(a, b, c) {
    // do something
}

假如,我們有一個已經(jīng)封裝好了的柯里化通用函數(shù)createCurry。他接收bar作為參數(shù),能夠?qū)轉(zhuǎn)化為柯里化函數(shù),返回結(jié)果就是這個被轉(zhuǎn)化之后的函數(shù)。

var _A = createCurry(A);

那么_A作為createCurry運行的返回函數(shù),他能夠處理A的剩余參數(shù)。因此下面的運行結(jié)果都是等價的。

_A(1, 2, 3);
_A(1, 2)(3);
_A(1)(2, 3);
_A(1)(2)(3);
A(1, 2, 3);

函數(shù)A被createCurry轉(zhuǎn)化之后得到柯里化函數(shù)_A,_A能夠處理A的所有剩余參數(shù)。因此柯里化也被稱為部分求值。

在簡單的場景下,可以不用借助柯里化通用式來轉(zhuǎn)化得到柯里化函數(shù),我們憑借眼力自己封裝。

例如有一個簡單的加法函數(shù),他能夠?qū)⒆陨淼娜齻€參數(shù)加起來并返回計算結(jié)果。

function add(a, b, c) {
    return a + b + c;
}

那么add函數(shù)的柯里化函數(shù)_add則可以如下:

function _add(a) {
    return function(b) {
        return function(c) {
            return a + b + c;
        }
    }
}

下面的運算方式是等價的。

add(1, 2, 3);
_add(1)(2)(3);

當然,靠眼力封裝的柯里化函數(shù)自由度偏低,柯里化通用式具備更加強大的能力。因此我們需要知道如何去封裝這樣一個柯里化的通用式。

首先通過_add可以看出,柯里化函數(shù)的運行過程其實是一個參數(shù)的收集過程,我們將每一次傳入的參數(shù)收集起來,并在最里層里面處理。在實現(xiàn)createCurry時,可以借助這個思路來進行封裝。

封裝如下:

function add() {
    // 類對象轉(zhuǎn)換成數(shù)組
    var _args = [...arguments];
    // 在內(nèi)部聲明一個函數(shù),利用閉包的特性保存_args并收集所有的參數(shù)值
    var adder = function() {
      // [].push.apply(_args, [].slice.call(arguments));
        _args.push(...arguments);
        return adder;
    };
    // 利用隱式轉(zhuǎn)換的特性,當最后執(zhí)行時隱式轉(zhuǎn)換,并計算最終的值返回
    adder.toString = function() {
        return _args.reduce(function(a, b) {
            return a + b;
        });
    }
    return adder;
}
var a = add(1)(2)(3)(4);   // f 10
var b = add(1, 2, 3, 4);   // f 10
var c = add(1, 2)(3, 4);   // f 10
var d = add(1, 2, 3)(4);   // f 10

// 可以利用隱式轉(zhuǎn)換的特性參與計算
console.log(a + 10); // 20
console.log(b + 20); // 30
console.log(c + 30); // 40
console.log(d + 40); // 50

// 也可以繼續(xù)傳入?yún)?shù),得到的結(jié)果再次利用隱式轉(zhuǎn)換參與計算
console.log(a(10) + 100);  // 120
console.log(b(10) + 100);  // 120
console.log(c(10) + 100);  // 120
console.log(d(10) + 100);  // 120
4.4 函數(shù)合成(compose)

函數(shù)合成指的是將代表各個動作的多個函數(shù)合并成一個函數(shù)。
上面講到,函數(shù)式編程是對過程的抽象,關注的是動作。以上面計算的例子為例,我們關注的是它的動作,先「加上 4」再「乘以 4」。那么我們的代碼實現(xiàn)如下:

function add4(x) {
    return x + 4
}
function multiply4(x) {
    return x * 4
}

console.log(multiply4(add4(1)))  // 20

根據(jù)函數(shù)合成的定義,我們能夠?qū)⑸鲜龃韮蓚€動作的兩個函數(shù)的合成一個函數(shù)。我們將合成的動作抽象為一個函數(shù) compose,這里可以比較容易地知道,函數(shù) compose 的代碼如下:

function compose(f,g) {
    return function(x) {
        return f(g(x));
    };
}

所以我們可以通過如下的方式得到合成函數(shù)

var calculate=compose(multiply4,add4);  //執(zhí)行動作的順序是從右往左

console.log(calculate(1))  // 20

可以看到,只要往 compose 函數(shù)中傳入代表各個動作的函數(shù),我們便能得到最終的合成函數(shù)。但上述 compose 函數(shù)的局限性是只能夠合成兩個函數(shù),如果需要合成的函數(shù)不止兩個呢,所以我們需要一個通用的 compose 函數(shù)。
這里我直接給出通用 compose 函數(shù)的代碼:

function compose(...args) {
    return function(x) {
        var composeFun = args.reduceRight(function(funLeft, funRight) {
            console.log(funLeft);
            return funRight(funLeft)
        }, x);
        return composeFun;
    }
}

讓我們來實踐下上述通用的 compose 函數(shù)~

function addHello(str){
    return 'hello '+str;
}
function toUpperCase(str) {
    return str.toUpperCase();
}
function reverse(str){
    return str.split('').reverse().join('');
}

var composeFn=compose(reverse,toUpperCase,addHello);

console.log(composeFn('ttsy'));  // YSTT OLLEH

上述過程有三個動作,「hello」、「轉(zhuǎn)換大寫」、「反轉(zhuǎn)」,可以看到通過 compose 將上述三個動作代表的函數(shù)合并成了一個,最終輸出了正確的結(jié)果。

結(jié)合使用

嗯,到了這里,已經(jīng)初步了解了函數(shù)式編程的概念了,那么我們怎么使用函數(shù)式編程的方式寫代碼呢,舉個例子:
// 偽代碼,思路
// 比如說,我們請求后臺拿到了一個數(shù)據(jù),然后我們需要篩選幾次這個數(shù)據(jù), 取出里面的一部分,并且排序

// 數(shù)據(jù)
const res = {
    status: 200,
    data: [
        {
            id: xxx,
            name: xxx,
            time: xxx,
            content: xxx,
            created: xxx
        },
        ...
    ]
}

// 封裝的請求函數(shù)
const http = xxx;

// '傳統(tǒng)寫法是這樣的'
http.post
    .then(res => 拿到數(shù)據(jù))
    .then(res => 做出篩選)
    .then(res => 做出篩選)
    .then(res => 取出一部分)
    .then(res => 排序)
    
// '函數(shù)式編程是這樣的'
// 聲明一個篩選函數(shù)
const a = curry()
// 聲明一個取出函數(shù)
const b = curry()
// 聲明一個排序函數(shù)
const c = curry()
// 組合起來
const shout = compose(c, b, a)
// 使用
shout(http.post)

如何在項目中正式使用函數(shù)式編程
我覺得,想要在項目里面正式使用函數(shù)式編程有這樣幾個步驟:

  • 1、先嘗試使用ES6自帶的高階函數(shù)
  • 2、熟悉了ES6自帶的高階函數(shù)后,可以自己嘗試寫幾個高階函數(shù)
  • 3、在這個過程中,盡量使用純函數(shù)編寫代碼
  • 4、對函數(shù)式編程有所了解之后,嘗試使用類似ramda的庫來編寫代碼
  • 5、在使用ramda的過程中,可以嘗試研究它的源代碼
  • 6、嘗試編寫自己的庫,柯里化函數(shù),組合函數(shù)等

當然了,這個只是我自己的理解,我在實際項目中也沒有完全的使用函數(shù)式編程開發(fā),我的開發(fā)原則是:

不要為了函數(shù)式而選擇函數(shù)式編程。如果函數(shù)式編程能夠幫助你,能夠提升項目的效率,質(zhì)量,可以使用;如果不能,那么不用;如果對函數(shù)式編程還不太熟,比如我這樣的,偶爾使用

擴展

函數(shù)式編程是在范疇論的基礎上發(fā)展而來的,而關于函數(shù)式編程和范疇論的關系,阮一峰大佬給出了一個很好的說明,在這里復制粘貼下他的文章

本質(zhì)上,函數(shù)式編程只是范疇論的運算方法,跟數(shù)理邏輯、微積分、行
列式是同一類東西,都是數(shù)學方法,只是碰巧它能用來寫程序

所以,你明白了嗎,為什么函數(shù)式編程要求函數(shù)必須是純的,不能有副作用?因為它是一種數(shù)學運算,原始目的就是求值,不做其他事情,否則就無法滿足函數(shù)運算法則了。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

友情鏈接更多精彩內(nèi)容