上輩子福份不夠,這輩子碰到奧客要抓住機會累積一下?
一筆大量訂單,總是會有不同的糖冰與品項組合,比如同樣的紅茶要半糖微冰與全糖去冰各一杯,今天要試著將品項的不同糖冰組合放到訂單清單內。
對於飲料訂單,可以預期有品項、單價、杯數與規格(糖冰),但在陣列中,還需要一個可以識別的固定編號,才能在修改的時候找到對應的資料,依照這樣的想法建立型別與陣列:
// 型別放元件外面
type DrinkSpec = {
sweetness: "全糖" | "半糖" | "無糖";
ice: "正常冰" | "少冰" | "去冰";
};
type OrderItem = {
id: string;
name: string;
price: number;
quantity: number;
spec: DrinkSpec;
};
// useState 放 DrinkOrder 的最上層。
const [orders, setOrders] = useState<OrderItem[]>([]);
DrinkSpec 是昨天建立的規格,OrderItem 描述一筆訂單項目。
OrderItem[] 表示由這種項目組成的陣列,後面的 [] 則是初始的空清單。
假設今天有兩筆不同規格與數量的品項 A 和 B:
| 編號 | 飲料 | 單價 | 杯數 | 規格 |
|---|---|---|---|---|
| A | 紅茶 | 30 元 | 2 | 半糖、少冰 |
| B | 紅茶 | 30 元 | 1 | 無糖、去冰 |
orders 裡面的元素代表的是,不同規格與數量的品項,例如表格中的紅茶有 2 個規格組合,A 組合有 2 杯,B 組合有 1 杯,總共有 3 杯。
昨天已經學過展開語法,對於陣列也可以使用展開語法,但一樣要注意 淺拷貝(shallow copy) 問題。對陣列直接使用 [...orders],得到的會是新的外層陣列,裡面的項目仍可能連到同一個物件。
測試看看:
{
const original = [
{ id: "a", quantity: 2, spec: { sweetness: "半糖", ice: "少冰" } },
{ id: "b", quantity: 1, spec: { sweetness: "全糖", ice: "正常冰" } },
];
const copied = [...original]; // 淺拷貝
console.log(Object.is(original, copied)); // false
console.log(Object.is(original[0], copied[0])); // true
console.log(Object.is(original[0].spec, copied[0].spec)); // true
copied[0].spec.sweetness = "無糖";
console.log(original[0].spec.sweetness); // 無糖
}
上面的測試可以看出 original 和 copied 是不同陣列,但它們的第一筆項目仍是同一個物件,裡面的 spec 也相同。因此如果從 copied 修改甜度,original 讀到的也變成無糖。
這跟昨天的巢狀物件是同一件事,只是外面又多了一層陣列,新陣列不代表新項目,更不表示內層規格也已經複製好了。
如果要複製指定的內層項目可以利用 map():
{
const original = [
{ id: "a", quantity: 2, spec: { sweetness: "半糖", ice: "少冰" } },
{ id: "b", quantity: 1, spec: { sweetness: "全糖", ice: "正常冰" } },
];
const next = original.map((item) =>
item.id === "a" // 當 id 是 a,建立新的項目放到新陣列內
? { ...item, spec: { ...item.spec, sweetness: "無糖" } }
: item
);
console.log(Object.is(original, next)); // false
console.log(Object.is(original[0], next[0])); // false
console.log(Object.is(original[0].spec, next[0].spec)); // false
console.log(Object.is(original[1], next[1])); // true
console.log(original[0].spec.sweetness, next[0].spec.sweetness); // 半糖 無糖
}
上面這段分別建立了新的 spec、新的第一筆項目,以及新的陣列,第二筆沒有修改,可以繼續共用,但前提是之後也把它當成唯讀資料。
| 修改 | 動作 |
|---|---|
第一筆的 spec |
建立新規格,保留少冰、改成無糖 |
| 第一筆項目 | 建立新項目,保留編號與杯數,連到新規格 |
| 外層陣列 | 建立新陣列,放入更新後的第一筆 |
| 第二筆項目 | 沒有修改,沿用原物件 |
這裡一樣沒有將整份陣列都深拷貝,而是只針對需要修改的項目建立新物件,其他沒修改的共用並唯讀。
理解淺拷貝後,接著來更新清單,首先比較各種操作會用到的方法:
| 需求 | 動作 | 避免做 |
|---|---|---|
| 新增 | 展開陣列,再接上新項目 | push()、unshift() |
| 刪除 | filter() 留下其他項目 |
pop()、shift()、splice() |
| 修改 | map() 搭配新物件 |
直接改 item 或指定 orders[i] |
| 清空 | setOrders([]) |
把原陣列的 length 設成 0 |
要注意的是
slice()和splice()拼寫很像,但slice()會取得一段淺拷貝,splice()會修改原陣列。
MDN:slice、MDN:splice
建立一個新增品項到訂單的函式 handleAddToOrder 放在 DrinkCounter 裡,onAddOrder 負責通知外層元件:
function handleAddToOrder() {
if (quantity === 0) return;
const newItem: OrderItem = {
id: crypto.randomUUID(),
name,
price,
quantity,
spec: { ...spec },
};
onAddOrder(newItem);
setQuantity(0);
}
上面函式做了三件事:
spec: { ...spec } 替新項目建立一份規格物件,把它與編輯中的規格分開。crypto.randomUUID() 產生這筆項目的識別字串,只在加入事件裡產生一次。外層元件 DrinkOrder 接到這一筆新增後,才更新清單:
function handleAddOrder(newItem: OrderItem) {
setOrders((prevOrders) => [...prevOrders, newItem]);
}
prevOrders 是這裡收到的待更新清單,把既有項目展開,再放入 newItem,就會得到新的陣列,舊清單仍然保留原本的項目與長度。
updater 更新依據來自參數,函式只計算下一份資料,不修改 prevOrders,ID 已經在加入事件裡產生,不需要放進 updater 裡重新產生。
那麼事件怎麼接呢?
首先替 DrinkCounterProps 增加一個欄位,DrinkCounter 的參數也要解構出 onAddOrder:
type DrinkCounterProps = {
name: string;
price: number;
onAddOrder: (item: OrderItem) => void // 表示在 prop 放入函式
};
接著在 DrinkOrder 的 JSX 把函式傳下去,紅茶與綠茶都使用同一個處理函式:
<DrinkCounter name="紅茶" price={30} onAddOrder={handleAddOrder} />
<DrinkCounter name="綠茶" price={25} onAddOrder={handleAddOrder} />
DrinkCounter 使用 state 記住杯數與規格,按下加入後透過 onAddOrder 通知 DrinkOrder 更新清單
接著來看刪除,從畫面往回思考功能,通常會是點選一筆資料後按下刪除。通過按鈕傳入 item.id,由 filter 來排除項目:
// 放在 DrinkOrder 裡。
function handleDeleteOrder(id: string) {
setOrders((prevOrders) => prevOrders.filter((item) => item.id !== id));
}
filter() 的條件描述的是「哪些要留下」,item.id !== id 留下編號不同的項目,把指定的那一筆排除,回傳新陣列,原本的陣列不會被刪改。
這裡很容易把條件寫反。如果使用 ===,就只留下原本要刪除的那一筆。也不能改用飲料名稱判斷,因為半糖紅茶和無糖紅茶可能都在清單裡,而我們一次只需要刪掉其中一筆。
當我們刪掉第一筆後,其他項目的位置會往前,但 ID 不用跟著重新編號,我們前面採用了 uuid,而刪除依賴 item.id,達到 項目排第幾個可以變,身分仍然保留。
要幫某一筆加一杯,不用刪除再新增,可以用 map() 逐筆判斷,遇到指定編號時,回傳更新後的項目:
// 放在 DrinkOrder 裡,amount 使用 1 或 -1。
function handleChangeQuantity(id: string, amount: number) {
setOrders((prevOrders) =>
prevOrders.map((item) =>
item.id === id // 指定編號要修改數量,新建項目後回傳更新的內容
? { ...item, quantity: Math.max(item.quantity + amount, 1) }
: item
)
);
}
編號相同時展開原項目,再覆蓋新的杯數,編號不同時直接回傳原項目。這樣會得到新陣列,但只有要修改的那筆會變成新物件。
修改數量的單筆下限為一杯,使用 Math.max(..., 1),若要完全移除就按刪除。
同樣的概念拿來更新資料規格,下面修改 spec.sweetness:
// 放在 DrinkOrder 裡。
function handleOrderSugarFree(id: string) {
setOrders((prevOrders) =>
prevOrders.map((item) =>
item.id === id
? { ...item, spec: { ...item.spec, sweetness: "無糖" } }
: item
)
);
}
最裡面先建立新的 spec,保留冰塊、覆蓋甜度;外面建立新項目,保留編號、飲料與杯數,map() 再把這些項目放進新陣列。
如果只寫 { ...item } 然後直接修改 item.spec.sweetness,會改到共用的規格。要保留舊資料,就得把這次修改經過的三層都處理好。
觀念看過之後,把按鈕和清單接進畫面吧,完整版都放下面了,直接替換 drink-order.tsx。
import { useState } from "react";
import { Link } from "react-router";
type DrinkSpec = {
sweetness: "全糖" | "半糖" | "無糖";
ice: "正常冰" | "少冰" | "去冰";
};
type OrderItem = {
id: string;
name: string;
price: number;
quantity: number;
spec: DrinkSpec;
};
type DrinkNameProps = {
name: string;
};
function DrinkName({ name }: DrinkNameProps) {
return <h2>{name}</h2>;
}
type DrinkCounterProps = {
name: string;
price: number;
onAddOrder: (item: OrderItem) => void;
};
function DrinkCounter({ name, price, onAddOrder }: DrinkCounterProps) {
const [quantity, setQuantity] = useState(0);
const [spec, setSpec] = useState<DrinkSpec>({
sweetness: "全糖",
ice: "正常冰",
});
function handleAdd() {
const nextQuantity = quantity + 1;
setQuantity(nextQuantity);
console.log("[事件] 這次讀到的杯數", name, quantity);
console.log("[事件] 準備更新的杯數與小計", name, nextQuantity, nextQuantity * price);
}
function handleAddThree() {
setQuantity((prevQuantity) => prevQuantity + 3);
}
function handleRemove() {
setQuantity((prevQuantity) => Math.max(prevQuantity - 1, 0));
}
function handleClear() {
setQuantity(0);
}
function handleSweetness(sweetness: DrinkSpec["sweetness"]) {
console.log("[事件] 指定甜度", name, sweetness);
setSpec((prevSpec) => ({ ...prevSpec, sweetness }));
}
function handleIce(ice: DrinkSpec["ice"]) {
console.log("[事件] 指定冰塊", name, ice);
setSpec((prevSpec) => ({ ...prevSpec, ice }));
}
function handleResetSpec() {
setSpec({ sweetness: "全糖", ice: "正常冰" });
}
function handlePreset() {
setSpec((prevSpec) => ({ ...prevSpec, sweetness: "無糖", ice: "去冰" }));
}
function handleAddToOrder() {
if (quantity === 0) return;
const newItem: OrderItem = {
id: crypto.randomUUID(),
name,
price,
quantity,
spec: { ...spec },
};
onAddOrder(newItem);
setQuantity(0);
}
function handleReportLater() {
console.log("[事件] 預約回報", name, quantity, spec.sweetness, spec.ice);
setTimeout(() => {
console.log("[延遲] 按下回報時的資料", name, quantity, spec.sweetness, spec.ice);
}, 3000);
}
return (
<div data-drink={name}>
<DrinkName name={name} />
<p>待加入 {quantity} 杯</p>
<p>小計:{quantity * price} 元</p>
<p>規格:{spec.sweetness}/{spec.ice}</p>
<div>
<button type="button" onClick={handleAdd}>加一杯</button>
<button type="button" onClick={handleAddThree}>一次加三杯</button>
<button type="button" onClick={handleRemove} disabled={quantity === 0}>
減一杯
</button>
<button type="button" onClick={handleClear}>清空杯數</button>
</div>
<fieldset>
<legend>{name}甜度</legend>
<button type="button" aria-pressed={spec.sweetness === "全糖"}
onClick={() => handleSweetness("全糖")}>
全糖
</button>
<button type="button" aria-pressed={spec.sweetness === "半糖"}
onClick={() => handleSweetness("半糖")}>
半糖
</button>
<button type="button" aria-pressed={spec.sweetness === "無糖"}
onClick={() => handleSweetness("無糖")}>
無糖
</button>
</fieldset>
<fieldset>
<legend>{name}冰塊</legend>
<button type="button" aria-pressed={spec.ice === "正常冰"}
onClick={() => handleIce("正常冰")}>
正常冰
</button>
<button type="button" aria-pressed={spec.ice === "少冰"}
onClick={() => handleIce("少冰")}>
少冰
</button>
<button type="button" aria-pressed={spec.ice === "去冰"}
onClick={() => handleIce("去冰")}>
去冰
</button>
</fieldset>
<div>
<button type="button" onClick={handleResetSpec}>恢復預設規格</button>
<button type="button" onClick={handlePreset}>一鍵無糖去冰</button>
<button type="button" onClick={handleReportLater}>3 秒後回報杯數與規格</button>
</div>
<label>
{name}練習備註(未加入清單):
<input type="text" placeholder="試著輸入晚點取" />
</label>
<div>
<button type="button" onClick={handleAddToOrder} disabled={quantity === 0}>
加入訂單清單
</button>
</div>
</div>
);
}
type DrinkDetailsProps = {
name: string;
description: string;
};
function DrinkDetails({ name, description }: DrinkDetailsProps) {
const [isExpanded, setIsExpanded] = useState(false);
function handleToggle() {
const nextIsExpanded = !isExpanded;
setIsExpanded(nextIsExpanded);
console.log("[事件] 這次與接下來的介紹狀態", name, isExpanded, nextIsExpanded);
}
return (
<div>
<button type="button" aria-expanded={isExpanded} onClick={handleToggle}>
{isExpanded ? "收合" : "展開"}「{name}」介紹
</button>
{isExpanded ? <p>{description}</p> : null}
</div>
);
}
export function DrinkOrder() {
const [orders, setOrders] = useState<OrderItem[]>([]);
const totalQuantity = orders.reduce((sum, item) => sum + item.quantity, 0);
const totalPrice = orders.reduce((sum, item) => sum + item.quantity * item.price, 0);
function handleAddOrder(newItem: OrderItem) {
setOrders((prevOrders) => [...prevOrders, newItem]);
}
function handleDeleteOrder(id: string) {
setOrders((prevOrders) => prevOrders.filter((item) => item.id !== id));
}
function handleChangeQuantity(id: string, amount: number) {
setOrders((prevOrders) =>
prevOrders.map((item) =>
item.id === id
? { ...item, quantity: Math.max(item.quantity + amount, 1) }
: item
)
);
}
function handleOrderSugarFree(id: string) {
setOrders((prevOrders) =>
prevOrders.map((item) =>
item.id === id
? { ...item, spec: { ...item.spec, sweetness: "無糖" } }
: item
)
);
}
function handleClearOrders() {
setOrders([]);
}
function handleReportOrdersLater() {
console.log("[事件] 預約回報的清單", JSON.stringify(orders));
setTimeout(() => {
console.log("[延遲] 按下回報時的清單", JSON.stringify(orders));
}, 3000);
}
return (
<main>
<header>
<p>DAY 14</p>
<h1>飲料點餐練習</h1>
<p>把不同規格加入清單,再依項目編號新增、刪除與修改。</p>
<Link to="/variable">比較一般變數</Link>
</header>
<div>
<section>
<DrinkCounter name="紅茶" price={30} onAddOrder={handleAddOrder} />
<DrinkDetails name="紅茶" description="茶香濃郁,口感醇厚。" />
</section>
<section>
<DrinkCounter name="綠茶" price={25} onAddOrder={handleAddOrder} />
<DrinkDetails name="綠茶" description="清爽茶香,適合喜歡清淡口味的人。" />
</section>
</div>
<section aria-labelledby="order-heading">
<h2 id="order-heading">訂單清單</h2>
<p>共 {orders.length} 筆、{totalQuantity} 杯,合計 {totalPrice} 元</p>
<button type="button" onClick={handleClearOrders} disabled={orders.length === 0}>
清空訂單清單
</button>
<button type="button" onClick={handleReportOrdersLater}>
3 秒後回報訂單清單
</button>
{orders.length === 0 ? (
<p>還沒有訂單項目,先選杯數與規格,再加入清單。</p>
) : (
<ul>
{orders.map((item) => (
<li key={item.id} data-order-id={item.id}>
<h3>{item.name}</h3>
<p>規格:{item.spec.sweetness}/{item.spec.ice}</p>
<p>{item.quantity} 杯 × {item.price} 元,小計 {item.quantity * item.price} 元</p>
<button type="button" onClick={() => handleChangeQuantity(item.id, 1)}>
此筆加一杯
</button>
<button type="button" onClick={() => handleChangeQuantity(item.id, -1)}
disabled={item.quantity === 1}>
此筆減一杯
</button>
<button type="button" onClick={() => handleOrderSugarFree(item.id)}>
此筆改無糖
</button>
<button type="button" onClick={() => handleDeleteOrder(item.id)}>
刪除此筆
</button>
</li>
))}
</ul>
)}
</section>
</main>
);
}
清單使用 key={item.id},ID 在加入時已經存在,渲染時只需要讀取。千萬不要用飲料名稱或目前索引,也不要寫成 key={crypto.randomUUID()},否則每次渲染都會換一個身分。
data-order-id 是額外放在 HTML 上方便觀察的屬性,React 使用的 key 本身不會出現在 Elements 的 HTML 裡。
總杯數與金額用 reduce() 累加,sum 是目前累加結果,最後的 0 是起始值,空清單也會得到零。這些值可以從 orders 算出,不需要另外放進 state,再跟著每個按鈕同步更新。

