iT邦幫忙

2021 iThome 鐵人賽

DAY 2
3
Modern Web

Javascript 從寫對到寫好系列 第 2

Day 2 - Array 陣列組合技 (1)

前言

寫「好」程式並不像是一本聖經,只要照著做就好,而是要不斷審視自己的周遭,有沒有什麼地方可以改進?用更有效率、更好讀的寫法?因此最好先從自己比較熟悉的東西開始。

Array 是從小陪伴各位長大的朋友(?),以我自己工作這幾年,真的是一天都離不開 array,因為它序列化的特性,配合迴圈來處理大量相似資料,是非常有效又實用的。

當然會看到這裡的朋友,應該用 array 都用到像呼吸一樣了(A之呼吸!),雖然大家語法都懂,但今天會幫大家介紹,各種實戰上會如何使用?甚至是常見的組合技。

各種 Method 的實戰

今天來看看以下這幾個 array 常用的 method:

  • forEach
  • filter
  • map
  • reduce

最後會再來比較一下,forEach 一枝獨秀 v.s. filter/map/reduce 聯合軍

✅ forEach

基本語法(完整版參考MDN)

array.forEach((element, index) => {
  // iterator
});

基本上就是 for 迴圈的好讀版本,其實 forforEach 能夠做到的事情基本上一樣,只是 for 比較像是基礎設施,可以適用各種 case,而 forEach 更像是主題式樂園,可以符合大部分的使用情況。

因此,如果某些情況用 forEach 讓你覺得有點卡,不妨試著改用 for 迴圈,基本上迴圈類能處理的都包辦了!

forEach 是 for 迴圈的好讀版本

forEach 預設會把整個迴圈每個項目都跑一次,而這也是大部分對於 array 跑迴圈的需求,比起 for 迴圈,少了一些 let i 或者 i++ 之類的指令,讓整體可讀性提升

const arr = [
    { id: 'item1', name: 'TV', price: 13500 },
    { id: 'item2', name: 'washing machine', price: 8200 },
    { id: 'item3', name: 'laptop', price: 25000 },
];
arr.forEach((element, index) => {
  console.log(element.name);
});

執行結果

TV
washing machine
laptop

✔ Continue

forEach 沒辦法使用 continue 控制邏輯,只能透過 return 做到類似 continue 的效果:

const arr = [
    { id: 'item1', name: 'TV', price: 13500 },
    { id: 'item2', name: 'washing machine', price: 8200 },
    { id: 'item3', name: 'laptop', price: 25000 },
];
arr.forEach((element, index) => {
  if (element.price < 10000) {
      return;
  }
  // 只會印出大於 10000 元的東西
  console.log(element.name);
});

執行結果

TV
laptop

✔ Break

forEach 也沒辦法使用 break,類似效果可以用 flag 記錄迴圈的狀態,但並不是真的「中斷」後續迴圈,只能「忽視」後續迴圈:

const arr = [
    { id: 'item1', name: 'TV', price: 13500 },
    { id: 'item2', name: 'washing machine', price: 8200 },
    { id: 'item3', name: 'laptop', price: 25000 },
];
let breakFlag = false;

arr.forEach((element, index) => {
  if (breakFlag) {
      return;
  }
  
  if (element.price > 10000) {
      breakFlag = true;
  }
  // 只會印出第一個大於 10000 元的東西
  console.log(element.name);
});

執行結果

TV

✅ filter

filter() 方法會建立一個經指定之函式運算後,由原陣列中通過該函式檢驗之元素所構成的新陣列。(參考 MDN)

arr.filter(callback(element => {}))

✔ 過濾掉缺漏的元素

有時候並不是 array 裡面每個元素都那麼完整,可能會缺幾個 property,而如果我們不慎存取到這些有缺漏的元素 property,就很容易產生 bug。

比如要將每個商品內的價格打八折(但不是每個商品內都有 price):

const arr = [
    { id: 'item1', name: 'TV', price: 13500 },
    { id: 'item2', name: 'washing machine' },
    { id: 'item3', name: 'laptop', price: 25000 }
];

