一文讀懂對JavaScript函數語言程式設計的初認識

語言: CN / TW / HK

theme: vuepress

222 我正在參加「初夏創意投稿大賽」詳情請看:初夏創意投稿大賽

前言

     這是淺淺的夏天,帶來恰到好處的美,平淡與浪漫,張揚與靜默,飄零與蓬勃,每一種都是風景,都展現著獨屬於這個時節的味道,函數語言程式設計如夏天般讓人充滿期待,函數語言程式設計(夏天)適合奔赴與成長。

背景

     函數語言程式設計可以說是非常古老的程式設計方式,但是近幾年變成了一個非常熱門的話題。不管是Google力推的Go、學術派的Scala與Haskell,還是Lisp的新語言Clojure,這些新的函數語言程式設計語言越來越受到人們的關注。函數語言程式設計思想對前端的影響很大,Angular、React、Vue等熱門框架一直在不斷通過該思想來解決問題。

     函數語言程式設計作為一種高階程式設計正規化,更接近於數學和代數的一種程式設計正規化,與面向物件的開發理念和思維模式截然不同,深入理解這種差異性,是程式設計師進階的必經之路。

程式設計正規化

     程式設計正規化(Programming Paradigm)是程式語言領域的模式風格,體現了開發者設計程式語言時的考量,也影響著程式設計師使用相應語言進行程式設計設計的風格。大體分為兩大類,具體內容如下圖所示:

截圖2022-04-15 上午9.44.26

函式式概念與思維

     函數語言程式設計(Functional Programming)是基於λ演算(Lambda Calculus)的一種語言模式,它的實現基於λ演算和更具體的α-等價、β-歸約等設定 。這是一個較官方的解釋,大家不要被這種概念嚇到,很有可能你已經在日常開發中使用了大量的函數語言程式設計概念和工具。如越來越函式式的ES6,新的規範有非常多的新特性,其中不少借鑑其他函式式語言的特性,給JavaScript語言添加了不少函式式的新特性。箭頭函式就是ES6釋出的一個新特性,箭頭函式也被叫做肥箭頭(Fat Arrow),大致是借鑑自CoffeeScript或者Scala語言。箭頭函式是提供詞法作用域的匿名函式。

函數語言程式設計思維的目標:程式執行時,應該把程式對結果以外的資料的影響控制到最小

函數語言程式設計的特點

  1. 宣告式(Declarative)
  2. 純函式(Pure Function)

  3. 函式的執行過程完全由輸入引數決定,不會受除引數之外的任何資料影響。

  4. 函式不會修改任何外部狀態,比如修改全域性變數或傳入的引數物件。

  5. 資料不可變性(Immutability)

    當我們需要資料狀態發生改變時,保持原有資料不變,產生一個新的資料來體現這種變化。不可改變的資料就是Immutable資料,一旦產生,可以肯定它的值永遠不會變,這非常有利於程式碼的理解。

下面用一段對比程式碼解釋指令式程式設計與函數語言程式設計

