上手使用 JavaScript 的 Map、Reduce 吧!


雖然有些概念類似甚至可以相通,但這裡並不是指常聽到的「MapReduce」,本文目的不是要討論如何運用 MapReduce 這樣的架構去處理大資料庫。這裡真正要討論的是,如何使用 JavaScript 裡陣列(Array)中的 .map() 和 .reduce() 方法,並把一些常見的使用方法和情境描述出來大家進行參考。

很多人對這兩個方法不習慣,原因不外乎是這兩種方法本來就不是一個非常直覺的東西,在大多數 JavaScript 語言的開發情境中,其實也沒有非得使用的理由。但不得不說,習慣了這兩個對陣列操作的方法,程式碼會變得簡潔,也更容易能處理一整批的資料。有時也能順便學習到一些「Functional Programming」會用到的概念,無論是在改善程式品質,還是投資自己的角度上,都有相當好處。

從最簡單的遍歷陣列開始


面對一個陣列裡的一堆資料,我們一定是從遍歷開始,一一處理裡面的每一筆資料。你也許已經非常熟悉如何遍歷陣列,最常見的不外乎就是兩種做法。

使用 for-loop:

var myArr = [ 1, 2, 3 ];

for (var index in myArr) {
    console.log(myArr[index]);
}

使用陣列內建的 forEach 方法:

var myArr = [ 1, 2, 3 ];

myArr.forEach(function(element) {
    console.log(element);
});

使用 .map() 對每個陣列元素加工


有些時候,我們想對每個陣列元素(Element)進行加工處理,於是最土法煉鋼的方法大概就是這樣:

幫每個元素加一:

var myArr = [ 1, 2, 3 ];

for (var index in myArr) {
    myArr[index] = myArr[index] + 1;
}

// [ 2, 3, 4 ]
console.log(myArr);

這時你可以使用 .map() 方法來達成同樣目的:

var myArr = [ 1, 2, 3 ];

var newArr = myArr.map(function(element) {
    return element + 1;
});

// [ 2, 3, 4 ]
console.log(newArr);

.map() 會將每一個元素代入處理函數,而處理函數回傳的值,會被收集組成一個新的陣列,這個新的陣列元素數量會和原本陣列的一樣。換句話說,同樣是對陣列加工後得到結果,它會回傳一個新的、加工過後的陣列,而不會修改原本的陣列內容。

使用 .map() 進行資料校正處理


當我們了解 .map() 的運作原理後,可以使用它做到更多資料處理的事,例如資料的校正或過濾。

舉例來說,若是我們得到一個包含許多數值的陣列,而我們想限定這些數值不得超過我們設定的上限值,這時我們可以這樣處理,來得到一個經過檢查校正過後的資料結果:

var myArr = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ];

var newArr = myArr.map(function(element) {
    // 數值大於五的數值視為五
    if (element > 5)
        return 5;
        
    return element;
});

// [ 1, 2, 3, 4, 5, 5, 5, 5, 5, 5 ]
console.log(newArr);

使用 .reduce() 進行數值加總


處理陣列資料的工作中,其中一項最常見的就是數值加總,或是進行統計運算。同樣的,若你使用土法煉鋼的做法,大致上如下:

var myArr = [ 1, 2, 3 ];
var result = 0;

for (var index in myArr) {
    result += myArr[index];
}

// 6
console.log(result);

若使用 .reduce(),可以這樣寫:

var myArr = [ 1, 2, 3 ];

// 處理每個元素後等待回傳結果,第一次處理時代入初始值 0
var result = myArr.reduce(function(prev, element) {
    // 與之前的數值加總,回傳後代入下一輪的處理
    return prev + element;
}, 0);

// 6
console.log(result);

我們可以看到,改用 .reduce() 之後,陣列元素的加總計算,不會再一直存取到外部的 result 變數,而是算完結果後才將結果統計結果回傳。這樣做的好處,是不會再跨 Scope 去存取外部的變數,這對 JavaScript 這種有複雜 Scope 設計的語言來說,程式碼不會到處去污染。

把 .map() 和 .reduce() 串接起來吧!


這兩種方法都是用來處理陣列,所以我們可以輕易地串接兩者,以前面的例子來說,可以先對陣列資料進行校正和加工,然後對資料進行收斂和加總:

var myArr = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ];