arr.forEach(item => {
    const discountPrice = item.price * 0.8;
    console.log(`${item.name}:${discountPrice}`);
});

執行結果

TV:10800
washing machine:NaN
laptop:20000

可以加上 filter 篩選:

const arr = [
    { id: 'item1', name: 'TV', price: 13500 },
    { id: 'item2', name: 'washing machine' },
    { id: 'item3', name: 'laptop', price: 25000 }
];

arr.filter(item => Number.isFinite(item.price))
   .forEach(item => {
        const discountPrice = item.price * 0.8;
        console.log(`${item.name}:${discountPrice}`);
    });

執行結果

TV:10800
laptop:20000

注意第 7 行
如果沒使用 Number.isFinite() 會怎麼樣?
arr.filter(item => item.price)
=>
可能會導致 price 是 0 的這種情況也被篩選掉,因為 0 轉成 Boolean 會是 false。
雖然這個 case,0 打八折也是 0,不影響最後結果,但如果程式寫起來跟自己的意圖不一致,就是 buggy 的程式碼,容易在未來意想不到的時候被回馬槍。。。

✅ map

map() 方法會建立一個新的陣列,其內容為原陣列的每一個元素經由回呼函式運算後所回傳的結果之集合。(參考 MDN)

arr.map(callback(element => {}))

✔ 顯示購物清單在畫面上

Array of Object 不像字串,沒有辦法直接顯示在畫面上,所以可以透過 map 來進行「轉換」。

這邊再多引用一個 join() 的方法,用來將 array 轉成一個字串,詳細用法可以參考 MDN

const arr = [
    { id: 'item1', name: 'TV', price: 13500 },
    { id: 'item2', name: 'washing machine', price: 8200 },
    { id: 'item3', name: 'laptop', price: 25000 }
];
const displayArr = arr.map(item => item.name).join('、');

console.log(`購買項目: ${displayArr}`);

執行結果

購買項目: TV、washing machine、laptop

filtermap 兩個 method 是我個人常搭配一起使用的組合,因為 filter 會回傳經過篩選的陣列,因此可以接著使用 map 等其它 array method,將一個任務分割成兩個區塊,大幅提升可讀性

✔ React jsx render HTML

這邊私心介紹一下我常在使用的 React,因為有 jsx,可以在 js 裡面寫類似 HTML 的語法並輸出,而這時候就可以搭配 filter & map,把存在 js 的陣列經過 filter 篩選之後,在經過 map 轉換成 HTML 輸出:

const arr = [
    { id: 'item1', name: 'TV', price: 13500, vip: false },
    { id: 'item2', name: 'washing machine', price: 8200, vip: false },
    { id: 'item3', name: 'laptop', price: 25000, vip: false },
    { id: 'item4', name: 'vip product', price: 99999, vip: true },
];
const isUserVip = false;

// ...

return arr
        .filter(item => isUserVip || !item.vip)
        .map(item => (
            <div key={item.id} >
                <div>{item.name}</div>
                <div>{item.price}</div>
            </div>
        ));

✅ reduce

reduce() 方法將一個累加器及陣列中每項元素(由左至右)傳入回呼函式,將陣列化為單一值。(參考 MDN)

arr.reduce(callback[accumulator, currentValue, currentIndex, array], initialValue)

reduce 的重點在於,把整個陣列的資料,透過「累積」產生一個最終的結果,所以只要感覺陣列中的元素,前一個要跟後一個有「互動」的,最後會產生單一個結果的,就可以考慮 reduce

✔ 加總整個陣列

// 加總一般 Number
const arr = [0, 1, 2, 3, 4];
const sum = arr.reduce((prev, curr) => prev + curr, 0);
console.log(sum);

執行結果

10

✔ 加總 Array of Object

// 加總 Array of Object
const arr = [
    { id: 'item1', name: 'TV', price: 13500 },
    { id: 'item2', name: 'washing machine', price: 8200 },
    { id: 'item3', name: 'laptop', price: 25000 },
];
const sum = arr.reduce((prev, curr) => prev + curr.price, 0);
console.log(sum);

