[譯] 函式式 JavaScript 快速入門

LeviDing發表於2018-09-05

函數語言程式設計是目前最熱門的趨勢之一,有很多好的論點解釋了人們為什麼想在程式碼中使用它。我並不打算在這裡詳細介紹所有函數語言程式設計的概念和想法,而是會盡力給你演示在日常情況下和 JavaScript 打交道的時候如何用上這種程式設計。

函數語言程式設計是一種程式設計範例,它將計算機運算視為數學上的函式計算,並且避免了狀態的改變和易變的資料。

重新定義函式

在深入接觸 JavaScript 的函數語言程式設計範例之前,我們們得先知道什麼是高階函式、它的用途以及這個定義本身究竟有什麼含義。高階函式既可以把函式當成引數來接收,也可以作為把函式作為結果輸出。你需要記住 函式其實也是一種值,也就是說你可以像傳遞變數一樣去傳遞函式。

所以呢,在 JavaScript 裡你可以這麼做:

// 建立函式
function f(x){
  return x * x;
}
// 呼叫該函式
f(5); // 25

// 建立匿名函式
// 並賦值一個變數
var g = function(x){
  return x * x;
}
// 傳遞函式
var h = g;
// And use it
h(5); // 25
複製程式碼

把函式當成值來使用

一旦使用上面這個技巧,你的程式碼更容易被重複利用,同時功能也更加強大。我們們都經歷過這樣的情況:想要把一個函式傳到另一個函式裡去執行任務,但需要寫一些額外的程式碼來實現這一點,對吧?使用函數語言程式設計的話,你將不再需要寫額外的程式碼,並且可以使你的程式碼變得很乾淨、易於理解。

有一點要注意,正確的泛函程式碼的特點是沒有副作用,也就是說函式應該只依賴於它們的引數輸入,並且不應以任何方式影響到外界環境。這個特點有重要的含義,舉個例子:如果傳遞進函式的引數相同,那麼輸出的結果也總是相同的;如果一個被呼叫的函式所輸出的結果並沒有被用到,那麼這個結果即使被刪掉也不會影響別的程式碼。


使用陣列原型的內建方法

Array.prototype 應該是你學習 JavaScript 函數語言程式設計的第一步,它涵蓋了很多陣列轉化的實用方法,這些方法在現代網頁應用裡相當的常見。

先來看看這個叫 Array.prototype.sort() 的方法會很不錯,因為這個轉化挺直白的。顧名思義,我們可以用這個方法來給陣列排序.sort() 只接收一個引數(即一個用於比較兩個元素的函式)。如果第一個元素在第二個元素的前面,結果返回的是負值。反之,則返回正值。

排序聽起來非常簡單,然而當你需要給比一般數字陣列複雜得多的陣列排序時,可能就不那麼簡單了。在下面這個例子裡,我們有一個物件的陣列,裡面存的是以磅(lbs)或千克(kg)為單位的體重,我們們需要對這些人的體重進行升序排列。程式碼看起來會是這樣:

// 我們們這個比較函式的定義
var sortByWeight = function(x,y){
  var xW = x.measurement == "kg" ? x.weight : x.weight * 0.453592;
  var yW = y.measurement == "kg" ? y.weight : y.weight * 0.453592;
  return xW > yW ? 1 : -1;
}

// 兩組資料有細微差別
// 要根據體重來對它們進行排序
var firstList = [
  { name: "John", weight: 220, measurement: "lbs" },
  { name: "Kate", weight: 58, measurement: "kg" },
  { name: "Mike", weight: 137, measurement: "lbs" },
  { name: "Sophie", weight: 66, measurement: "kg" },
];
var secondList = [
  { name: "Margaret", weight: 161, measurement: "lbs", age: 51 },
  { name: "Bill", weight: 76, measurement: "kg", age: 62 },
  { name: "Jonathan", weight: 72, measurement: "kg", age: 43 },
  { name: "Richard", weight: 74, measurement: "kg", age: 29 },
];

