iT邦幫忙

2021 iThome 鐵人賽

DAY 10
2
Modern Web

Javascript 從寫對到寫好系列 第 10

Day 10 - Functional Programming 初探 (3) - 實戰購物車流程

前言

這兩天花了滿多心力在介紹 FP 的觀念跟方法,但其實大部分都停留在理論,或者教科書上的那種 apple、banana 的練習,大家都是在職場上走跳的,把理論轉為實務是必要的能力。

所以,今天我們試著結合前兩天講的東西,今天來寫一些實戰練習吧!

實戰 - 購物車流程

FP 最適合做那種「一連串」、「一個挨著一個」的程式,所以通常是有流程的,先做 A 再做 B 再做 C。

因此,我們來做一個購物車,模擬一個購物的流程,當然電商是很複雜的,我們只實作幾個主要的基本功能:

  1. 把商品加入購物車
  2. 商品價格打折
  3. 購物車結帳
  4. 清空購物車

先定義生產線上的資料

雖然已經定義出流程,但現在腦中就是一片空白,到底要從什麼東西開始寫啊?

首先要先定義「在生產線上跑」的資料,比如在水管這個生產線,「水」就是我們的資料;在廚房這個生產線,「青菜」就是我們的資料。

我們定義了:

const pipe = (f, g) => (...args) => g(f(...args));

const user = {
  name: 'Joey',
  cart: [],
  purchases: []
};

const item = {
    name: 'TV', 
    price: 24000
};

首先是 pipe,如果忘記他在幹嘛,可以參考 Day 9 - Functional Programming 初探 (2),就記得它是用來組合積木、組合函式用的,把裡面的所有函式組成一個生產線,而且是由左到右的順序。

接著是 user,我們定義一個用來代表使用者的物件,裡面的 cart 陣列代表購物車,而 purchases 陣列代表購買的項目。

最後是 item,代表我們要購買的物件。

從最小的積木開始

接著就要開始寫 FP 啦,FP 的最小單位是 function,而且每個 function 做的事情都盡可能少,專業分工,最後再透過 pipe 組合起來就可以了。

首先來寫「把商品加入購物車」:

const addItemToCart = (user, item) => {
    return { ...user, cart: [...user.cart, item] };
};

接著要結帳了,先幫「商品價格打折」:

const applyDiscountToItems = (user) => {
    const discount = 0.8;
    const updatedCart = user.cart.map(item => {
        return {
          name: item.name,
          price: item.price * discount
        }
    });
    return { ...user, cart: updatedCart };
};

然後是「購物車結帳」,其實不會放進陣列就真的結帳開始出貨啦,不過那些與後端串接的邏輯就先省略了:

const buyItem = (user) => {
  return { ...user, purchases: [...user.cart] };
};

最後是「清空購物車」:

const emptyUserCart = (user) => {
  return { ...user, cart: [] };
};

把積木組裝成生產線

有了上述四個積木,接著開始就是這三天鍛鍊下來的精華了!要來驗收囉!

首先,為了避免一開始就越級打怪,我們前面宣告的 pipe 只能夠接受 2 個 function 組合,所以我們先從前面 2 個開始:

  1. 把商品加入購物車
  2. 商品價格打折
const pipe = (f, g) => (...args) => g(f(...args));
const user = {
  name: 'Joey',
  cart: [],
  purchases: []
};
const item = {
    name: 'TV', 
    price: 24000
};

const addItemToCart = (user, item) => {
  return { ...user, cart: [...user.cart, item] };
};

const applyDiscountToItems = (user) => {
    const discount = 0.8;
    const updatedCart = user.cart.map(item => {
        return {
          name: item.name,
          price: item.price * discount
        }
    });
    return { ...user, cart: updatedCart };
};

const newUser = pipe(addItemToCart, applyDiscountToItems)(user, item);
console.log(newUser);

執行結果

{
    name: 'Joey',
    cart: [{
        name: 'TV', 
        price: 19200
    }],
    purchases: []
};

真的寫起來還是覺得很神奇,透過小 function 的組合出大 function,接著丟參數進去就跑出答案了,是不是很像數學在寫 f(x),還有 f(x)。g(x) 的感覺呢?

那現在問題來了,雖然兩個 function 成功組合了,但總共有四個啊!

如何組合多個 function

最直覺的想法是再用一次 pipe:

const pipe = (f, g) => (...args) => g(f(...args));

// ... 中間省略,同上一個範例

const buyItem = (user) => {
  return { ...user, purchases: [...user.cart] };
};

const emptyUserCart = (user) => {
  return { ...user, cart: [] };
};

const newUser = pipe(addItemToCart, applyDiscountToItems)(user, item);
const newUser2 = pipe(buyItem, emptyUserCart)(newUser);

console.log(newUser2);

執行結果

{
    name: 'Joey',
    cart: [],
    purchases: [{
        name: 'TV', 
        price: 19200
    }]
};