紅色框表示半糖少冰加入 2 杯,無糖去冰加入 1 杯,可以看到是分別記數的。

只有半糖少冰會增加 1 杯數量,無糖去冰數量不變。

只刪除第二筆無糖去冰的紅茶,應剩下 3 杯半糖少冰紅茶,且總額為 90。
以前在 JavaScript 新增資料,通常就直接 push(),以 handleAddOrder 為例:
// 錯誤示範。
function handleAddOrder(newItem: OrderItem) {
orders.push(newItem);
setOrders(orders);
}
看起來很合理,但會發生以下流程:
第一行改了舊清單,第二行交回 setter 同一個陣列。而 React 用 Object.is 比較 state,發現是相同參考,可以略過更新。
等到其他操作觸發更新後,才可能讓之前改過的資料出現在畫面上。

可以看到新增杯數事件發生了,但訂單筆數沒有變化。
雖然畫面後來看得到,但這個更新方式並不正確,它和昨天直接修改規格物件,再交回原物件,是同樣的問題,正確的流程是更新資料完成後,顯示在畫面上。
做個測試:
{
const original = [{ id: "a", quantity: 1 }];
const alias = original;
const result = alias.push({ id: "b", quantity: 2 });
console.log(Object.is(original, alias)); // true
console.log(original.length); // 2
console.log(result); // 2
}
原陣列已被修改,而且 push() 回傳的是新長度,不是新陣列。因此 setOrders(orders.push(newItem)) 也不行,會把數字傳給需要 OrderItem[] 的 setter。
前面已經知道淺拷貝只處理一層,現在把這個問題放進點餐畫面,把 DrinkOrder 裡的 handleOrderSugarFree 整份替換成:
// 錯誤示範
function handleOrderSugarFree(id: string) {
const nextOrders = [...orders];
const target = nextOrders.find((item) => item.id === id);
if (!target) return;
target.spec.sweetness = "無糖";
console.log("[事件] 是否同一個陣列", Object.is(orders, nextOrders));
console.log(
"[事件] 是否同一個項目",
Object.is(orders.find((item) => item.id === id), target)
);
setOrders(nextOrders);
}
find() 會找到符合編號的項目,找不到時是 undefined,但它不會複製找到的物件。
存檔、重新載入,加入紅茶兩杯、半糖少冰。先預約清單回報,再於三秒內按「此筆改無糖」,預期會看到:
| 觀察 | 結果 |
|---|---|
| 陣列參考比較 | false,外層確實是新的 |
| 項目參考比較 | true,裡面仍是同一筆物件 |
| 畫面 | 正常顯示無糖少冰 |
| 預約當下的 Log | 半糖少冰 |
| 延遲回報 | 無糖少冰 |