// 用開頭定義的函式
// 對兩組資料進行排序
firstList.sort(sortByWeight); // Kate, Mike, Sophie, John 
secondList.sort(sortByWeight); // Jonathan, Margaret, Richard, Bill
複製程式碼

用函數語言程式設計來對兩個陣列進行排序的例子

在上面的例子裡,你可以很清楚地觀察到使用高階函式帶來的好處:節省了空間、時間,也讓你的程式碼更能被讀懂、更容易被重複利用。如果你不打算用 .sort() 來寫的話,你得另外寫兩個迴圈並重復大部分的邏輯。坦率來說,那樣將導致更冗長、臃腫且不易理解的程式碼。


通常你對陣列的操作也不單只是排序而已。就我的經驗而言,根據屬性來過濾一個陣列很常見,而且沒有什麼方法比 Array.prototype.filter() 更加合適。過濾陣列並不困難,因為你只需將一個函式作為引數,對於那些需要被過濾掉的元素,該函式會返回 false。反之,該函式會返回 true。很簡單,不是嗎?我們們來看看例項:

// 一群人的陣列
var myFriends = [
  { name: "John", gender: "male" },
  { name: "Kate", gender: "female" },
  { name: "Mike", gender: "male" },
  { name: "Sophie", gender: "female" },
  { name: "Richard", gender: "male" },
  { name: "Keith", gender: "male" }
];

// 基於性別的簡易過濾器
var isMale = function(x){
  return x.gender == "male";
}

myFriends.filter(isMale); // John, Mike, Richard, Keith
複製程式碼

關於過濾的一個簡單例子

雖然 .filter() 會返回陣列中所有符合條件的元素,你也可以用 Array.prototype.find() 提取陣列中第一個符合條件的元素,或是用 Array.prototype.findIndex() 來提取陣列中第一個匹配到的元素索引。同理,你可以使用 Array.prototype.some() 來測試是否至少有一個元素符合條件,抑或是用 Array.prototype.every() 來檢查是否所有的元素都符合條件。這些方法在某些應用中可以變得相當有用,所以我們們來看一個囊括了這幾種方法的例子:

// 一組關於分數的陣列
// 不是每一項都標註了人名
var highScores = [
  {score: 237, name: "Jim"},
  {score: 108, name: "Kit"},
  {score: 91, name: "Rob"},
  {score: 0},
  {score: 0}
];

// 這些簡單且能重複使用的函式
// 是用來檢視每一項是否有名字
// 以及分數是否為正數
var hasName = function(x){
  return typeof x['name'] !== 'undefined';
}
var hasNotName = function(x){
  return !hasName(x);
}
var nonZeroHighScore = function(x){
  return x.score != 0;
}

// 填充空白的名字,直到所有空白的名字都有“---”
while (!highScores.every(hasName)){
  var highScore = highScores.find(hasNotName);
  highScore.name = "---";
  var highScoreIndex = highScores.findIndex(hasNotName);
  highScores[highScoreIndex] = highScore;
}

// 檢查非零的分數是否存在
// 並在 console 裡輸出
if (highScores.some(nonZeroHighScore))
  console.log(highScores.filter(nonZeroHighScore));
else 
  console.log("No non-zero high scores!");
複製程式碼

使用函數語言程式設計來構造資料

到這一步,你應該會有些融會貫通的感覺了。上面的例子清楚地體現出高階函式是如何使你避免了大量重複且難以理解的程式碼。這個例子雖然簡單,但你也能看出程式碼的簡潔之處,與你在未使用函數語言程式設計範例時所編寫的內容形成鮮明對比。


先撇開上面例子裡複雜的邏輯,我們們有的時候只想要將陣列轉化成另一個陣列,且無需對陣列裡的資料做那麼多的改變。這個時候 Array.prototype.map() 就派上用場了,我們可以用這個方法來轉化陣列中的物件。.map()和之前例子所用到的方法並不相同,區別在於其作為引數的高階函式會返回一個物件,可以是任何你想寫的物件。讓我用一個簡單的例子來演示一下:

