背景
函數式編程可以説是非常古老的編程方式,但是近幾年變成了一個非常熱門的話題。不管是Google力推的Go、學術派的Scala與Haskell,還是Lisp的新語言Clojure,這些新的函數式編程語言越來越受到人們的關注。函數式編程思想對前端的影響很大,Angular、React、Vue等熱門框架一直在不斷通過該思想來解決問題。
函數式編程作為一種高階編程範式,更接近於數學和代數的一種編程範式,與面向對象的開發理念和思維模式截然不同,深入理解這種差異性,是程序員進階的必經之路。
編程範式
編程範式(Programming Paradigm)是編程語言領域的模式風格,體現了開發者設計編程語言時的考量,也影響着程序員使用相應語言進行編程設計的風格。大體分為兩大類,具體內容如下圖所示:
函數式概念與思維
函數式編程(Functional Programming)是基於λ演算(Lambda Calculus)的一種語言模式,它的實現基於λ演算和更具體的α-等價、β-歸約等設定 。這是一個較官方的解釋,大家不要被這種概念嚇到,很有可能你已經在日常開發中使用了大量的函數式編程概念和工具。如越來越函數式的ES6,新的規範有非常多的新特性,其中不少借鑑其他函數式語言的特性,給JavaScript語言添加了不少函數式的新特性。箭頭函數就是ES6發佈的一個新特性,箭頭函數也被叫做肥箭頭(Fat Arrow),大致是借鑑自CoffeeScript或者Scala語言。箭頭函數是提供詞法作用域的匿名函數。
函數式編程思維的目標:程序執行時,應該把程序對結果以外的數據的影響控制到最小。
函數式編程的特點
- 聲明式(Declarative)
- 純函數(Pure Function)
- 函數的執行過程完全由輸入參數決定,不會受除參數之外的任何數據影響。
- 函數不會修改任何外部狀態,比如修改全局變量或傳入的參數對象。
-
數據不可變性(Immutability)
當我們需要數據狀態發生改變時,保持原有數據不變,產生一個新的數據來體現這種變化。不可改變的數據就是Immutable數據,一旦產生,可以肯定它的值永遠不會變,這非常有利於代碼的理解。
下面用一段對比代碼解釋命令式編程與函數式編程:
// 計算傳入數據乘以2
// 命令式編程
function double(arr) {
const results = []
for (let i = 0; i < arr.length; i++){
results.push(arr[i] * 2)
}
return results
}
console.log(double([1, 2, 3]));// [2, 4, 6]
// 函數式編程
function double(arr) {
return arr.map(item => item * 2);
}
const oneArray = [1, 2, 3];
const anotherArray = double(oneArray);
console.log(oneArray); // [1, 2, 3]
console.log(anotherArray);// [2, 4, 6]
函數是一等公民
數字在JavaScript裏就是一等公民,同樣作為一等公民的函數就會擁有類似數字的性質。
- 函數與數字一樣可以存儲為變量
let one = function() { return 1 };
- 函數與數字一樣可以存儲為數組的一個元素
let ones = [1, function() { return 1 }];
- 函數與數字一樣可以被傳遞給另一個函數
function numAdd(n, f) { return n + f()};
numAdd(1, function() { return 1}); // 2
- 函數與數字一樣可以被另一個函數返回
return 1;
return function() { return 1 };
最後兩點其實就是“高階”函數的定義;一個高階函數應該可以至少執行一項,以一個函數作為參數或者返回一個函數作為結果。
高階函數(High Order Function)
高階函數,通俗來説,就是以其他函數為參數的函數,返回其他函數的函數。我們稱函數的嵌套高階調用為高階函數,高階函數可以説是編程語言便捷踐行函數式的基礎。比如在React中我們會遇到的高階組件HOC。
以數字添加千分位符號為demo的代碼如下:
const addThousandSeprator = (strOrNum) => {
return parseFloat(strOrNum).toString().split('.').map((x,idx) => {
if(!idx) {
return x.split('')
.reverse()
.map((xx,idxx) => (idxx && !(idxx % 3)) ? (xx + ',') : xx )
.reverse()
.join('')
} else {
return x;
}
}).join('.')
}
高階函數應用之柯里化(Currying)
柯里化函數為每一個邏輯參數返回一個新的函數,會逐漸返回已配置的函數,直到所有的參數用完。
function curry(fun) {
return function(arg) {
return fun(arg)
}
}
const arr = ['1', '2', '3', '4'].map(curry(parseInt));
console.log(arr) // [ 1, 2, 3, 4 ]
使用柯里化比較容易產生流利的函數式API。在Haskell編程語言中,函數式默認柯里化。但在JavaScript中,函數式API的設計必須利用柯里化,而且必須文檔化。
遞歸
程序調用自身的編程技巧稱為遞歸( recursion)。遞歸作為一種算法在程序設計語言中廣泛應用。 遞歸是一種解決過程堆疊的方法,在運行時承擔了更多的工作。遞歸的能力在於用有限的語句來定義對象的無限集合。一般來説,遞歸需要有邊界條件、遞歸前進段和遞歸返回段。當邊界條件不滿足時,遞歸前進;當邊界條件滿足時,遞歸返回。
説起遞歸,不得不談起尾遞歸。早期的瀏覽器引擎是不支持尾遞歸,所以當我們計算經典的斐波那契數列或進行其他遞歸操作時,可能會觸發堆棧調用超限的提醒。如果每次遞歸尾部返回的內容都是一個待計算的表達式,那麼運行時的內存棧中會一直壓入等待計算的變量和環境,這就是產生超限的根本原因。而如果我們使用新的遞歸方法,若運行環境支持優化,則立即釋放被替換的函數負載。
// 遞歸:將外層調用保存在內存堆棧中
const factorialFn = (n) => {
if (n <= 1) {
return 1;
} else {
return n + factorialFn(n - 1);
}
}
console.log('factorialFn: ', factorialFn(30))
// 返回函數調用;尾遞歸優化
const factorialFun = (n, acc) => {
if(n <= 1) {
return acc;
} else {
return factorialFun(n - 1, n + acc)
}
}
console.log('factorialFun: ', factorialFun(30, 1))
運行結果如下:
基於流的編程
在前端領域中,「流」的經典代表之一「RxJS」。
在Rx官網https://reactivex.io/ 上,有一段介紹文字:
An API for asynchronous programming with observable streams.
翻譯過來就是:Rx是一套通過可監聽流來做異步編程的API。老實説,這句描述並沒有把概念解釋清楚,所以在下面我們就用普通的語言來解釋Rx。
RxJS初認識
RxJS是Reactive Extension模式的JavaScript語言實現。
RxJS是一個使用可觀察序列組成異步和基於事件的程序庫。它提供了一種核心類型,Observable,廣播類型(Observer,Schedulers,Subjects)和操作符(map,filter,reduce等),允許將異步事件作為集合處理。
RxJS的運行就是Observable和Observer之間的互動遊戲。
RxJS中的數據流就是Observable對象,Observable實現了兩種設計模式:觀察者模式(Observer Pattern)、迭代器模式(Iterator Pattern) 。
Observable和Observer的關係是觀察者模式和迭代器模式的結合,通過Observable對象的subscribe函數,可以讓一個Observer對象訂閲某個Observable對象的推送內容,可以通過unsubscribe函數退訂內容。
RxJS核心概念
Observable:可觀察者對象,表示可以調用的未來值或事件集合的方法。
Observer: 觀察者,是一組回調函數,處理Observable提供的值。
/**
* Observable對象(source$)就是一個發佈者,通過Observable對象的subscribe函數,把發佈者和觀察者連接起來
* 扮演觀察者的是console.log,不管傳入什麼“事件”,它只管把“事件”輸出到console上
*/
const source$ = of(1, 2, 3); // 發佈者
source$.subscribe(console.log); // 觀察者
這段代碼輸出結果如下:
Subscription:訂閲關係,表示Observable執行,主要用於取消執行。
import {Observable} from 'rxjs/Observable';
const onSubscribe = observer => {
let number = 1;
const handle = setInterval(() => {
console.log(`onSubscirbe: ${number}`)
observer.next(number++);
}, 1000);
return {
unsubscribe: () => {
clearInterval(handle);
}
};
};
const source$ = new Observable(onSubscribe);
const subscription = source$.subscribe(item => console.log(`第${item}次調用`));
setTimeout(() => {
subscription.unsubscribe();
}, 5500);
這段代碼輸出結果如下:
該行代碼被註釋後 clearInterval(handle),代碼輸入結果如下:
當unsubscribe函數中的clearInterval被註釋掉後,也就是setInterval不被打斷,setInterval的函數參數中輸出當前number,修改之後的程序會不斷的輸出 onSubscirbe: n。
由此可見,Observable產生的事件,只有Observer通過subscribe訂閲之後才會收到,在unsubscribe之後就不會再收到。
Operators:操作符,純粹的函數,一個操作符是返回一個Observable對象的函數。
説起操作符,不得不説的就是彈珠圖,彈珠圖可以通過動畫很直白的向我們展示操作過程,動態: https://reactive.how/rxjs/ , 靜態:https://rxmarbles.com/#interval。
在所有操作符中最容易理解的可能就是map和filter,因為JavaScript的數組對象有兩個同名的函數map和filter。
JavaScript寫法:
const source = [1,2,3,4,5,6];
const result = source.filer(x => x % 2 === 0).map(x => x * 2);
console.log(result);
RxJS寫法:
const result$ = of(1,2,3,4,5,6).filter(x => x % 2 === 0).map(x => x * 2);
result$.subscribe(console.log);
按功能分類,大致可以分為9大類:
- 創建類(creation)
- 轉化類(transformation)
- 過濾類(filtering)
- 合併類(conbination)
- 多播類(multicasting)
- 錯誤處理類(error Handling)
- 輔助工作類(untility)
- 條件分支類(conditional & boolean)
- 數據和合計類(mathmatical & aggregate)
Subject:主題,相當於EventEmitter,將值或事件廣播到多個Observer的唯一方法。
import {Observable} from 'rxjs/Observable';
import {Subject} from 'rxjs/Subject';
import 'rxjs/add/observable/interval';
import 'rxjs/add/operator/take';
const tick$ = Observable.interval(1000).take(3);
const subject = new Subject();
tick$.subscribe(subject);
subject.subscribe(value => console.log('observer 1: ' + value));
setTimeout(() => {
subject.subscribe(value => console.log('observer 2: ' + value));
}, 1500);
這段代碼的執行結果如下:
以上代碼可以看出,Subject兼具Observable和Observer的性質,就像有兩副面孔,可以左右逢源。
日常常用場景如瀏覽器中鼠標的移動事件、點擊事件,瀏覽器的滾動事件,來自WebSocket的推送消息,還有Node.js支持的EventEmitter對象消息,及微服務系統中主應用與各個子應用之間的通信等。
Scheduler:控制併發的集中調度器,使我們能夠協調發生在setTimeout或其他的事件。
Scheduler實例:
- undefined/null:也就是不指定Scheduler,代表同步執行的Scheduler。
- asap:儘快執行的Scheduler。
- async:利用setInterval實現的Scheduler,用於基於時間吐出數據的場景。
- queue:利用隊列實現的Scheduler,用於迭代一個大的集合的場景。
- animationFrame:用於動畫場景的S cheduler。
RxJS默認選擇Scheduler的原則是:儘量減少併發運行。所以,對於range,就選擇undefined,指的是同步執行的Scheduler;對於很大的數據,就選擇queue;對於時間相關的操作符比如interval,就選擇async。
import {Observable} from 'rxjs/Observable';
import 'rxjs/add/observable/range';
import {asap} from 'rxjs/scheduler/asap';
const source$ = Observable.range(1, 3, asap);
console.log('before subscribe');
source$.subscribe(
value => console.log('data: ', value),
error => console.log('error: ', error),
() => console.log('complete')
);
console.log('after subscribe');
這段代碼的執行結果如下:
函數式在前端的積極作用
web開發時,我們會在服務端管理大量的系統狀態和系統數據,可以看到隨着前端工作流逐漸增多,事件和遠程狀態響應都會變得錯綜複雜。對於查看一個多於10個頁面或組件複雜的項目代碼時,我們會發現相比於後端,很難通過前端代碼讀懂整個業務鏈路。如果我們將核心代碼更換成較為合理的函數式邏輯,或者使用函數式工具和規範對已有邏輯進行歸納,就可以明顯提高代碼的可讀性和代碼運行時的可調試性,這也是對歷史代碼進行升級、改造的方法之一。
前端函數式的初衷是我們希望能更好、更快、更強地解決開發過程中遇到的問題。與其等待後續的治理,不如在日常開發中進行合理的規劃,養成良好的開發習慣。