執行結果

46700

✔ Array 轉換成 Object (類似 groupby 功能)

const arr = [
    { id: 'item1', name: 'TV', price: 13500 },
    { id: 'item2', name: 'washing machine', price: 8200 },
    { id: 'item3', name: 'laptop', price: 25000 },
];
const resultObject = arr.reduce((prev, curr) => {
    prev[curr.id] = curr;
    return prev;
}, {});
console.log(resultObject);

執行結果

{
   item1: {id: "item1", name: "TV", price: 13500},
   item2: {id: "item2", name: "washing machine", price: 8200},
   item3: {id: "item3", name: "laptop", price: 25000}
}

✔ 統計 Array 重複元素數量

const arr = ['Alice', 'Bob', 'Tiff', 'Bruce', 'Alice'];
const resultObject = arr.reduce((prev, curr) => {
  if (curr in prev) {
    prev[curr]++;
  }
  else {
    prev[curr] = 1;
  }
  return prev;
}, {});
console.log(resultObject);

執行結果

{ Alice: 2, Bob: 1, Tiff: 1, Bruce: 1 }

forEach 一枝獨秀 v.s. filter/map/reduce 聯合軍

filter 用途在於「篩選
map 用途在於「轉換
reduce 用途在於「整合

有趣的是,forEach 其實可以一個函式就完成上面三個的功能。

比如要計算超過 10000 元商品的總價:

const arr = [
    { id: 'item1', name: 'TV', price: 13500 },
    { id: 'item2', name: 'washing machine', price: 8200 },
    { id: 'item3', name: 'laptop', price: 25000 },
];

// 聯合軍(filter + map + reduce)
const totalPrice = arr.filter(item => item.price > 10000)
                      .map(item => item.price)
                      .reduce((prev, curr) => prev + curr, 0);
                      
// forEach 的版本
let totalPrice = 0;
arr.forEach(item => {
    if(item.price > 10000) {
        totalPrice += item.price;
    }
});

// 以上兩個 totalPrice 都是 38500

效能 v.s. 可讀性

注意,聯合軍的版本,arr 陣列其實跑了 3 次迴圈(不過 map 跟 reduce 的迴圈比較小一點就是了),而 forEach 只跑了 1 次迴圈。

❓ 看起來 forEach 光靠一個 function 就搞定,甚至連效率都比聯合軍高,那幹嘛還要有其它 method 啊?都給 forEach 玩就好了啊?

原因是,上面的例子只是為了快速理解兩邊的差異,告訴大家其實 forEach 可以做的事情跟聯合軍一樣,但真實在職場上,你可能會遇到類似這樣的東西,不妨試試,能不能短時間看懂這段邏輯:

const arr = [
    { id: 'item1', name: 'TV', price: 13500, vip: false, discount: 0.15 },
    { id: 'item2', name: 'washing machine', price: 8200, vip: false, discount: 0.1 },
    { id: 'item3', name: 'laptop', price: 25000, vip: false, discount: 0.12 },
    { id: 'item4', name: 'vip product', price: 99999, vip: true, discount: 0.3 },
];
const isUserVip = true;
                      
// forEach 的版本
let totalPrice = 0;
arr.forEach(item => {
    if((isUserVip || !item.vip) && item.price > 10000) {
        if(isUserVip) {
            totalPrice += item.price * (1 - item.discount);
        } else {
            totalPrice += item.price;
        }
    }
});

// 聯合軍(filter + map + reduce)
const totalPrice = arr
.filter(item => (isUserVip || !item.vip) && item.price > 10000)
.map(item => {
    if(isUserVip) {
        return item.price * (1 - item.discount);
    } else {
        return item.price;
    }
})
.reduce((prev, curr) => {
    return prev + curr;
}, 0);

上面這段 code 的目的是:「根據 user 是否為 vip,算出其身分可購買且大於 10000 元的所有商品各自打折後的總價」

雖然 forEach 的 code 很醜,啊聯合軍也沒比較簡潔啊!

Trade-off