畫面會更新是因為 setter 收到新陣列。但舊清單與新清單仍連到相同的項目,直接修改 target.spec.sweetness,也就改掉了舊回呼會讀取的資料。
還是跟先前一樣的觀念,舊 closure 並沒有切換去讀最新 state,它仍讀取舊陣列,只是沿著那份陣列找到的規格物件,已經被改成無糖了。
把函式恢復成前面用 map()、新項目和新 spec 的版本,存檔、重新載入,用相同順序再測一次。預期畫面一樣顯示無糖少冰,但延遲回報會保持半糖少冰。
| 比較 | 只複製陣列,再改內層 | 沿修改路徑建立新資料 |
|---|---|---|
| 畫面 | 無糖少冰 | 無糖少冰 |
| 延遲回報 | 無糖少冰 | 半糖少冰 |
| 舊規格是否保留 | 已被改掉 | 維持原樣 |
兩種寫法的畫面可能相同,但對舊資料的影響不同。把預約時、修改後和延遲回報一起看,才能確認這次更新有沒有把之前的內容也改掉。
這次把清單、updater,以及昨天的淺拷貝接起來了。只要先想清楚哪些資料共用、哪一層要改,再建立下一份清單,同款飲料就能各自保留不同規格。![]()
明天接著用 state 表達表單輸入、送出中、成功與失敗的畫面狀態吧。