吃飽才有力氣動,上班總是需要一點小快樂
今天來處理點飲料一定會遇到的問題:甜度跟冰塊。
現在把甜度和冰塊,放到同一個物件內:
// 型別放在元件外面。
type DrinkSpec = {
sweetness: "全糖" | "半糖" | "無糖";
ice: "正常冰" | "少冰" | "去冰";
};
// useState 放在 DrinkCounter 的最上層。
const [spec, setSpec] = useState<DrinkSpec>({
sweetness: "全糖",
ice: "正常冰",
});
DrinkSpec 描述這個物件的欄位與型別, | 表示允許的幾種字串,所以甜度不能填成「微冰」。useState<DrinkSpec> 指定 state 的型別,後面的物件才是實際的初始資料。
杯數放在原本的 quantity,單價與飲料名稱一樣從 props 傳入,畫面直接讀取物件的欄位:
<p>規格:{spec.sweetness}/{spec.ice}</p>
以直覺的寫法來直接修改物件內的資料欄位:
spec.sweetness = "無糖";
setSpec(spec);
第一行直接修改原物件的欄位,稱為 mutation(直接修改),第二行雖然呼叫 setter,但交回去的仍是原本那個物件。
這是因為 useState 會用 Object.is 比較前後的 state。比較物件時,看參考是否相同,不會逐一檢查裡面的甜度和冰塊。當交回同一個物件時,React 可以略過更新,畫面就可能還停在舊的資料。
來試一下Object.is 的實際運作:
{
const original = { sweetness: "全糖", ice: "正常冰" };
const alias = original; // 這裡是指向 original 的參考
const replacement = { sweetness: "無糖", ice: original.ice };
console.log(Object.is(original, alias)); // 輸出 true
console.log(Object.is(original, replacement)); // 輸出 false
alias.sweetness = "半糖";
console.log(original.sweetness); // 半糖
console.log(replacement.sweetness); // 無糖
}
alias 只是替同一個物件取另一個名字,改它的甜度,original 讀到的也會變。replacement 則是另一個物件,不受這次修改影響。
const 限制的是變數不能重新指定,沒有把物件本身凍結,另一個變數名稱,不代表另一份物件。
回到 React,放進 state 的物件應該視為唯讀。要改甜度就建立新的規格,再交給 setter,例如:
setSpec({
sweetness: "無糖",
ice: spec.ice,
});
這樣既交出了新物件,也沒有回頭修改舊的規格。
如果之後再增加容量、加料,每次只改甜度,卻要把其他欄位全部重寫一次,很容易會漏掉,這時可以使用展開語法(spread syntax):
setSpec({
...spec,
sweetness: "無糖",
});
把原本的欄位放進新物件,再指定新的甜度。相同欄位以後面出現的值為準,順序要注意,假設原本是全糖、少冰:
| 寫法 | 結果 |
|---|---|
{ ...spec, sweetness: "無糖" } |
無糖、少冰 |
{ sweetness: "無糖", ...spec } |
全糖、少冰,舊甜度又蓋回來了 |
{ sweetness: "無糖" } |
少了 ice,不符合 DrinkSpec |
setSpec 接收的是下一個完整 state,不會自動幫我們補上沒寫的欄位。
在這個 TypeScript 專案中,漏掉 ice 會出現型別錯誤,補回資料就好,不要用 any 蓋過去。
不過對於 ... 並不一定表示整份資料都獨立了,它還有一個很重要的限制。
展開語法建立的是淺拷貝(shallow copy),只複製一層。例如 spec 裡面的甜度字串,展開一層就能處理,但如果物件裡面還有物件,就要多看一層。
用一份假想訂單做獨立的 Console 測試:
{
const originalOrder = {
name: "紅茶",
spec: { sweetness: "全糖", ice: "正常冰" }, // 放在內層的物件
};
const shallowCopy = { ...originalOrder }; // 淺拷貝
console.log(Object.is(originalOrder, shallowCopy)); // 輸出 false
console.log(Object.is(originalOrder.spec, shallowCopy.spec)); // 輸出 true
originalOrder.name = "綠茶";
console.log(shallowCopy.name); // 紅茶
originalOrder.spec.ice = "去冰";
console.log(shallowCopy.spec.ice); // 去冰
}
外層的 originalOrder 與 shallowCopy 是不同物件,所以改原本的 name,另一份仍然是紅茶。但兩邊的 spec 連到同一個物件,從任何一邊改裡面的冰塊,另一邊都會讀到變動。
因為兩邊讀取的是同一份 spec。
要讓冰塊的修改不影響原本那份,就替 spec 也建立新物件:
{
const originalOrder = {
name: "紅茶",
spec: { sweetness: "全糖", ice: "正常冰" },
};
const nextOrder = {
...originalOrder,
spec: { ...originalOrder.spec, ice: "去冰" }, // 內層建立新物件
};
console.log(Object.is(originalOrder, nextOrder)); // false
console.log(Object.is(originalOrder.spec, nextOrder.spec)); // false
console.log(originalOrder.spec.ice, nextOrder.spec.ice); // 正常冰 去冰
}
建立新的 spec,再讓新的訂單連到它,原本的訂單與規格都保持不變。需要改哪一層,就從那一層往外建立新物件,不能只換外層,再直接修改共用的內層。
既然淺拷貝還要注意這些,那乾脆一開始就深拷貝呢?
其實可以,只是會連沒改動的內層物件一起重新建立。假設訂單還有顧客資料,只改冰塊時,顧客資料並沒有變,每次都深拷貝,就把這些不需要重建的部分一起複製了。
| 訂單裡的資料 | 只修改冰塊時 |
|---|---|
| 訂單外層 | 建立新物件,連到新的規格 |
spec |
建立新物件,保留甜度、修改冰塊 |
| 沒修改的顧客資料 | 沿用原本的物件 |
這樣能減少不必要的複製,也保留「同一個參考,表示這一支沒改動」的判斷依據,讓依參考比較跳過重算的最佳化能發揮作用。
React 要求的是不要修改既有 state,不是每次更新都要深拷貝。沒有改的資料可以繼續共用,但共用的資料,雙方都要把它視為唯讀。 之後要修改顧客資料,再替那一支建立新物件。
那還有另一個問題:每次更新都建立新物件,記憶體不會越堆越多嗎?
建立物件確實有配置和回收的成本,但舊物件不會因為曾經放進 state,就永久留在記憶體裡。當程式已經無法再存取它,JavaScript 的 垃圾回收機制(GC) 就能回收,實際時間由引擎決定,不是呼叫 setter 後馬上釋放。
如果還有計時器的回呼要讀取舊規格,那份資料就可能暫時保留。若把每次更新都存進沒有上限的歷史清單,也得考慮這些持續保留的資料。
以今天點一下按鈕、修改兩個欄位的規模,通常不用為此改成直接修改 state。等到資料量大、更新頻繁,再量測複製與回收成本,調整資料結構或更新方式。
知道物件怎麼複製後,再來看只有甜度、冰塊兩個字串的 spec,接上昨天的 updater:
// 示意片段:在 DrinkCounter 裡。
function handleSweetness(sweetness: DrinkSpec["sweetness"]) {
setSpec((prevSpec) => ({
...prevSpec,
sweetness,
}));
}
prevSpec 這一步接到待更新規格,會建立新物件,保留其他欄位,再放入新的甜度。
物件裡單獨寫
sweetness,是sweetness: sweetness的簡寫,當屬性名稱與變數名稱相同可以這樣做。
MDN:Property definitions
DrinkSpec["sweetness"] 則取出甜度欄位的型別,讓參數只能接收全糖、半糖或無糖。
還有一個語法要注意:=> ({ ... }) 的小括號,是為了直接回傳物件,如果使用函式本體的大括號,就要明確寫 return,例如:
setSpec((prevSpec) => {
return { ...prevSpec, sweetness: "無糖" };
});
updater 要保持純粹,只計算並回傳下一份資料,不修改 prevSpec,也不在裡面安排計時器。
單次更新用 { ...spec, sweetness: "無糖" } 也可以,但這次用 updater 是要明確表達更新依據。
整份替換 drink-order.tsx,把甜度與冰塊按鈕功能放進點餐畫面。
import { useState } from "react";
import { Link } from "react-router";
type DrinkSpec = {
sweetness: "全糖" | "半糖" | "無糖";
ice: "正常冰" | "少冰" | "去冰";
};
type DrinkNameProps = {
name: string;
};
function DrinkName({ name }: DrinkNameProps) {
return <h2>{name}</h2>;
}
type DrinkCounterProps = {
name: string;
price: number;
};
function DrinkCounter({ name, price }: DrinkCounterProps) {
const [quantity, setQuantity] = useState(0);
const [spec, setSpec] = useState<DrinkSpec>({
sweetness: "全糖",
ice: "正常冰",
});
console.log("[渲染] 飲料資料", name, quantity, spec.sweetness, spec.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 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={handleReportLater}>3 秒後回報杯數與規格</button>
</div>
<label>
{name}備註:
<input type="text" placeholder="試著輸入晚點取" />
</label>
</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() {
return (
<main>
<header>
<p>DAY 13</p>
<h1>飲料點餐練習</h1>
<p>建立新的規格物件,更新甜度與冰塊。</p>
<Link to="/variable">比較一般變數</Link>
</header>
<div>
<section>
<DrinkCounter name="紅茶" price={30} />
<DrinkDetails name="紅茶" description="茶香濃郁,口感醇厚。" />
</section>
<section>
<DrinkCounter name="綠茶" price={25} />
<DrinkDetails name="綠茶" description="清爽茶香,適合喜歡清淡口味的人。" />
</section>
</div>
</main>
);
}
把甜度與冰塊個別放在 fieldset 裡,legend 顯示組別名稱,aria-pressed 表達目前選中的按鈕。畫面也直接列出規格,方便對照。
延遲回報也增加甜度與冰塊,透過 Log 逐一印出字串和數字,方便比較記錄當下的值。
預設兩款飲料都是 0 杯、全糖、正常冰。
操作順序:
看結果:

重新載入,把紅茶設成 1 杯、半糖、少冰,按「3 秒後回報杯數與規格」,接著在回報出現前改成無糖。

這次回報連到的是安排當次的 (半糖少冰) spec,後來修改甜度建立了新物件,但沒有改舊規格,所以仍能回報原本的半糖。
沒有改動舊物件,舊回呼才有原本的內容可以讀。
前面說直接修改物件可能不會更新畫面,現在來對照看看,暫時把 DrinkCounter 裡的 handleSweetness 替換成:
function handleSweetness(sweetness: DrinkSpec["sweetness"]) {
const nextSpec = spec;
nextSpec.sweetness = sweetness;
console.log("[事件] 是否同一個物件", Object.is(spec, nextSpec));
console.log("[事件] 物件裡的甜度", spec.sweetness);
setSpec(nextSpec);
}
重新載入,從 0 杯、全糖、正常冰開始,按紅茶的「無糖」,原本預期會印出 true 和「無糖」,但畫面卻是全糖,再按一次紅茶的「加一杯」,甜度也跟著顯示成無糖了。

看起來是加杯順便更新了甜度,其實是因為 quantity 的更新觸發 DrinkCounter 而重新計算 JSX,但這時的 spec 早就被改過,只是要依賴另一個操作,才能把先前的修改資料顯示出來。
重新載入,全糖時先按下預約回報,然後在三秒內按無糖,這次先不要按加杯,延遲回報會讀到無糖,畫面仍可能停在全糖。

原因一樣:舊回呼讀取的物件被直接改掉了,但資料還需要另一個操作才能渲染出來。
畫個流程:
從這個回報結果,追蹤 closure 讀取的物件與修改之間的關係,可以看到回呼在執行時讀取 spec.sweetness。
其中的 spec 來自預約當次的渲染,修改甜度的程式也連到同一個物件,當甜度改成無糖,三秒後 closure 讀取這個物件時會回報無糖,但畫面因為傳入相同的物件參考,React 可以略過這次更新,因此畫面仍停在全糖,直到加減杯數後,重新計算畫面才會顯示。
接著加一個常用規格,讓一顆按鈕同時設定甜度與冰塊。先確認前面的甜度更新已恢復正確,在 DrinkCounter 的 handleResetSpec 旁新增:
// 兩行都從同一份 spec 建立新物件
function handlePreset() {
setSpec({ ...spec, sweetness: "無糖" });
setSpec({ ...spec, ice: "去冰" });
}
在「恢復預設規格」旁新增按鈕:
<button type="button" onClick={handlePreset}>一鍵無糖去冰</button>
這次明明兩行都有建立新物件,應該沒問題了吧?
讓我們重新載入,從全糖、正常冰開始,按這顆按鈕看看。

| 順序 | 交給 setter 的物件 |
|---|---|
| 第一次 | { sweetness: "無糖", ice: "正常冰" } |
| 第二次 | { sweetness: "全糖", ice: "去冰" } |
最後會是全糖、去冰,第二行展開的是事件開始時的 spec,又把全糖帶回來了,這是因為 setter 接收到兩份完整物件,第二份替換掉前一步的結果,沒有自動把冰塊合併進第一份規格。
這就要回到昨天的更新佇列(queue),如果把 handlePreset 換成兩個 updater:
function handlePreset() {
setSpec((prevSpec) => ({ ...prevSpec, sweetness: "無糖" }));
setSpec((prevSpec) => ({ ...prevSpec, ice: "去冰" }));
}
第一步建立無糖、正常冰,第二步接到這份規格,再建立無糖、去冰。重新載入後重測,就正常了。
不過這個需求本來就是一次設定兩個欄位,其實也可以把它整理成一個 updater:
function handlePreset() {
setSpec((prevSpec) => ({
...prevSpec,
sweetness: "無糖",
ice: "去冰",
}));
}
整理今天容易混淆的幾種寫法,假設 spec 是已建立的物件:
| 寫法 | 問題與修正方向 |
|---|---|
const nextSpec = spec,再修改欄位 |
只是另一個名字,要建立新物件 |
{ sweetness: "無糖", ...spec } |
舊欄位蓋回新值,改成先展開、再覆蓋 |
setSpec({ sweetness: "無糖" }) |
缺少冰塊,setter 不會自動補齊 |
updater 裡修改 prevSpec.ice |
傳入函式也不能直接修改舊物件 |
先修改 spec.ice,再 setSpec({ ...spec }) |
雖然有新物件,舊資料已經先被改過 |
只複製訂單外層,再修改共用的 spec |
內層也要建立新物件 |
漏寫 return 的物件:
// 錯誤示意
setSpec((prevSpec) => {
sweetness: "無糖";
});
這裡的 {} 是函式本體,sweetness: 被當成標籤,並不是回傳物件。
TypeScript 會指出 updater 沒有回傳需要的規格,改成 ({ ...prevSpec, sweetness: "無糖" }),或明確寫 return 就能修正。
但直接修改物件的寫法不一定會被型別檢查擋下來,所以還是把操作前後的畫面與資料一起比較完整。
原本以為只是把杯數換成物件,結果又碰到參考、淺拷貝和舊快照,還是要看清楚哪些資料共用、哪一層要改,再建立對應的新資料。
明天把飲料與規格放進訂單陣列,接著處理新增、刪除與修改,讓點餐功能再往前一步。![]()