是的,如果論程式碼長度來說,的確輸了一截,但同時也發現,程式碼從一整坨,被切成三段,分別處理了「篩選」、「轉換」、「整合」,分工合作,清楚明確,以可讀性來說,如果很清楚 filtermapreduce 各自的用途,就很容易分段讀懂整段 code。

但反過來說,當資料量愈龐大,這種「聯合軍」的寫法會跑愈慢,因為比起 forEach 只跑一次迴圈,聯合軍跑了三次,資料愈多差異愈大。

因此我認為這是一個 trade-off,需要根據團隊習慣、效能或環境需求,自行判斷要採用哪一種寫法。如果資料量不大,其實很推薦使用聯合軍的寫法,因為起碼我要看懂別人寫 forEach,真的是需要花比較多時間QQ

結語

我們非常熟悉的陣列,也有著許多平常沒用過的寫法,但每個 method 各有各自擅長的項目,在對的時間使用對的 method,在可讀性與效能上取得平衡,是讓程式碼邁向更「好」的第一步!

天平的兩端
藏著各自的風景
寫著各自的故事

參考資料

Array MDN


上一篇
Day 1 - 寫「好」程式是什麼樣的感覺?
下一篇
Day 3 - Array 陣列組合技 (2)
系列文
Javascript 從寫對到寫好30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

2 則留言

0
TD
iT邦新手 4 級 ‧ 2021-09-18 10:18:18

我是 reduce 派 XD

const totalPrice = arr.reduce((acc, item) => {
    if((isUserVip || !item.vip) && item.price > 10000) {
      acc = isUserVip
            ? acc + item.price * (1 - item.discount)
            : acc + item.price
    }
    return acc
}, 0)

如果 forEach 裡面的處理邏輯變複雜的話,我想可以把邏輯拉出來,拆分、封裝成不同的 functions 之後再組合回去,也許可讀性會變好一點,譬如

const isValid = (item) => (isUserVip || !item.vip) && item.price > 10000
const sumPriceForVip = (item) => totalPrice += item.price * (1 - item.discount)
const sunPriceForNonVip = (item) => totalPrice += item.price

arr.forEach(item => {
  if(isValid(item)) {
    isUserVip ? sumPriceForVip(item) : sunPriceForNonVip(item)
  }
});

(以上只是舉例,以這個簡單的 case 不用硬拆成這樣 :p)

看更多先前的回應...收起先前的回應...
ycchiuuuu iT邦新手 4 級 ‧ 2021-09-18 11:03:47 檢舉

OMG!我未來的某一個主題直接被你預測掉了XDDD 真的厲害

將 function 拆出來也是我滿喜歡做的事情(雖然我是命名苦手...),在提升共用性也有大加分!謝謝你幫忙舉例,驗證了程式優化絕對不只一條路~

TD iT邦新手 4 級 ‧ 2021-09-18 11:10:32 檢舉

ycchiuuuu 原來後面會提到 > < 只好加倍期待一下了哈哈

命名有時候真的很讓人困擾呢(這也會是未來的主題嗎 XD)

ycchiuuuu iT邦新手 4 級 ‧ 2021-09-18 11:35:47 檢舉

命名會不會獨立一個主題還不確定,但肯定是要花個半篇以上來講的,因為這也是我的痛點XD

動不動就會寫出 fetchCustomizedCategoriesIncludeOther 這種獵奇的 function 名字。。。

Len iT邦新手 5 級 ‧ 2021-09-27 15:59:54 檢舉

喜歡拆function,這樣寫測試也好寫,不會全部都綁在一起~~~

0
williamyeh
iT邦新手 5 級 ‧ 2021-11-28 09:36:13

[問] JavaScript 的 functional programming 設施,是否有 lazy evaluation 機制?

ycchiuuuu iT邦新手 4 級 ‧ 2021-11-29 12:59:50 檢舉

嗨你好,可以透過 Lazy.jsimmutable.js 等套件來輕鬆做到

因為 lazy evaluation 需要確保每一步驟都是 immutable,所以如果用我們熟知的原生寫法比較難辦到,引入套件來處理是一個比較方便的方式唷!

我要留言

立即登入留言