動態

詳情 返回 返回

一文讀懂對JavaScript函數式編程的初認識 - 動態 詳情

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)
  • 函數的執行過程完全由輸入參數決定,不會受除參數之外的任何數據影響。
  • 函數不會修改任何外部狀態,比如修改全局變量或傳入的參數對象。
  1. 數據不可變性(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裏就是一等公民,同樣作為一等公民的函數就會擁有類似數字的性質。

  1. 函數與數字一樣可以存儲為變量
let one = function() { return 1 };
  1. 函數與數字一樣可以存儲為數組的一個元素
let ones = [1, function() { return 1 }];
  1. 函數與數字一樣可以被傳遞給另一個函數
function numAdd(n, f) { return n + f()};
numAdd(1, function() { return 1}); // 2
  1. 函數與數字一樣可以被另一個函數返回
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))

運行結果如下:

截屏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

​
/**
 * 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執行,主要用於取消執行。

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

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

這段代碼的執行結果如下:

截屏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。

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個頁面或組件複雜的項目代碼時,我們會發現相比於後端,很難通過前端代碼讀懂整個業務鏈路。如果我們將核心代碼更換成較為合理的函數式邏輯,或者使用函數式工具和規範對已有邏輯進行歸納,就可以明顯提高代碼的可讀性和代碼運行時的可調試性,這也是對歷史代碼進行升級、改造的方法之一。

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

Add a new 評論

Some HTML is okay.