JavaScript ES6函數式編程(一):閉包與高階函數

函數式編程的歷史

函數的第一原則是要小,第二原則則是要更小 —— ROBERT C. MARTINhtml

解釋一下上面那句話,就是咱們常說的一個函數只作一件事,好比:將字符串首字母和尾字母都改爲大寫,咱們此時應該編寫兩個函數。爲何呢?爲了更好的複用,這樣作保證了函數更加的顆粒化。前端

早在 1950 年代,隨着 Lisp 語言的建立,函數式編程( Functional Programming,簡稱 FP)就已經開始出如今你們視野。而直到近些年,函數式以其優雅,簡單的特色開始從新風靡整個編程界,主流語言在設計的時候無一例外都會更多的參考函數式特性( Lambda 表達式,原生支持 map ,reduce ……),Java8 開始支持函數式編程。ajax

而在前端領域,咱們一樣能看到不少函數式編程的影子:Lodash.js、Ramda.js庫的普遍使用,ES6 中加入了箭頭函數,Redux 引入 Elm 思路下降 Flux 的複雜性,React16.6 開始推出 React.memo(),使得 pure functional components 成爲可能,16.8 開始主推 Hooks,建議使用 pure functions 進行組件編寫……編程

這些無一例外的說明,函數式編程這種古老的編程範式並無隨着歲月而褪去其光彩,反而越發生機勃勃。api

什麼是函數式編程

上面咱們瞭解了函數式編程的歷史,肯定它是個很棒的東西。接下來,咱們要去了解一下什麼是函數式編程?數組

其實函數咱們從小就學,什麼一元函數(f(x) = 3x),二元函數……根據學術上函數的定義,函數便是一種描述集合和集合之間的轉換關係,輸入經過函數都會返回有且只有一個輸出值。瀏覽器

因此,函數其實是一個關係,或者說是一種映射,而這種映射關係是能夠組合的,一旦咱們知道一個函數的輸出類型能夠匹配另外一個函數的輸入,那他們就能夠進行組合。緩存

在編程的世界裏,咱們須要處理其實也只有「數據」和「關係」,而「關係」就是函數,「數據」就是要傳入的實參。咱們所謂的編程工做也不過就是在找一種映射關係,好比:將字符串首字母轉爲大寫。一旦關係找到了,問題就解決了,剩下的事情,就是讓數據流過這種關係,而後轉換成另外一個數據返回給咱們。閉包

想象一個流水線車間的工做過程,把輸入當作原料,把輸出當作產品,數據能夠不斷的從一個函數的輸出能夠流入另外一個函數輸入,最後再輸出結果,這不就是一套流水線嘛?app

因此,如今你明確了函數式編程是什麼了吧?它其實就是強調在編程過程當中把更多的關注點放在如何去構建關係。經過構建一條高效的建流水線,一次解決全部問題。而不是把精力分散在不一樣的加工廠中來回奔波傳遞數據。

參考連接:阮一峯 - 函數式編程入門教程

函數式編程的特色

  • 函數是一等公民

根據維基百科,編程語言中一等公民的概念是由英國計算機學家Christopher Strachey提出來的,時間則早在上個世紀60年代,那個時候尚未我的電腦,沒有互聯網,沒有瀏覽器,也沒有JavaScript。而且當時也沒給出清晰的定義。

關於一等公民,我找到一個權威的定義,來自於一本書《Programming Language Pragmatics》,這本書是不少大學的程序語言設計的教材。

In general, a value in a programming language is said to have first-class status if it can be passed as a parameter, returned from a subroutine, or assigned into a variable.

也就是說,在編程語言中,一等公民能夠做爲函數參數,能夠做爲函數返回值,也能夠賦值給變量。

例如,字符串在幾乎全部編程語言中都是一等公民,字符串能夠作爲函數參數,字符串能夠做爲函數返回值,字符串也能夠賦值給變量。

對於各類編程語言來講,函數就不必定是一等公民了,好比Java 8以前的版本。

對於JavaScript來講,函數能夠賦值給變量,也能夠做爲函數參數,還能夠做爲函數返回值,所以JavaScript中函數是一等公民。

  • 聲明式編程 (Declarative Programming)

經過上面的例子能夠看出來,函數式編程大多時候都是在聲明我須要作什麼,而非怎麼去作。這種編程風格稱爲**聲明式編程 **。

// 好比:咱們要打印數組中的每一個元素
// 1. 命令式編程
let arr = [1, 2, 3];
for (let i = 0, len = arr.length; i < len; i++) {
  console.log(arr[i])
}

// 2. 聲明式編程
let arr = [1, 2, 3];
arr.forEach(item => {
  console.log(item)
})

/*
* 相對於命令式編程的 for 循環拿到每一個元素,聲明式編程不須要本身去找每一個元素
* 由於 forEach 已經幫咱們拿到了,就是 item,直接打印出來就行
*/

