JavaScript ES6函数式编程(一):闭包与高阶函数
函數式編程的歷史
函數的第一原則是要小,第二原則則是要更小 —— ROBERT C. MARTIN
解釋一下上面那句話,就是我們常說的一個函數只做一件事,比如:將字符串首字母和尾字母都改成大寫,我們此時應該編寫兩個函數。為什么呢?為了更好的復用,這樣做保證了函數更加的顆粒化。
早在 1950 年代,隨著 Lisp 語言的創建,函數式編程( Functional Programming,簡稱 FP)就已經開始出現在大家視野。而直到近些年,函數式以其優雅,簡單的特點開始重新風靡整個編程界,主流語言在設計的時候無一例外都會更多的參考函數式特性( Lambda 表達式,原生支持 map ,reduce ……),Java8 開始支持函數式編程。
而在前端領域,我們同樣能看到很多函數式編程的影子:Lodash.js、Ramda.js庫的廣泛使用,ES6 中加入了箭頭函數,Redux 引入 Elm 思路降低 Flux 的復雜性,React16.6 開始推出 React.memo(),使得 pure functional components 成為可能,16.8 開始主推 Hooks,建議使用 pure functions 進行組件編寫……
這些無一例外的說明,函數式編程這種古老的編程范式并沒有隨著歲月而褪去其光彩,反而愈加生機勃勃。
什么是函數式編程
上面我們了解了函數式編程的歷史,確定它是個很棒的東西。接下來,我們要去了解一下什么是函數式編程?
其實函數我們從小就學,什么一元函數(f(x) = 3x),二元函數……根據學術上函數的定義,函數即是一種描述集合和集合之間的轉換關系,輸入通過函數都會返回有且只有一個輸出值。
所以,函數實際上是一個關系,或者說是一種映射,而這種映射關系是可以組合的,一旦我們知道一個函數的輸出類型可以匹配另一個函數的輸入,那他們就可以進行組合。
在編程的世界里,我們需要處理其實也只有“數據”和“關系”,而“關系”就是函數,“數據”就是要傳入的實參。我們所謂的編程工作也不過就是在找一種映射關系,比如:將字符串首字母轉為大寫。一旦關系找到了,問題就解決了,剩下的事情,就是讓數據流過這種關系,然后轉換成另一個數據返回給我們。
想象一個流水線車間的工作過程,把輸入當做原料,把輸出當做產品,數據可以不斷的從一個函數的輸出可以流入另一個函數輸入,最后再輸出結果,這不就是一套流水線嘛?
所以,現在你明確了函數式編程是什么了吧?它其實就是強調在編程過程中把更多的關注點放在如何去構建關系。通過構建一條高效的建流水線,一次解決所有問題。而不是把精力分散在不同的加工廠中來回奔波傳遞數據。
參考鏈接:阮一峰 - 函數式編程入門教程
函數式編程的特點
函數是一等公民
根據維基百科,編程語言中一等公民的概念是由英國計算機學家Christopher Strachey提出來的,時間則早在上個世紀60年代,那個時候還沒有個人電腦,沒有互聯網,沒有瀏覽器,也沒有JavaScript。并且當時也沒給出清晰的定義。
關于一等公民,我找到一個權威的定義,來自于一本書《Programming Language Pragmatics》,這本書是很多大學的程序語言設計的教材。
In general, a value in a programming language is said to have ?rst-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
上面的代碼創建了一個閉包,有兩個特點:
函數inFn嵌套在函數outFn內部
函數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 函數式編程——入門篇
總結
最后總結一下這次的重點:純函數、變量作用域、閉包、高階函數。
純函數的定義:給定的輸入返回相同的輸出的函數。
變量作用域是閉包的實質。根據變量作用域向上查找的特性,閉包可以緩存變量到內存中,函數執行完畢不會立即銷毀。
高階函數的核心是閉包,利用閉包緩存一些未來會用到的變量,可以實現柯里化、偏應用...
下一節介紹柯里化、偏應用、組合、管道...
總結
以上是生活随笔為你收集整理的JavaScript ES6函数式编程(一):闭包与高阶函数的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 新手求大神,有其他swit-case的思
- 下一篇: 在Photoshop中将图片保存为web