---
# System prepended metadata

title: 實作紀錄：星冰樂飲品選購頁 Demo
tags: [HTML, CSS, JavaScript]

---

# 實作紀錄：星冰樂飲品選購頁 Demo

###### tags: `JavaScript` `CSS`  `HTML`

## 實作網址
gh-pages
https://judy-nihao.github.io/starbucks-demo/

GitHub Repo
https://github.com/Judy-Nihao/starbucks-demo

## 實作功能
- 主宣傳頁點擊飲料小圖同步切換大圖
- 購物車計算
- 商品加入同步購物車icon數量更新
- 商品加入即時渲染購物車品項
- 購物車內商品可修改數量＋即時計算總金額
- 跨分頁點擊仍保持購物車內容紀錄
- 送出訂單即清空購物車並出現提示文字
- 未加入任何商品就按送出訂單會出現提示文字
- RWD響應式版面調整：footer 手機版時轉化為手風琴折疊版面

![](https://hackmd.io/_uploads/BysHko4Ih.jpg)
![](https://hackmd.io/_uploads/HJf6XeS82.jpg)


## 筆記


基本邏輯，有 2 份陣列，「產品清單」和「購物車清單」。

頁面上的產品是透過「產品清單陣列」渲染出來的，

而「購物車清單陣列」的渲染，則是看點擊到哪項產品，就將該產品加入至「購物車清單陣列」。

關鍵在於，「產品清單」要保持初始狀態，內容與值全部都是固定的，

會有變化的只有「購物車清單」，因為購物車會做數量的加減，計算總金額。


### 將產品清單渲染在畫面上
產品共 9 個，陣列中定義個別 id 、 產品名稱、圖像路徑、價格與數量。
這邊要稍微留意產品 id 從 1 號開始編號，但是陣列索引是從 0 開始算。

```javascript!
const products = [
  {
    id: 1,
    name: "桃子晶凍星冰樂",
    image: "flappuccino01.png",
    price: 155,
    quantity:1
  },
  ...(中略)
  {
    id: 9,
    name: "綿滑甜薯星冰樂",
    image: "flappuccino09.png",
    price: 130,
    quantity:1
  },
]
```

讓產品清單陣列跑`forEach()`渲染出 HTML 結構，

在產品按鈕上直接綁定 `addToCart()` 函式，並帶入該產品的索引值，

一點擊「加入購物車」按鈕，

就會比對產品清單陣列，抓到相同索引值的產品物件，將之加入購物車清單陣列之中。

```javascript!

const productWrapper = document.querySelector(".menu__content__list__wrapper");

function showProduct(){
  products.forEach(function(item, index){
      
       let str = `
      <div class="menu__content__list__card">
          <div class="card__img"><img src="assets/images/${item.image}" alt="星冰樂照片"></div>
          <h2>${item.name}</h2>
          <p class="price">NT$<span>${item.price}</span></p>
          <button class="addToCart" ontouchstart onclick="addToCart(${index})"><i class='bx bx-plus-circle'></i>加入購物車</button>
      </div>
      `;
      
      if(productWrapper){productWrapper.innerHTML += str};      
  })

}

showProduct();
```

一開始的那條 if 判斷式，
```javascript!
if(productWrapper){productWrapper.innerHTML += str}
```

意思是，先判斷畫面上有沒有`productWrapper`這個元素存在，

若有此元素存在，才把產品清單渲染上去。

因為 `productWrapper` 元素只存在於「菜單」分頁之中，如果不加入這個 if 判斷式，切換到其他分頁時會因為找不到元素可以渲染產品清單而報錯。
 
![](https://hackmd.io/_uploads/HJsHVoVL2.png)




### 加入購物車的判斷

**不重複加入商品**
建立一個新物件`newObj`，物件內容是透過「深拷貝」產品清單陣列的物件而得，並且每次點擊加入購物車，就比對此商品物件 id 和購物車陣列內的產品物件 id，有沒有重複？沒有重複才可以加入購物車。

```javascript!
function addToCart(index){
    //先深拷貝物件，才不會影響到原始 products 陣列物件
    let newObj = JSON.parse(JSON.stringify(products[index]));

    //把購物車目前所有品項的 id 抽出來做一個陣列
    let mapResult = shoppingList.map(function(obj){
      return obj.id;
  }) 
    // 比對購物車 id 陣列和 新加入的商品的 id 有沒有重複
    if(!mapResult.includes(newObj.id)){
      shoppingList.push(newObj);
    }
    
    reloadShoppingCard();
};

```

**三種陣列方法**

1. 用`map()` 方法提取購物車陣列內所有物件的 id ，組成一個新的 id 陣列 `mapResult`。
2. 用`includes()` 方法檢查`mapResult`陣列內，是否含有`newObj`的 id。`includes()` 方法會回傳布林值，true or false。只有當 `mapResult.includes(newObj.id)` 結果是 false 時，才加入清單，所以要在 if 條件括號內，添加一個驚嘆號，將 false 轉成 true ，才去執行加入購物車的步驟。
3. 用`push()` 方法將`newObj`加入購物車陣列。


看似沒幾行的原始碼，其實經過一段試錯，最後才梳理出合乎邏輯不會報錯的寫法，以下列出過程中撞牆的幾點省思。

### 陣列賦值的蹊蹺01：陣列的長度計算
一開始我想的很簡單，我希望所有品項的順序都固定排好，產品清單索引0的物件，就直接對照存進購物車清單索引0的物件，所以寫成：

```javascript
// 如果購物車清單是空的就加進去
if(shoppingList[index] == null){
shoppingList[index] = products[index]; 
```

如果是照順序將`id:1`、`id:2`......`id:9`產品加入，看不出問題。

但如果一開始就加入購物車的是`id:9`的產品，印出購物車陣列後會發現，明明只加入 1 項物件，陣列長度卻顯示為 9，陣列狀態顯示為：

```javascript!
[empty × 8, {…}]
```

前面出現 8 個 empty，表示這個寫法怪怪的。

`id:9` 的產品表示他是 `products[8]`，被加入購物車後，指定放在購物車陣列「索引值8」的位置也就是`shoppingList[8]` ，陣列因而產生出索引值 0~7 的空位。這樣會導致陣列的長度計算出現錯誤，明明只有1個物件，卻回傳長度為9。

要避免產生 empty ，只要用最基礎的`push()`方法，把物件存進陣列即可，push 一個物件，陣列長度就是 1，push 兩個物件，陣列長度就是 2 ，以此類推。

```javascript!
shoppingList.push(newObj);
```


### 陣列賦值的蹊蹺02：淺拷貝與深拷貝

把 B 陣列的物件直接複製給 A 陣列，會讓複製出來物件的值，跟原本陣列物件的值，互相產生影響。

導致後面要計算金額時出現錯誤。

例如這個狀況：購物車要根據「修改後的數量」 * 「產品原始價格」計算購物總金額

```javascript!

shoppingList[index].price = quantityChanged * products[index].price;

```

`shoppingList[index].price` 是購物車中，該項產品數量增減後的產品購買總金額，

我希望拿改變過的數量 * 產品清單初始價格，算出購買總金額。

例如：桃子星冰樂定價 1 杯 150元，數量改 3 杯，預期結果是 3 * 150  = 450。

然而實際計算時發現，第一次計算，數量從 1 變成 3，得到 450 元的結果，很正常，

但接續計算，從 3 杯 改回 2 杯後，照理來說金額該是：

```
2 * 150 = 300
```

實際計算出來卻變成

```
2 * 450 = 900
```

印出產品清單陣列檢查，會發現陣列內初始價格 ` products[index].price` 被改變了！

價格已經被改成上次加乘後的結果，所以下次再計算時，價格就會越乘越大。

會發生這種狀況，就是因為當初複製陣列時的寫法，是「淺拷貝」：


```
shoppingList[index] = products[index]
```

[參考：JS 中的淺拷貝 (Shallow copy) 與深拷貝 (Deep copy) 原理與實作](https://www.programfarmer.com/articles/2021/javascript-shallow-copy-deep-copy)
> 相對地，Object data 則是僅複製地址(address)，因此值可能互相影響，這種複製出的變數與原來的變數間會互相影響的複製方法，稱為淺拷貝(shallow copy)。

> 當 Original Object data 與 Cloned Object data 中，有任何一層的資料地址相同，背後指向的值相同，兩個物件的操作會互相影響，就為淺拷貝(shallow copy)。

要斷開原始陣列和克隆陣列之間的連結，

就是要用「深拷貝」複製物件，以確保複製後的是「獨立新物件」。

透過轉譯兩次可以達到「深拷貝」：

1. 用 `JSON.stringify` 先把物件轉字串，再用 `JSON.parse` 把字串轉物件。
2. 再用 `push()` 方法把物件 push 存進去新陣列中。

```javascript!
let newObj = JSON.parse(JSON.stringify(products[index]));
shoppingList.push(newObj);
```

當時在這邊卡很久，不明白為什麼簡單的乘法計算，結果卻越來越離譜，爬文後才發現是自己當初陣列複製時，無意間採用的淺拷貝寫法在搞鬼。


### 切換頁面仍保持購物車資料：localStorage 

購物車目前只在「菜單」分頁上存在，只要一切換網站內的分頁，購物車就會被清空。

我希望即使切換分頁，購物車也能保留選購資料，模擬消費者在瀏覽網頁消費時的行為，除非自己刪除品項或是送出訂單，否則購物車的品項應該要被記錄著。

要保留的資料有 2 處，

1. 購物車清單陣列
2. 購物車 icon 上面的數字


![](https://hackmd.io/_uploads/rk4ArerU3.jpg)

![](https://hackmd.io/_uploads/B1KplRVLn.png)

保留的意思，其實是保存當下陣列狀態或是當下的選購數字，切換分頁後，再次渲染在購物車畫面上，看起來就像是沒有變一樣。

使用 2 組 `localStorage.setItem` 和 `localStorage.getItem` 分別紀錄購物車清單陣列，和購物車 icon 數字。

保存資料時，要注意 localStorage 只能存進去純字串，所以購物車陣列存進去時，

要先透過 `JSON.stringify` 字串化，取出來要炫染畫面時，再透過 `JSON.parse()` 物件化。

localStorage 格式如下，其中的 key 是自訂的名稱，可以取任何名字。

```
localStorage.setItem(key, value)
```

**setItem**

儲存用的函式定義好之後，就在每次渲染購物車時執行一次，

把 `saveData()` 和 `saveQ()` 放在 `reloadShoppingCard()` 函式內的最後面，

每次購物車有任何渲染變動，就儲存當下陣列狀態。


```javascript!
function saveData(){
  localStorage.setItem("list", JSON.stringify(shoppingList));
}
```

```javascript!
// 這裡的 quantity變數是指購物車 icon 右上角的數字
function saveQ(){
  localStorage.setItem("counting", quantity.textContent);
}
```

**getItem**

取出資料則是先物件後，把資料存進陣列中，再渲染在畫面上。

但這邊要注意，必須先判斷 localStorage 目前有沒有資料在裡面，

比如：購物車若被清空，localStorage 沒有資料，卻要求他要取資料渲染，就會報錯。

所以先透過 if 條件判斷，只有當 localStorage 內有儲存東西才渲染，如果沒東西就不會執行後續步驟。

```javascript!
function showTask(){
    if(localStorage.getItem("list") !== null){
      shoppingList = JSON.parse(localStorage.getItem("list"));
      reloadShoppingCard();
    }
}

showTask(); // 記得一開始就要先呼叫
```

購物車 icon 也是要先判斷，icon 右上方的數字初始值是 0，最少一定會顯示 0 ，

- 如果購物數量增減後大於 0，就去 localStorage 裡面抓購物車最新產品數量，炫染數字出來。
- 如果購物車被清空了，產品數量等於 0 ，就顯示 0 。

如果沒有給這條判斷，購物車清空後，會因為抓不到資料，使右上角的數字消失，連 0 都不顯示，畫面不合理。

```javascript!
function showQ(){
  if(parseInt(quantity.textContent)>0){
    quantity.textContent =  localStorage.getItem("counting");
  }else if(parseInt(quantity.textContent) == 0){
    quantity.textContent = "0";
  }
}

showQ(); // 記得一開始就要先呼叫
```


### 行動版的調整：iPad 對於 click 事件沒反應要換成 touch 事件

為了增加使用者選購時的互動感受，「加入購物車」按鈕有添加`:active`狀態樣式，

點擊下去時會放大一下下，視覺上才有點到按鈕的感覺。

```css!
.addToCart:active{
            transform: scale(1.15);
        }
```

可是使用 iPad 查看時，發現點擊下去的時，按鈕沒有變化，一開始以為是不是 `transform` 這個樣式寫法少了前綴？還是其他地方有問題。

爬文之後發現`:active`這個偽類，在 iOS 行動裝置上必須要用 touch 事件才會觸發，

[:active pseudo-class doesn't work in mobile safari](https://stackoverflow.com/questions/3885018/active-pseudo-class-doesnt-work-in-mobile-safari)
> On iOS, mouse events are sent so quickly that the down or active state is never received. 
Therefore, the :active pseudo state is triggered only when there is a touch event set on the HTML element

所以在一開始的產品清單 HTML 渲染上，每一個「加入購物車」按鈕上面都添加一個`ontouchstart`屬性，等於告訴瀏覽器這個按鈕要監聽 touchStart 事件。

```htmlembedded!
<button class="addToCart" ontouchstart onclick="addToCart(${index})">
```

加上 touchStar 事件後再操作 iPad 點擊就能正常出現按鈕縮放效果了。