這樣有個好處是代碼的可讀性特別高,由於聲明式代碼大多都是接近天然語言的,同時,它解放了大量的人力,由於它不關心具體的實現,所以它能夠把優化能力交給具體的實現,這也方便咱們進行分工協做。

  • 惰性執行(Lazy Evaluation)

所謂惰性執行指的是函數只在須要的時候執行,即不產生無心義的中間變量。

  • 無狀態和數據不可變 (Statelessness and Immutable data)

這是函數式編程的核心概念:

數據不可變:它要求你全部的數據都是不可變的,這意味着若是你想修改一個對象,那你應該建立一個新的對象用來修改,而不是修改已有的對象。
**無狀態: **主要是強調對於一個函數,無論你什麼時候運行,它都應該像第一次運行同樣,給定相同的輸入,給出相同的輸出,徹底不依賴外部狀態的變化。

  • 沒有反作用(side effect)

反作用,通常指完成份內的事情以後還帶來了很差的影響。在函數中,最多見的反作用就是隨意修改外部變量。因爲js對象傳遞的是引用地址,這很容易帶來bug。

例如: map 函數的原本功能是將輸入的數組根據一個函數轉換,生成一個新的數組。而在 JS 中,咱們常常能夠看到下面這種對 map 的 「錯誤」 用法,把 map 看成一個循環語句,而後去直接修改數組中的值。

const list = [...];
// 修改 list 中的 type 和 age
list.map(item => {
  item.type = 1;
  item.age++;
})

傳遞引用一時爽,代碼重構火葬場

這樣函數最主要的輸出功能沒有了,變成了直接修改了外部變量,這就是它的反作用。而沒有反作用的寫法應該是:

const list = [...];
// 修改 list 中的 type 和 age
const newList = list.map(item => ({...item, type: 1, age:item.age + 1}));

保證函數沒有反作用,一來能保證數據的不可變性,二來能避免不少由於共享狀態帶來的問題。當你一我的維護代碼時候可能還不明顯,但隨着項目的迭代,項目參與人數增長,你們對同一變量的依賴和引用愈來愈多,這種問題會愈來愈嚴重。最終可能連維護者本身都不清楚變量究竟是在哪裏被改變而產生 Bug。

  • 純函數 (pure functions)

函數式編程最關注的對象就是純函數,純函數的概念有兩點:

不依賴外部狀態(無狀態): 函數的的運行結果不依賴全局變量,this 指針,IO 操做等。
沒有反作用(數據不變): 不修改全局變量,不修改入參。

因此純函數纔是真正意義上的 「函數」, 它也遵循引用透明性——相同的輸入,永遠會獲得相同的輸出

咱們這麼強調使用純函數,純函數的意義是什麼?

便於測試和優化:這個意義在實際項目開發中意義很是大,因爲純函數對於相同的輸入永遠會返回相同的結果,所以咱們能夠輕鬆斷言函數的執行結果,同時也能夠保證函數的優化不會影響其餘代碼的執行。這十分符合測試驅動開發 TDD(Test-Driven Development ) 的思想,這樣產生的代碼每每健壯性更強。

可緩存性:由於相同的輸入老是能夠返回相同的輸出,所以,咱們能夠提早緩存函數的執行結果,有不少庫有所謂的 memoize 函數,下面以一個簡化版的 memoize 爲例,這個函數就能緩存函數的結果,對於像 fibonacci 這種計算,就能夠起到很好的緩存效果。

function memoize(fn) {
    const cache = {};
    return function() {
      const key = JSON.stringify(arguments);
      var value = cache[key];
      if(!value) {
        value = [fn.apply(null, arguments)];  // 放在一個數組中,方便應對 undefined,null 等異常狀況
        cache[key] = value; 
      }
      return value[0];
    }
  }

  const fibonacci = memoize(n => n < 2 ? n: fibonacci(n - 1) + fibonacci(n - 2));
  console.log(fibonacci(4))  // 執行後緩存了 fibonacci(2), fibonacci(3),  fibonacci(4)
  console.log(fibonacci(10)) // fibonacci(2), fibonacci(3),  fibonacci(4) 的結果直接從緩存中取出,同時緩存其餘的

閉包

定義:一個可以讀取其餘函數內部變量的函數,實質是變量的解析過程(由內而外)

閉包是ES中一個離不開的話題,並且也是是一個難懂又必須搞明白的概念!提及閉包,就不得不提與它密切相關的變量做用域和變量的生命週期。下面來看下:

變量做用域

變量做用域分爲兩類:全局做用域和局部做用域。

  • 編寫在script標籤中的變量或者沒用var關鍵字聲明的變量,就表明全局變量,在頁面的任意位置均可以訪問到
  • 在函數中聲明變量帶有var關鍵字的便是局部變量,局部變量只能在函數內才能訪問到
function fn() {
    var a = 1;     // a爲局部變量
    console.log(a);  // 1
}
fn();
console.log(a);     // a is not defined  外部訪問不到內部的變量

上面代碼展現了在函數中聲明的局部變量a在函數外部拿不到。但是咱們就想要在函數外拿到它,怎麼辦?下面就要看發揮閉包的威力了。

