延續昨天介紹的 forEach
函式,今天要來介紹 map
和 reduce
這兩個函數式程式設計的核心工具~
map
:陣列的變形工廠map
是函數式程式設計中最常用的工具之一。它的核心任務非常明確:對一個陣列中的每一個元素執行相同的操作,然後回傳一個包含所有結果的全新陣列。
map
?假設我們有一個儲存商品價格的陣列,現在需要根據不同的業務需求,對這組價格進行多種轉換:
5%
營業稅後的價格。"$120.00"
。如果用 for
迴圈來處理,程式碼可能會是這樣:
const prices = [20, 55, 120];
// 需求 1: 計算含稅價
const pricesWithTax = [];
for (let i = 0; i < prices.length; i++) {
pricesWithTax.push(prices[i] * 1.05);
}
// console.log(pricesWithTax); -> [21, 57.75, 126]
// 需求 2: 計算折扣價
const discountedPrices = [];
for (let i = 0; i < prices.length; i++) {
discountedPrices.push(prices[i] * 0.9);
}
// console.log(discountedPrices); -> [18, 49.5, 108]
// 需求 3: 格式化價格字串
const formattedPrices = [];
for (let i = 0; i < prices.length; i++) {
formattedPrices.push(`$${prices[i].toFixed(2)}`);
}
// console.log(formattedPrices); -> ["$20.00", "$55.00", "$120.00"]
上面程式碼中,有個很常見的重複問題,三個 for
迴圈的處理流程幾乎一模一樣:
for
迴圈遍歷原始陣列push
進新陣列唯一不同的是迴圈內部那一行核心的「轉換邏輯」。
這種重複乍看之下沒什麼問題,程式也可以運作正常,但它有一點隱含的問題在於,它讓程式碼變得冗長,而且如果未來迭代的邏輯需要修改(例如,需要處理 null
或 undefined
的情況),我們就必須在三個地方同步修改,增加了維護成本和出錯的風險。
那要如何解決呢? 這痛點就是 map
要解決的問題,map
可以將這個重複的「迭代模式」抽象出來,變成可重複使用的工具,每次我們需要類似迴圈處理流程時,只需提供那段獨特的「轉換邏輯」即可。
map
函式現在我們來寫一個 map
函式來處理這段重複的邏輯,一個通用的 map
函式需要:
根據這個邏輯,我們可以寫出如下的程式碼:
function customMap(array, transform) { // 1. 接收一個陣列和轉換函數作為參數
const newArray =; // 2. 建立一個新的空陣列
for (let i = 0; i < array.length; i++) { // 3. 遍歷陣列
const originalElement = array[i];
const transformedElement = transform(originalElement); // 4. 應用轉換函數
newArray.push(transformedElement); // 5. 將結果推入新陣列
}
return newArray; // 6. 回傳新陣列
}
// --- 使用我們自製的 map ---
const prices = [20, 55, 120];
const pricesWithTax = customMap(prices, price => price * 1.05);
const discountedPrices = customMap(prices, price => price * 0.9);
const formattedPrices = customMap(prices, price => `$${price.toFixed(2)}`);
console.log(pricesWithTax); // [21, 57.75, 126]
console.log(discountedPrices); // [18, 49.5, 108]
console.log(formattedPrices); // ["$20.00", "$55.00", "$120.00"]
我們將「迭代的邏輯」(for
迴圈)與「要執行的行為」(transform
函數)分離開來。customMap
封裝了繁瑣的迴圈細節,讓我們能專注於定義轉換規則,程式碼變得既簡潔又易於維護。
Array.prototype.map()
方法JavaScript 有一個內建的 Array.prototype.map()
方法,其用途就和我們自己手寫的 customMap
類似,此 map
方法會遍歷呼叫它的陣列中的每個元素,為每個元素呼叫一次我們提供的回呼函數 (callback function),並將每次回呼函數執行的結果蒐集起來,組成一個新的陣列回傳 。
這裡有兩個要注意的特性:
map
產生的新陣列,其長度必定與原始陣列完全相同。如果原始陣列有 10 個元素,map
之後的新陣列也絕對是 10 個元素。它像一個變形工廠的生產線,每個原料進去,就有一個成品出來。如下示意圖,map
函式會把輸入陣列(X
)轉成輸出陣列(Y
),因此我們需傳入能將 X
元素指向 Y
元素的函式(此轉換函式接受 X
元素,傳回 Y
元素)。
圖 1 map
運作示意圖(資料來源: 自行繪製)
map
不會修改 (mutate) 原始陣列。它會回傳一個全新的陣列,而原始陣列保持原封不動。這是函數式程式設計的核心原則之一,它能避免許多意想不到的副作用 (side effects),讓程式碼的行為更可預測。而為了維持不可變性,傳入 map
的函式應盡量為 Calculation,呼叫 map
的函式才會是 Calculation。舉個例子,假設我們有一個數字陣列,想要計算每個數字的平方根:
const numbers = [4, 9, 16, 25];
// 傳遞 Math.sqrt 這個函數給 map
const roots = numbers.map(Math.sqrt);
console.log(roots); // 輸出: [2, 3, 4, 5]
console.log(numbers); // 輸出: [4, 9, 16, 25] (原始陣列安然無恙)
可以從上述程式中看出,原始的陣列 numbers
沒有被改變,並且輸出的陣列長度 roots
也和原始陣列 numbers
長度相同 👍
reduce
:陣列聚合器如果說 map
是陣列的變形工廠,那麼 reduce
就是陣列的聚合器。它可以將一個陣列中的所有元素「濃縮」、「聚合」或「歸納」成一個單一的值。這個「單一的值」可以是任何東西:一個數字、一個字串、一個物件,甚至是一個新的陣列。
reduce
?與 map
類似,我們先從一個情境出發。假設我們有一個購物車陣列,裡面包含了多個商品物件,每個物件有價格和數量。現在我們需要完成兩個任務:
使用 for
迴圈,我們會這樣做:
const cartItems = [
{ name: '鍵盤', price: 800, quantity: 1 },
{ name: '滑鼠', price: 1200, quantity: 1 },
{ name: '螢幕', price: 9500, quantity: 2 }
];
// 任務 1: 計算總金額
let totalCost = 0;
for (let i = 0; i < cartItems.length; i++) {
totalCost += cartItems[i].price * cartItems[i].quantity;
}
console.log(totalCost); // 21000
// 任務 2: 計算總數量
let totalItems = 0;
for (let i = 0; i < cartItems.length; i++) {
totalItems += cartItems[i].quantity;
}
console.log(totalItems); // 4
同樣地,我們看到了重複的模式。兩個迴圈都在做同一件事:
這個「從一個初始值開始,遍歷一個列表,並將其濃縮成一個最終值」的過程,就是「化簡」或「歸納」(Reduction)。
如果我們使用一個 reduce 函式,就可以將這個通用的「化簡」模式也抽象出來,我們只需要提供「如何更新累積值」的邏輯,不須管迴圈遍歷的細節。
reduce
函式我們可以打造一個 customReduce
函數來封裝這個重複的模式。它需要接收一個陣列、一個「化簡器」函數 (reducer) 和一個初始值 (initial value)。
function customReduce(array, reducer, initialValue) {
let accumulator = initialValue; // 1. 從初始值開始
for (let i = 0; i < array.length; i++) { // 2. 用索引遍歷陣列
const element = array[i];
// 3. 使用 reducer 函數更新 accumulator
accumulator = reducer(accumulator, element);
}
return accumulator; // 4. 回傳最終的累積結果
}
// --- 使用我們自製的 reduce ---
const cartItems = [
{ name: '鍵盤', price: 800, quantity: 1 },
{ name: '滑鼠', price: 1200, quantity: 1 },
{ name: '螢幕', price: 9500, quantity: 2 }
];
// 任務 1: 計算總金額
const totalCost = customReduce(
cartItems,
(sum, item) => sum + (item.price * item.quantity),
0
);
console.log(totalCost); // 21000
// 任務 2: 計算總數量
const totalItems = customReduce(
cartItems,
(count, item) => count + item.quantity,
0
);
console.log(totalItems); // 4
透過 customReduce
,我們可以將迭代的程式碼分離出去。我們現在可以用一種更宣告式的方式來表達我們的意圖:「將 cartItems
陣列化簡成一個總金額」或「化簡成一個總數量」,而不用關心 for
迴圈的細節。
Array.prototype.reduce()
JavaScript 有一個內建的 Array.prototype.reduce()
方法,其用途就和我們自己手寫的 customReduce
類似,它會對陣列中的每個元素執行一個由我們提供的「化簡器」(reducer) 函數,並將其結果逐項累積,最終回傳一個單一的結果值。
最常見的例子就是加總一個數字陣列:
const numbers = [10, 20, 20];
const sum = numbers.reduce((accumulator, currentValue) => {
return accumulator + currentValue;
});
console.log(sum); // 50
reduce
的關鍵元素:accumulator
與 initialValue
reduce
有兩個核心參數:accumulator
(累加器)和 initialValue
(初始值)。這也是我一開始學 reduce
最困惑的地方。reduce
的完整語法是 arr.reduce(callbackFn, initialValue)
。
callbackFn(accumulator, currentValue, currentIndex, array)
accumulator
:累加器。它是上一次回呼函數呼叫所回傳的值。currentValue
:陣列中目前正在處理的元素。initialValue
(可選):作為第一次呼叫 callbackFn
時 accumulator
的初始值。如以下示意圖,reduce
會一邊走訪陣列,一邊「累進」其中元素,最後得出一個合併完成的值。
圖 2 reduce
運作示意圖(資料來源: 自行繪製)
一般來說會建議提供 initialValue
,因為如果在空陣列上呼叫 reduce
,有無 initialValue
會有結果上的差異:
initialValue
,會直接拋出 TypeError
。因為 reduce
無法從空陣列中獲取第一個元素作為初始的 accumulator
。initialValue
,reduce
會安全地直接回傳 initialValue
,而不會執行回呼函數。因此一律提供 initialValue
能讓 reduce
的行為更加一致和可預測,特別是在處理可能為空的陣列時,可以避免執行時錯誤。
reduce()
可以做什麼?reduce
的威力體現在它能將陣列轉換成任何你想要的資料結構。除了簡單的加總,它在「資料塑形」方面也非常強大,以下舉幾個應用範例。
reduce
可以將一個扁平的陣列,根據某個屬性重新組織成一個巢狀的物件結構。
舉例來說,我們可以用來將學生列表按照他們的成績分組。
const students = [
{ name: 'Alice', grade: 'A' },
{ name: 'Bob', grade: 'B' },
{ name: 'Charlie', grade: 'A' },
{ name: 'David', grade: 'C' },
{ name: 'Eve', grade: 'B' }
];
const groupedByGrade = students.reduce((groups, student) => {
const { grade } = student; // 取得學生的成績
// 如果這個成績的分類還不存在,就建立一個空陣列
if (!groups[grade]) {
groups[grade] = [];
}
// 將當前學生推進對應的分類陣列中
groups[grade].push(student);
return groups;
}, {});
console.log(groupedByGrade);
/*
{
A: [ { name: 'Alice', grade: 'A' }, { name: 'Charlie', grade: 'A' } ],
B: [ { name: 'Bob', grade: 'B' }, { name: 'Eve', grade: 'B' } ],
C: [ { name: 'David', grade: 'C' } ]
}
*/
這是 reduce
的典型應用場景:處理一個物件陣列,並根據其屬性進行計算。
const cartItems = [
{ name: '鍵盤', price: 800, quantity: 1 },
{ name: '滑鼠', price: 1200, quantity: 1 },
{ name: '螢幕', price: 9500, quantity: 2 }
];
const totalCost = cartItems.reduce((total, item) => {
return total + (item.price * item.quantity);
}, 0); // 初始總價為 0
console.log(totalCost); // 21000 (800 + 1200 + 19000)
將一個二維陣列「攤平」成一維陣列,是 reduce
的另一個常見用途。
const nestedArray = [
[1, 2],
[3, 4],
[5, 6]
];
const flatArray = nestedArray.reduce((flat, currentArray) => {
return flat.concat(currentArray);
}, []); // 初始值是一個空陣列
console.log(flatArray);
// [1, 2, 3, 4, 5, 6]
雖然現在 JavaScript 內建了更方便的 Array.prototype.flat()
方法,但透過 reduce
來實現有助於深入理解其運作原理。
從這些例子可以看出,reduce
是處理列表資料最底層、最通用的計算模式。實際上,陣列的 map
和 filter
方法本身都可以用 reduce
來實現 。
補充:不同語言的
reduce()
可能用不同名稱。
不同名稱如:
fold()
foldLeft()
(代表處理陣列的方向)foldRight()
(代表處理陣列的方向)
今天介紹了 map
和 reduce
這兩個 JavaScript 中的高階函式,兩者差異如下:
map
專門用於對陣列進行一對一的轉換,並產生一個全新的、長度相同的陣列。reduce
能夠將整個陣列聚合、濃縮成任何形式的單一結果。我們也見證了從指令式「如何做」到宣告式「做什麼」的思維轉變。這種轉變讓我們能夠撰寫出意圖更明確、更少錯誤、更易於推理的程式碼,接著會再介紹 Currying(柯里化),繼續更深入理解 FP 程式設計~