// 一個有 4 個物件的陣列
var myFriends = [
  { name: "John", surname: "Smith", age: 52},  
  { name: "Sarah", surname: "Smith", age: 49},  
  { name: "Michael", surname: "Jones", age: 46},  
  { name: "Garry", surname: "Thomas", age: 48}
];

// 一個簡單的函式
// 用來把名和姓放在一起
var fullName = function(x){
  return x.name + " " + x.surname;
}

myFriends.map(fullName);
// 應輸出
// ["John Smith", "Sarah Smith", "Michael Jones", "Garry Thomas"]
複製程式碼

對陣列裡的物件進行 mapping 操作

從上面這個例子可以看出,一旦對陣列使用了 .map() 方法,很容易就能得到一個僅包含我們們所需屬性的陣列。在這個例子裡,我們只想要物件中 namesurname 這兩行字串,所以才使用簡單的 mapping(譯者注:即使用 map 方法) 來利用原來包含很多物件的陣列上建立了另一個只包含字串的陣列。Mapping 這種方式可能比你想象的還要常用,它在每個網頁開發者的口袋中可以成為很強大的工具。所以說,這整篇文章裡你如果別的沒記住的話,沒關係,但千萬要記住如何使用 .map()


最後還有一點非常值得你注意,那就是常規目的陣列轉化中的 Array.prototype.reduce().reduce() 與上面提到的所有方法都有所不同,因為它的引數不僅僅是一個高階函式,還包含一個累加器。一開始聽起來可能有些令人困惑,所以先看一個例子來幫助你理解 .reduce() 背後的基礎概念吧:

// 關於不同公司支出的陣列
var oldExpenses = [
  { company: "BigCompany Co.", value: 1200.10},
  { company: "Pineapple Inc.", value: 3107.02},
  { company: "Office Supplies Inc.", value: 266.97}
];
var newExpenses = [
  { company: "Office Supplies Inc.", value: 108.11},
  { company: "Megasoft Co.", value: 1208.99}
];

// 簡單的求和函式
var sumValues = function(sum, x){
  return sum + x.value;
}

// 將第一個陣列降為幾個數值之和
var oldExpensesSum = oldExpenses.reduce(sumValues, 0.0);
// 將第二個陣列降為幾個數值之和
console.log(newExpenses.reduce(sumValues, oldExpensesSum)); // 5891.19
複製程式碼

將陣列降為和值

對於任何曾經把陣列中的值求和的人來說,理解上面這個例子應該不會特別困難。一開始我們們定義了一個可重複使用的高階函式,用於把陣列中的 value 都加起來。之後,我們們用這個函式來給第一個陣列中的支出數值求和,並把求出來的值當成初始值,而不是從零開始地去累加第二個陣列中的支出數值。所以最後得出的是兩個陣列的支出數值總和。

當然了,.reduce() 可以做的事情遠不止在陣列中求和而已。大多數別的方法解決不了的複雜轉化,都可以使用 .reduce() 與一個陣列或物件的累加器來輕鬆解決。一個實用的例子是轉化一個有很多篇文章的陣列,每一篇文章有一個標題和一些標籤。原來的陣列會被轉化成標籤的陣列,每一項中有使用該標籤的文章數目以及這些文章的標題構成的陣列。我們們來看看程式碼:

// 一個帶有標籤的文章的陣列
var articles = [
  {title: "Introduction to Javascript Scope", tags: [ "Javascript", "Variables", "Scope"]},
  {title: "Javascript Closures", tags: [ "Javascript", "Variables", "Closures"]},
  {title: "A Guide to PWAs", tags: [ "Javascript", "PWA"]},
  {title: "Javascript Functional Programming Examples", tags: [ "Javascript", "Functional", "Function"]},
  {title: "Why Javascript Closures are Important", tags: [ "Javascript", "Variables", "Closures"]},
];