var result = myArr
    .map(function(element) {
        // 數值大於五的數值視為五
        if (element > 5)
            return 5;
            
        return element;
    })
    .reduce(function(prev, element) {
        // 與之前的數值加總,回傳後代入下一輪的處理
        return prev + element;
    }, 0);

// 40
console.log(result);

利用 .reduce() 進行陣列扁平化


如果你開始查 .reduce() 的資料,應該會看到一些 MDN 文件,會提到一些相當實用的功能,其中一個就是扁平化陣列的應用。簡單來說,就是將一個複雜的陣列,扁平化成一維,這在很多資料處理或數值計算上相當有用。

var myArr = [
    [ 1, 2 ],
    [ 3, 4, 5 ],
    [ 6, 7, 8 ]
];

// 將所有元素都與之前代入的陣列相接起來,第一次處理時代入初始值空陣列
var newArr = myArr.reduce(function(arr, element) {
    // ex: [ 1, 2 ].concat([ 3, 4, 5 ])
    return arr.concat(element);
}, []);

// [ 1, 2, 3, 4, 5, 6, 7, 8 ]
console.log(newArr);

所以這個處理函數將會被執行三次:

  1. 將空陣列與 [ 1, 2 ] 相接起來後回傳
  2. 將被代入的 [ 1, 2 ] 與 [ 3, 4, 5 ] 相接起來後回傳
  3. 將被代入的 [ 1, 2, 3, 4, 5 ] 與 [ 6, 7, 8 ] 相接起來後回傳

利用 .reduce() 進行資料歸納和統計吧!


我們也可以利用 .reduce() 配合上物件操作,對陣列的內容進行統計工作:

var myArr = [
    'C/C++',
    'JavaScript',
    'Ruby',
    'Java',
    'Objective-C',
    'JavaScript',
    'PHP'
];

// 計算出每種語言出現過幾次
var langStatistics = myArr.reduce(function(langs, langName) {
    if (langs.hasOwnProperty(langName)) {
        langs[langName]++
    } else {
        langs[langName] = 1;
    }
    
    return langs;
}, {});

// { 'C/C++': 1, 'JavaScript': 2, 'Ruby': 1, 'Java': 1, 'Objective-C': 1, 'PHP': 1 }
console.log(langStatistics);

如果想要處理的資料是 Object 的形式怎麼辦?


運用 Object.keys() 這樣的技巧,我們可以把 .map() 或 .reduce() 結合使用到 Object 的資料上使用,這樣就可以對 Object 資料進行相同的統計運算或數值計算。

var data = {
    'Fred': 1,
    'Leon': 2,
    'Wesley': 3,
    'Chuck': 4,
    'Denny': 5
};

// 使用 Object.keys() 取得包含所有 key 的陣列
var result = Object.keys(data).reduce(function(prev, name) {
    // 利用 key 取得原始物件中的值,然後加總
    return data[name] + prev;
}, 0);

// 15
console.log(result);

你在寫啥?結合 ECMAScript 6 後,世界都不一樣了。


ES6 已經上了實際的戰場,當 .map()/.reduce() 方法加上箭頭函數(Arrow Function
),然後又配合上 JavaScript 語言的特性,整個程式碼將變得更為簡短乾淨。

let newArr = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ].map((value) => value + 1);

當箭頭函數只有一個參數時,可以省去括號「()」:

let newArr = [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ].map(value => value + 1);

註:不過,對於不習慣的人來說,更難閱讀了。但在開放原始碼和社群的圈子裡,因為已經被大量使用,所以最好趕快習慣它,會方便你更容易看懂坊間的各種「新」程式碼。

後記


當然,濫用 map/reduce 也可能會造成程式碼難以閱讀,無論是哪一種程式的技巧,這肯定都是一個問題。但至於什麼時候該用,什麼時候不該用,並不在本文範疇,個人認為,我們得先熟練使用這兩種方法,用熟了,再接著探討「好的使用情境」才有意義。因為很多人不熟悉,又不敢亂用,就更沒有機會習慣它了。

所以,先別想太多,嘗試習慣使用它們吧!

留言

這個網誌中的熱門文章

有趣的邏輯問題:是誰在說謊

Web 技術中的 Session 是什麼?

淺談 USB 通訊架構之定義(二)

淺談 USB 通訊架構之定義(一)

Reverse SSH Tunnel 反向打洞實錄