```JavaScript // 計算傳入資料乘以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裡就是一等公民,同樣作為一等公民的函式就會擁有類似數字的性質。

  1. 函式與數字一樣可以儲存為變數

JavaScript let one = function() { return 1 };

  1. 函式與數字一樣可以儲存為陣列的一個元素

JavaScript let ones = [1, function() { return 1 }];

  1. 函式與數字一樣可以被傳遞給另一個函式

JavaScript function numAdd(n, f) { return n + f()}; numAdd(1, function() { return 1}); // 2

  1. 函式與數字一樣可以被另一個函式返回

JavaScript return 1; return function() { return 1 };

最後兩點其實就是“高階”函式的定義;一個高階函式應該可以至少執行一項,以一個函式作為引數或者返回一個函式作為結果。

高階函式(High Order Function)

     高階函式,通俗來說,就是以其他函式為引數的函式,返回其他函式的函式。我們稱函式的巢狀高階呼叫為高階函式,高階函式可以說是程式語言便捷踐行函式式的基礎。比如在React中我們會遇到的高階元件HOC。

以數字新增千分位符號為demo的程式碼如下:

JavaScript 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)

     柯里化函式為每一個邏輯引數返回一個新的函式,會逐漸返回已配置的函式,直到所有的引數用完。

JavaScript 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)。遞迴作為一種演算法在程式設計語言中廣泛應用。 遞迴是一種解決過程堆疊的方法,在執行時承擔了更多的工作。遞迴的能力在於用有限的語句來定義物件的無限集合。一般來說,遞迴需要有邊界條件、遞迴前進段和遞迴返回段。當邊界條件不滿足時,遞迴前進;當邊界條件滿足時,遞迴返回。

     說起遞迴,不得不談起尾遞迴。早期的瀏覽器引擎是不支援尾遞迴,所以當我們計算經典的斐波那契數列或進行其他遞迴操作時,可能會觸發堆疊呼叫超限的提醒。如果每次遞迴尾部返回的內容都是一個待計算的表示式,那麼執行時的記憶體棧中會一直壓入等待計算的變數和環境,這就是產生超限的根本原因。而如果我們使用新的遞迴方法,若執行環境支援優化,則立即釋放被替換的函式負載。

JavaScript // 遞迴:將外層呼叫儲存在記憶體堆疊中 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))

執行結果如下:

截圖2022-05-18 下午6.26.53

基於流的程式設計

     在前端領域中,「流」的經典代表之一「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提供的值。

image-20220518105003037

JavaScript ​ /** * Observable物件(source$)就是一個釋出者,通過Observable物件的subscribe函式,把釋出者和觀察者連線起來 * 扮演觀察者的是console.log,不管傳入什麼“事件”,它只管把“事件”輸出到console上 */ const source$ = of(1, 2, 3);  // 釋出者 source$.subscribe(console.log); // 觀察者

這段程式碼輸出結果如下:

截圖2022-05-18 下午6.33.41

Subscription:訂閱關係,表示Observable執行,主要用於取消執行。

JavaScript 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);

這段程式碼輸出結果如下:

截圖2022-05-18 下午6.32.41

該行程式碼被註釋後 clearInterval(handle),程式碼輸入結果如下:

截圖2022-05-18 上午11.30.31

當unsubscribe函式中的clearInterval被註釋掉後,也就是setInterval不被打斷,setInterval的函式引數中輸出當前number,修改之後的程式會不斷的輸出 onSubscirbe: n。

由此可見,Observable產生的事件,只有Observer通過subscribe訂閱之後才會收到,在unsubscribe之後就不會再收到

     Operators:操作符,純粹的函式,一個操作符是返回一個Observable物件的函式。

     說起操作符,不得不說的就是彈珠圖,彈珠圖可以通過動畫很直白的向我們展示操作過程,動態:​ https://reactive.how/rxjs/ , 靜態:https://rxmarbles.com/#interval

     在所有操作符中最容易理解的可能就是mapfilter,因為JavaScript的陣列物件有兩個同名的函式map和filter。

JavaScript寫法:

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寫法:

JavaScript 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的唯一方法。

JavaScript 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);

這段程式碼的執行結果如下:

截圖2022-05-18 下午6.28.24

以上程式碼可以看出,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。

JavaScript 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');

這段程式碼的執行結果如下:

截圖2022-05-18 下午6.28.24

函式式在前端的積極作用

     web開發時,我們會在服務端管理大量的系統狀態和系統資料,可以看到隨著前端工作流逐漸增多,事件和遠端狀態響應都會變得錯綜複雜。對於檢視一個多於10個頁面或元件複雜的專案程式碼時,我們會發現相比於後端,很難通過前端程式碼讀懂整個業務鏈路。如果我們將核心程式碼更換成較為合理的函式式邏輯,或者使用函式式工具和規範對已有邏輯進行歸納,就可以明顯提高程式碼的可讀性和程式碼執行時的可除錯性,這也是對歷史程式碼進行升級、改造的方法之一。

     前端函式式的初衷是我們希望能更好、更快、更強地解決開發過程中遇到的問題。與其等待後續的治理,不如在日常開發中進行合理的規劃,養成良好的開發習慣。

小結

     在悠悠清風佛過的地方,總有些美麗在悄然醞釀,總有些事物在向陽生長,若得閒時,一定要去赴函數語言程式設計的約,無需多麼隆重,素心素面慢慢賞,薄衫輕履走一趟。