嗯。。。雖然答案正確,但看起來很怪對不對?

好像明明只要一條生產線一次做完的事情,卻硬是切成兩條生產線,等第一條完成接著做第二條,非常多此一舉。

接受多個 function 的 pipe

所以我們試著改良一下生產線 pipe:

const pipe = (f, g) => (...args) => g(f(...args));
const newPipe = (...fns) => fns.reduce(pipe);

// 上面是比較好讀的版本,你也可以合併成一個:
const pipe = (...fns) => fns.reduce((f, g) => (...args) => g(f(...args)));

可以看到 newPipe 的部分,它是一個 function,參數 ...fns 代表可以帶多個參數(逗點分開)進去,fns 進到函式內之後就會變成一個陣列(裡面存著你丟進來的多個參數)。

還記得 reduce 代表什麼嗎?它負責「整合」,目的是要把我們帶進去的多個參數整合,變成一個統一的生產線。

因此做完上述的改良,我們從原本只能組合 2 個 function,變成你帶幾個 function 進來,全都給你組成一條生產線。

最終版本

所以我們的最終版本就出來了:

const pipe = (f, g) => (...args) => g(f(...args));
const newPipe = (...fns) => fns.reduce(pipe);
const user = {
  name: 'Joey',
  cart: [],
  purchases: []
};
const item = {
    name: 'TV', 
    price: 24000
};

const addItemToCart = (user, item) => {
  return { ...user, cart: [...user.cart, item] };
};

const applyDiscountToItems = (user) => {
    const discount = 0.8;
    const updatedCart = user.cart.map(item => {
        return {
          name: item.name,
          price: item.price * discount
        }
    });
    return { ...user, cart: updatedCart };
};

const buyItem = (user) => {
  return { ...user, purchases: [...user.cart] };
};

const emptyUserCart = (user) => {
  return { ...user, cart: [] };
};

const newUser = newPipe(
    addItemToCart, 
    applyDiscountToItems, 
    buyItem, 
    emptyUserCart
)(user, item);

console.log(newUser);

執行結果

{
    name: 'Joey',
    cart: [],
    purchases: [{
        name: 'TV', 
        price: 19200
    }]
};

此時 FP 的好處立刻就出現了!

假如今天突然不想打折了,可以把 applyDiscountToItems 直接抽掉,程式立刻就按照你的意圖下去執行。

或者清空購物車之後還想做其它的事(比如返回首頁),就可以自己再寫個積木,然後放到 emptyUserCart 的後面即可,完全不用動到前面寫的函式們。

注意哦!這只是我們組出來的其中一種 pipe 而已,只要你手上有積木(function),而且每個積木都專業分工,就可以用各種方式去組合它,有無限多可能啊!

感覺像樂高廣告

結語

FP 不是 Javascript 專屬的東西,而且已經非常有歷史了,因此網路上累積的學習資源非常豐富,這邊只用三天來討論完全是冰山一角,但我想足以讓完全沒概念的新手,有個初步對於 FP 的想像與理解。

我相信應該還是很多人看完通篇,仍然不知道 FP 在幹嘛,就跟我第一次學的時候一樣QQ,歡迎留言寫下你的疑惑,也許也是其它人的疑惑~

我相信學習不會有白費的過程,而是循序漸進的,即便不能一次讀懂,下次再讀到 FP 時,也就更有底氣了!

在寂靜與黑暗之中
閉上眼
已到了蟲洞外的新世界

參考資料

Will - 理解函式編程核心概念與如何進行 JavaScript 函式編程
Les Lee - Functional Programming 一文到底全紀錄


上一篇
Day 9 - Functional Programming 初探 (2) - Currying 與 Composition
下一篇
Day 11 - OOP 初探 (1) - Closures 與繼承鏈
系列文
Javascript 從寫對到寫好30
0
pjchender
iT邦新手 4 級 ‧ 2021-09-28 08:46:27

newpipe 超讚的,而且可以這樣抽換 function 的彈性也很大(開放封閉原則)。

ycchiuuuu iT邦新手 5 級 ‧ 2021-09-29 13:01:54 檢舉

可以抽來抽去真的超好用的!真的像在組積木一樣~

0
TD
iT邦新手 5 級 ‧ 2021-09-28 09:52:54

看到 newpipe 的實作真是讓人感動,這就是我平常在用的 pipe 啊~~ 謝謝分享!

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

newPipe 第一次看到真的是看不懂,看懂了也不知道可以用來做什麼,真的是要自己來寫寫看才知道,我的收穫也很大!

0
Jen
iT邦新手 5 級 ‧ 2021-09-28 20:47:15

覺得今天看完聽完FP這個主題,觀念又更清楚了/images/emoticon/emoticon34.gif

ycchiuuuu iT邦新手 5 級 ‧ 2021-09-29 13:00:45 檢舉

哈哈因為公司的緣故,你是難得有看到又有聽到的XD

我要留言

立即登入留言