函數能夠創造函數做用域,在函數做用域中若是要查找一個變量的時候,若是在該函數內沒有聲明這個變量,就會向該函數的外層繼續查找,一直查到全局變量爲止。

因此變量的查找是由內而外的,這也造成了所謂的做用域鏈。

var a = 7;
function outer() {
    var b = 8;
    function inner() {
        var c = 9;
        alert(b);
        alert(a);
    }
    inner();
    alert(c);   // c is not defined
}
outer();    // 調用函數

仍是最開始的函數,利用做用域鏈,咱們試着去拿到a,改造一下fn函數:

function fn() {
    var a = 1;     // a爲局部變量
    return function() {
        console.log(a);
    }
}
var fn2 = fn();
fn2();      // 1

理解了變量做用域,順着這條做用域鏈,再來回顧一下閉包的定義:**閉包就是可以讀取其餘函數內部變量的函數,實質是變量的解析過程(由內而外) **

變量生命週期

理解了變量做用域,再來看看變量的生命週期,直白一點就是它能在程序中存活多久。

  • 對於全局變量而言,它的生命週期機就是永久的,除非咱們手動銷燬它(這一點也是頗有必要的,防止內存溢出)
  • 對於在函數中經過var聲明的變量而言,就沒那麼幸運了。當函數執行完畢後,它也就沒什麼利用價值了,隨之被瀏覽器的垃圾處理機制當垃圾處理掉了
    好比下面這段代碼:
var forever = 'i am forever exist'  // 全局變量,永生
function fn() {
    var a = 123;    // fn執行完畢後,變量a就將被銷燬了
    console.log(a);
}
fn();

函數執行完畢,內部的變量a就被無情的銷燬了。那麼咱們有沒有辦法拯救這個變量呢?答案是確定的,救星來了——閉包

閉包的建立

function outFn() {
    var i = 1;
    function inFn () {
        return ++i
    }
    return inFn;
}
var fn = outFn(); // 此處建立了一個閉包
fn();   // 2
fn();   // 3
fn();   // 4

上面的代碼建立了一個閉包,有兩個特色:

  1. 函數inFn嵌套在函數outFn內部
  2. 函數outFn返回內部函數inFn

在執行完var fn = outFn();後,變量 fn 其實是指向了函數 inFn,再執行 fn( ) 後就會返回 i 的值(第一次爲1)。這段代碼其實就建立了一個閉包,這是由於函數 outFn 外的變量 fn 引用了函數 outFn 內的函數inFn。也就是說,當函數 outFn 的內部函數 inFn 被函數 outFn 外的一個變量 fn 引用的時候,就建立了一個閉包(函數內部的變量 i 被保存到內存中,不會被當即銷燬)。

參考連接:
閉包的建立
閉包和內存

高階函數

定義:高階函數就是接受函數做爲參數或者返回函數做爲輸出的函數。

下面分兩種狀況講解,搞清這兩種應用場景,這將有助於理解並運用高階函數。

函數做爲參數傳入

函數做爲參數傳入最多見的就是回調函數。例如:在 ajax 異步請求的過程當中,回調函數使用的很是頻繁。由於異步執行不能肯定請求返回的時間,將callback回調函數當成參數傳入,待請求完成後執行 callback 函數。

$.ajax({
  url: 'http://musicapi.leanapp.cn/search',  // 以網易雲音樂爲例
  data: {
      keywords
  },
  success: function (res) {
      callback && callback(res.result.songs);
  }
})

函數做爲返回值輸出

函數做爲返回值輸出的應用場景那就太多了,這也體現了函數式編程的思想。其實從閉包的例子中咱們就已經看到了關於高階函數的相關內容了。

還記得在咱們去判斷數據類型的時候,咱們都是經過Object.prototype.toString來計算的,每一個數據類型之間只是'[object XXX]'不同而已。

下面咱們封裝一個高階函數,實現對不一樣類型變量的判斷:

function isType (type) {
    return function (obj) {
        return Object.prototype.toString.call(obj) === `[object ${type}]
    }
}

const isArray = isType('Array'); // 判斷數組類型的函數
const isString = isType('String'); // 判斷字符串類型的函數
console.log(isArray([1, 2]); // true
console.log(isString({});  // false

參考連接:
高階函數,你怎麼那麼漂亮呢!
簡明 JavaScript 函數式編程——入門篇

總結

最後總結一下此次的重點:純函數、變量做用域、閉包、高階函數。

  1. 純函數的定義:給定的輸入返回相同的輸出的函數。
  2. 變量做用域是閉包的實質。根據變量做用域向上查找的特性,閉包能夠緩存變量到內存中,函數執行完畢不會當即銷燬。
  3. 高階函數的核心是閉包,利用閉包緩存一些將來會用到的變量,能夠實現柯里化、偏應用...

下一節介紹柯里化、偏應用、組合、管道...

相關文章
相關標籤/搜索