// 一個能夠將文章陣列降為標籤陣列的函式
// 
var tagView = function(accumulator, x){
  // 針對文章的標籤陣列(原陣列)裡的每一個標籤
  x.tags.forEach(function(currentTag){
    // 寫一個函式看看標籤是否匹配
    var findCurrentTag = function(y) { return y.tag == currentTag; };
    // 檢查是否該標籤已經出現在累積器陣列
    if (accumulator.some(findCurrentTag)){
      // 找到標籤並獲得索引
      var existingTag = accumulator.find(findCurrentTag);
      var existingTagIndex = accumulator.findIndex(findCurrentTag);
      // 更新使用該標籤的文章數目,以及文章標題的列表
      accumulator[existingTagIndex].count += 1;
      accumulator[existingTagIndex].articles.push(x.title);
    }
    // 否則就在累積器陣列中增添標籤
    else {
      accumulator.push({tag: currentTag, count: 1, articles: [x.title]});
    }
  });
  // 返回累積器陣列
  return accumulator;
}

// 轉化原陣列
articles.reduce(tagView,[]);
// 輸出:
/*
[
 {tag: "Javascript", count: 5, articles: [
    "Introduction to Javascript Scope", 
    "Javascript Closures",
    "A Guide to PWAs", 
    "Javascript Functional Programming Examples",
    "Why Javascript Closures are Important"
 ]},
 {tag: "Variables", count: 3, articles: [
    "Introduction to Javascript Scope", 
    "Javascript Closures",
    "Why Javascript Closures are Important"
 ]},
 {tag: "Scope", count: 1, articles: [ 
    "Introduction to Javascript Scope" 
 ]},
 {tag: "Closures", count: 2, articles: [
    "Javascript Closures",
    "Why Javascript Closures are Important"
 ]},
 {tag: "PWA", count: 1, articles: [
    "A Guide to PWAs"
 ]},
 {tag: "Functional", count: 1, articles: [
    "Javascript Functional Programming Examples"
 ]},
 {tag: "Function", count: 1, articles: [
    "Javascript Functional Programming Examples"
 ]}
]
*/
複製程式碼

使用 reduce() 來進行一項複雜的轉化

上面這個例子可能看起來會有些小複雜,所以需要一步一步來研究。首先呢,我們想要的最終結果是一個陣列,所以累加器的初始值就成了[]。然後,我們想要陣列中的每一個物件都包含標籤名、使用該標籤的文章數目以及文章標題的列表。不但如此,每一個標籤在陣列中只能出現一次,所以我們必須用 .some().find().findIndex() 來檢查標籤是否存在,之後將現有標籤的物件進行轉化,而不是另加一個新的物件。

棘手的地方在於,我們不能定義一個函式來檢查每個標籤是否都存在(否則需要 7 個不同的函式)。所以我們們才在當前標籤的迴圈裡定義高階函式,這樣一來就可以再次使用高階函式,避免重寫程式碼。對了,其實這也可以通過 Currying 來完成,但我不會在本文中解釋這個技巧。

當我們們在累加器陣列中獲取標籤的物件之後,只需要把使用該標籤的文章數目遞增,並且將當前標籤下的文章新增到其文章陣列中就行了。最後,我們們返回累加器,大功告成。仔細閱讀的話會發現程式碼不但非常簡短,而且很容易理解。相同情況下,非函數語言程式設計的程式碼將會看起來非常令人困惑,而且明顯會更冗雜。

結語

函數語言程式設計作為目前最熱門的趨勢之一,是有其充分原因的。它使我們們在寫出更清晰、更精簡和更“吝嗇”程式碼的同時,不必去擔心副作用和狀態的改變。JavaScript 的 [Array.prototype](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/prototype) 方法在許多日常情況下非常實用,並且讓我們們在對陣列進行簡單和複雜的轉化,也不必去寫太多重複的程式碼。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章