FE Day 7 渲染模型:條件、清單與 key
執行需求:CPU 可跑。今天是「前端開發實戰:React 與 Next.js 全套」系列的第七篇。Day 5 與 Day 6 我們把元件、JSX、props、state、事件處理都打好了,現在畫面能根據狀態改變、能接收使用者輸入、能用 props 傳遞資料。但 React 是怎麼決定「哪些 DOM 要更新、哪些不動」的?清單渲染時為什麼一定要給 key?這個 key 用 index 跟用 id 差在哪?今天的目標就是回答這些「為什麼 React 這麼設計」的問題。讀完之後,你應該理解 reconciliation 演算法的運作原理、能寫出正確的清單渲染、避開常見的 key 錯亂 bug,並把「不可變資料」這個觀念內化到日常寫 code 的習慣裡。整篇閱讀時間約三十五分鐘,動手做約三十分鐘。
引言
後端工程師第一次接觸 React,最常有的疑惑是:「為什麼 React 不直接改 DOM,而是先比對、再決定更新哪裡?」這個設計看起來很繞,實際上是 React 對「為什麼前端要快」這個問題的回答。直接操作 DOM(像早期的 jQuery)是「每次狀態改變就重新生成整個畫面」,在小元件 OK、在大應用就會卡。React 的策略是「用 Virtual DOM(虛擬 DOM)比對兩次 render 的差異,只更新真正變動的部分」——這個比對過程叫 reconciliation,是 React 效能模型的核心。
今天有三個核心主題:第一,reconciliation 是怎麼運作的、React 怎麼決定「這個節點要重用還是重建」;第二,條件渲染的三種常見寫法(if、三元、短路)與它們對 DOM 的影響;第三,清單渲染的關鍵——key 的角色,以及為什麼用 index 當 key 會出事。最後我們會用一個完整的「預約狀態過濾器」把前述觀念串起來,並刻意展示「key 錯了會發生什麼事」讓你有實際感受。
Reconciliation:React 的比對演算法
每次 React 元件重新渲染(props 變、state 變、父層重渲染),它會執行一次元件函式、回傳新的 JSX。但這不代表瀏覽器的 DOM 全部被重建。React 把新 JSX 跟舊 Virtual DOM 比對,計算出「最小更新集合」,再把那個集合送給瀏覽器。這個比對過程就叫 reconciliation。
reconciliation 的核心假設有兩個:第一,不同型別的元件會產生不同的樹(<div> 跟 <span> 不會互相重用,<MyComponent /> 跟 <OtherComponent /> 也不會);第二,開發者可以透過 key 提示 React「這個節點在邏輯上是同一個」,讓 React 在重新排序時能正確識別。第一條假設讓 React 的比對演算法從「O(n³)」降到「O(n)」——不需要對所有節點做全配對,只要看型別就能決定。第二條假設讓清單渲染變得可靠,特別是在「新增」、「刪除」、「排序」的情境。
實際的流程是這樣:元件 render → 產生新的 Virtual DOM 樹 → React 跟舊樹比對 → 計算出需要更新的節點 → 對真實 DOM 執行 patch。這個流程在 React 19 已經高度最佳化,但理解它的存在能幫你避開「為什麼我改了一個 state 整個畫面都重繪」、「為什麼我的輸入框會失去焦點」這類問題。
同型別元素會被重用
當節點型別相同,React 會嘗試重用 DOM 節點,只更新變動的屬性(attribute)。例如:
// 第一次 render
<div className="text-red-500">Hello</div>
// 第二次 render(state 變了)
<div className="text-blue-500">World</div>
// React 內部做的事:
// 1. 找到同一個 <div> DOM 節點(重用)
// 2. 更新 className:text-red-500 -> text-blue-500
// 3. 更新 textContent:Hello -> World
// 4. 沒有動到的事件、style 都不會被重設
這就是為什麼 React 的輸入框「不會失去焦點」——只要同一個 <input> 節點被重用,瀏覽器就會記得當前的 focus 狀態。但如果你把 <input> 換成 <textarea>,React 會認為這是不同節點、把整個元素拆掉重做,焦點就會掉。
key:清單渲染的身份證
當同一層有多個同型別的兄弟節點,React 靠「位置」識別它們:
// 第一次 render
<ul>
<li>蘋果</li>
<li>香蕉</li>
</ul>
// 第二次 render(在開頭新增)
<ul>
<li>奇異果</li> {/* 新 */}
<li>蘋果</li> {/* 原本在位置 0 */}
<li>香蕉</li> {/* 原本在位置 1 */}
</ul>
如果沒有 key,React 會用「位置比對」:第一個 <li> 對應第一個、第二個對應第二個。結果「蘋果」這個文字內容就被 React 認為「改變了」,於是 update textContent;同樣「香蕉」也 update。React 不知道「蘋果其實跟原本第一個是同一個」。當每個 <li> 裡面有 input(受控元件)時,這個問題會更明顯——輸入框的 focus、value 都會被誤判為「改變」。
加了 key 之後,React 就能正確識別「同一個節點」:
// 用 key 讓 React 識別身份
<ul>
<li key="kiwi">奇異果</li>
<li key="apple">蘋果</li>
<li key="banana">香蕉</li>
</ul>
當 React 比對時會看到「蘋果」的 key 沒變、值也沒變,整個節點直接重用;「奇異果」是新的、insert 進來;其他節點不動。這就是 key 的核心角色:讓 React 在「清單重新排序」時能正確對應到原本的 DOM 節點。
條件渲染:讓畫面根據狀態改變
條件渲染是「根據某個布林值決定要不要顯示某段 UI」。JSX 本身沒有 if 這種敘述型語法,所以要靠 JavaScript 的「表達式」來達成。下面三種寫法各有適用情境。
寫法一:用 if 把條件渲染抽成變數
當條件很複雜、JSX 太長時,把判斷結果抽成變數,主 JSX 保持乾淨:
// src/components/UserGreeting.tsx
// 用 if 抽變數:複雜條件
type UserGreetingProps = {
user: { name: string; isLoggedIn: boolean; role: "admin" | "user" };
};
export function UserGreeting({ user }: UserGreetingProps) {
// 在元件本體裡用 if 計算要顯示的內容
let greeting: string;
if (!user.isLoggedIn) {
greeting = "請登入";
} else if (user.role === "admin") {
greeting = `管理員 ${user.name},您好`;
} else {
greeting = `${user.name},您好`;
}
return <p className="text-lg">{greeting}</p>;
}
寫法二:用三元運算做兩種結果
當「條件成立時顯示 A,否則顯示 B」這種兩種結果明確的情境,用三元最直觀:
// src/components/LoginButton.tsx
// 三元:兩種明確結果
type Props = { isLoggedIn: boolean; onLogin: () => void; onLogout: () => void };
export function LoginButton({ isLoggedIn, onLogin, onLogout }: Props) {
return (
<button
type="button"
onClick={isLoggedIn ? onLogout : onLogin}
className="rounded bg-slate-900 px-4 py-2 text-white"
>
{isLoggedIn ? "登出" : "登入"}
</button>
);
}
寫法三:用短路 AND 做「條件成立才渲染」
當「條件成立就渲染、否則什麼都不渲染」這種單向情境,用 &&:
// src/components/Notification.tsx
// 短路 AND:條件成立才渲染
type Props = { message: string | null; type: "success" | "error" };
export function Notification({ message, type }: Props) {
return (
<div>
{/* 只有 message 不是 null 才顯示 */}
{message && (
<p
className={type === "success" ? "text-emerald-600" : "text-red-600"}
>
{message}
</p>
)}
</div>
);
}
注意 && 有個小陷阱:當 message 是數字 0、空字串 "" 時,React 會把 falsy 值當成「不渲染」以外的行為——空字串會被當文字顯示、數字 0 也會。要嘛轉 boolean:{message !== null && ...};要嘛用三元:{message ? (...) : null}。這是常見 bug,記得避開。
條件渲染對 DOM 的影響
理解條件渲染怎麼影響 DOM,是寫高效 React 元件的關鍵。第一種:{cond && <X />}:cond 是 true 時渲染 X、false 時不渲染任何 DOM(X 完全不在樹裡)。第二種:{cond ? <X /> : <Y />}:永遠有一個 DOM 節點存在,只在 X 跟 Y 之間切換。第三種:{cond && <X /> && <Y />}:根據 cond 的 boolean 值串接多個節點(少用)。
實務上「永遠有一個 DOM 節點」的寫法(用三元)對穩定性比較好——切換時 DOM 結構不變,狀態不會被清空(例如輸入框的值不會被重置)。但如果你的元件在「false 時不該出現在畫面上」(例如「管理員專區」、「錯誤橫幅」),用 && 完全移除節點比較乾淨。
清單渲染:把陣列變成 UI
前端最常見的渲染模式之一是「把後端回傳的陣列轉成清單」。JSX 用 map 達成這件事:
// src/components/StringList.tsx
// 清單渲染:map 陣列成元素
type Props = { items: string[] };
export function StringList({ items }: Props) {
return (
<ul>
{items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
);
}
注意兩個重點:第一,key 一定要給,否則 React 會在 dev 模式警告「Each child in a list should have a unique key prop」;第二,key 必須在「同一層兄弟節點之間」唯一,不能用 index(陣列位置)當 key——除非你的清單永遠不會重新排序、新增、刪除。
為什麼用 index 當 key 會出事
「用陣列 index 當 key」是 React 新手最常見的 bug。表面上能跑、實際上會造成狀態錯亂。下面用一個輸入框清單示範:
// src/components/BadIndexList.tsx
// 反例:用 index 當 key 會導致狀態錯亂
import { useState } from "react";
const initialItems = ["蘋果", "香蕉", "奇異果"];
export function BadIndexList() {
const [items, setItems] = useState(initialItems);
const handleChange = (index: number, value: string) => {
const next = [...items];
next[index] = value;
setItems(next);
};
// 故意用 index 當 key
return (
<ul>
{items.map((item, index) => (
<li key={index}>
<input
value={item}
onChange={(e) => handleChange(index, e.target.value)}
/>
</li>
))}
</ul>
);
}
執行這段程式時,使用者在第一個輸入框打「蘋果」、第二個輸入框打「香蕉」、第三個輸入框打「奇異果」。現在如果你在「蘋果」前面新增一個「草莓」:
// 在開頭新增一筆
const newItems = ["草莓", ...items];
setItems(newItems);
預期結果:四個輸入框依序顯示「草莓、蘋果、香蕉、奇異果」。實際結果:使用者原本輸入的「蘋果」會跑到第二個輸入框、「香蕉」會跑到第三個、「奇異果」會跑到第四個——但顯示的 value 卻沒變動,因為 React 用「位置」識別,認為 input 還是同一個 DOM,值該是同一個 value。
這就是 index 當 key 的典型 bug:DOM 節點被重用、但 props 對應錯亂,導致「看到的內容」與「輸入的內容」不一致。修法是用穩定的識別欄位(例如 id)當 key:
// 修正:用穩定的 id 當 key
type Item = { id: number; name: string };
const initial: Item[] = [
{ id: 1, name: "蘋果" },
{ id: 2, name: "香蕉" },
{ id: 3, name: "奇異果" },
];
export function GoodKeyList() {
const [items, setItems] = useState(initial);
return (
<ul>
{items.map((item) => (
<li key={item.id}>
<input
value={item.name}
onChange={(e) => {
const next = items.map((x) =>
x.id === item.id ? { ...x, name: e.target.value } : x,
);
setItems(next);
}}
/>
</li>
))}
</ul>
);
}
現在新增「草莓」時,React 透過 id 識別「原本的蘋果/香蕉/奇異果還是同一個 DOM」,於是它們的 value 與 focus 都被保留,只有新的草莓插入。這個差別在大型應用會非常明顯——忘記給穩定 key,使用者會遇到「為什麼我的輸入框狀態消失了」、「為什麼動畫卡卡的」、「為什麼焦點跑掉」這類神秘 bug。
不可變資料:React 的「不可改變」設計
React 對 state 的設計哲學是「不可變」(immutable):你不能直接修改 state 物件,而是要建立新物件、呼叫 setter。這個設計跟 React 的 diff 演算法直接相關——React 比較「舊值」與「新值」的 reference 來判斷是否需要 re-render,如果你直接改內部屬性、reference 不變,React 就不知道要重繪。
實務上有兩個常見的不可變更新模式:
// src/utils/immutable.ts
// 不可變更新的兩種模式
// 模式 1:陣列(用 map/filter 建立新陣列)
const arr = [1, 2, 3];
const next1 = arr.map((x) => (x === 2 ? 20 : x)); // [1, 20, 3]
const next2 = arr.filter((x) => x !== 2); // [1, 3]
const next3 = [...arr, 4]; // [1, 2, 3, 4]
// 模式 2:物件(用 spread 建立新物件)
const obj = { id: 1, name: "Hao" };
const next4 = { ...obj, name: "Min" }; // { id: 1, name: "Min" }
const next5 = { ...obj, tags: [...obj.tags, "vip"] }; // 巢狀更新
注意兩件事:第一,「淺拷貝」對巢狀結構不夠:{ ...obj, address: { ...obj.address, city: "Taipei" } } 才是正確的巢狀更新;第二,「不要 mutate 原始物件」:即使你看起來「只是改一個欄位」,React 的 state 必須是 immutable 的,否則下一輪 render 會拿到被改過的值、reconciliation 會出錯。Day 13 會展開「為什麼不可變是 React 的基石」,這裡先記住「一律建立新值」這條規則。
完整實作:預約狀態過濾器
把今天學的條件渲染、清單渲染、key、不可變更新整合起來:寫一個「預約狀態過濾器」,使用者可以用頁籤切換「全部 / 待確認 / 已確認 / 已取消 / 已完成」:
// src/features/booking/BookingStatusFilter.tsx
// 預約狀態過濾器:用 page tab 切換、用 useState 管當前選擇
import { useState } from "react";
import type { Booking, BookingStatus } from "../../types/booking";
// 狀態對應的中文顯示
const statusTabs: { id: BookingStatus | "all"; label: string }[] = [
{ id: "all", label: "全部" },
{ id: "pending", label: "待確認" },
{ id: "confirmed", label: "已確認" },
{ id: "cancelled", label: "已取消" },
{ id: "completed", label: "已完成" },
];
type Props = { bookings: Booking[] };
export function BookingStatusFilter({ bookings }: Props) {
// 當前選擇的狀態 tab
const [activeStatus, setActiveStatus] = useState<BookingStatus | "all">("all");
// 根據 activeStatus 過濾 bookings(衍生資料,不另存 state)
const filtered = activeStatus === "all"
? bookings
: bookings.filter((b) => b.status === activeStatus);
// 計算每個狀態的數量(用 reduce,不可變)
const countByStatus = bookings.reduce<Record<BookingStatus | "all", number>>(
(acc, b) => {
acc[b.status] = (acc[b.status] ?? 0) + 1;
acc.all = (acc.all ?? 0) + 1;
return acc;
},
{ all: 0, pending: 0, confirmed: 0, cancelled: 0, completed: 0 },
);
return (
<div>
{/* 狀態頁籤列 */}
<div className="mb-4 flex gap-2 border-b" role="tablist">
{statusTabs.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={activeStatus === tab.id}
onClick={() => setActiveStatus(tab.id)}
className={[
"px-3 py-2 text-sm",
activeStatus === tab.id
? "border-b-2 border-slate-900 font-semibold"
: "text-slate-500",
].join(" ")}
>
{tab.label} ({countByStatus[tab.id]})
</button>
))}
</div>
{/* 預約清單 */}
{filtered.length === 0 ? (
<p className="rounded border border-dashed p-6 text-center text-slate-500">
這個狀態下沒有預約
</p>
) : (
<ul className="divide-y rounded border">
{filtered.map((booking) => (
<li key={booking.id} className="p-3">
<p className="font-medium">{booking.customerName}</p>
<p className="text-sm text-slate-600">
{booking.customerPhone}・{booking.startAt}
</p>
</li>
))}
</ul>
)}
</div>
);
}
這支元件展示八個整合觀念:第一,useState 存「當前選擇」,其餘都是衍生計算(不另存 state);第二,filter 用不可變方式建立新陣列;第三,reduce 用累加器計算每個狀態的數量,也是不可變;第四,key={booking.id} 用穩定的 id 而非 index;第五,aria-selected 是無障礙屬性;第六,「這個狀態下沒有預約」用三元做條件渲染;第七,role="tablist"、role="tab" 是 ARIA 規範;第八,className 用陣列.join 串接多個條件 class。這支元件約 60 行,卻把 Day 5 到 Day 7 的所有核心觀念都演練了一遍。
常見錯誤與踩雷
清單渲染與 key 的踩雷幾乎都是「key 給錯」或「忘了給」。第一個是「用 index 當 key」。我們剛剛示範過 index 會導致輸入框狀態錯亂。其他常見情境:「刪除中間一筆」會讓 React 把「刪除那筆之後的值」誤判為「前一筆的值」,導致 state 跟 DOM 對不上。修法一律是用穩定 id(後端回傳的 UUID、資料庫主鍵)。
第二個是「key 用 Math.random()」。這樣每次 render key 都不同,React 會把整個清單拆掉重建,效能極差而且 input 的值會一直被重置。key 必須在「同一筆資料的生命週期內」保持穩定——同一筆預約從頭到尾都用同一個 key,不能每次 render 換一個。
第三個是「忘記給 key」。React 在 dev 模式會跳警告「Each child in a list should have a unique key prop」,這時候就一定要補上。常見的狀況是「map 之後回傳多個節點(用 Fragment 包)」忘了給 key。修法是在 Fragment 上加 <React.Fragment key={{...}}>,或者用 <>...</> 短語法(短語法不支援 key,要用長的)。
效能與實務提醒
今天的關鍵字是「不可變」與「穩定 key」。前者讓 React 的 diff 演算法能正確判斷「這個 state 變了沒」,後者讓清單渲染能正確對應到 DOM 節點。實務上有兩個延伸提醒。第一,當清單很長(幾千筆以上)時,filter 每次 render 都重算可能會有效能問題。解法是用 useMemo 把計算結果記住,只有 bookings 或 activeStatus 變動時才重算。Day 10 與 Day 13 會展開 useMemo 與 React.memo。
第二個提醒是「不要在 key 上做運算」。例如 key={`booking-${index}`}、key={booking.name} 都不理想:前者退化成 index,後者會因為 name 重複而衝突。建議用「資料來源本身的識別欄位」(id、UUID、slug)。如果後端沒給 id,可以在前端用 useRef(new Map()) 或第三方函式庫(uuid、nanoid)建立穩定 id。
另一個 React 19 的小細節是「Fragment 與 key」。<>...</> 短語法 Fragment 不能帶 key,要用 React.Fragment 或 import Fragment 來加 key。或者用陣列寫法 {[<X key="x" />, <Y key="y" />]},React 也支援陣列形式的子節點。本系列後續範例會大量用清單渲染,能看到這幾種寫法的差異。
延伸觀察:React DevTools 看 reconciliation
想知道 reconciliation 真的怎麼運作,最直接的方式是裝 React DevTools。裝好後打開瀏覽器擴充功能,點到「Profiler」分頁,按下「Record」操作畫面,就會看到每一次 render 的耗時、每個元件的 commit 過程、哪些 props/state 改變觸發重渲染。這對 debug「為什麼這個元件一直重繪」、「為什麼這個 useEffect 跑了兩次」非常有用。
另一個觀察方式是用 React.memo 把元件包起來。如果 props 沒變,React 不會重新執行元件函式——這就是 React.memo 提供的「略過重渲染」能力。實務上,React.memo 對「list 中每個 item 元件」特別有用:當 list 重新排序時,只有 props 有變的 item 會真的重繪,其他保持原樣。Day 13 會展開 React.memo 與其他效能工具。
另外有一個 React 18 引入、React 19 持續演進的能力:「concurrent rendering」。它讓 React 可以「暫停」一個低優先級的 render、「插隊」處理高優先級的更新(例如使用者輸入)。當你用 useTransition 或 useDeferredValue 時,就是在告訴 React「這個更新可以晚一點完成」。這個機制對大型應用的響應速度影響很大,但本系列 Day 9 與 Day 25 才會大量用到,這裡先記得有這件事。
小結
今天我們把 React 的渲染模型拆開來看:reconciliation 演算法的兩條假設(同型別重用、用 key 識別身份)、條件渲染的三種寫法(if 抽變數、三元、短路 AND)與對 DOM 的影響、清單渲染與 key 的關鍵角色、不可變資料的設計哲學。最後用一個完整的「預約狀態過濾器」把這些觀念串起來。明天 Day 8 進入樣式方案:Tailwind CSS 4 與設計慣例——把今天寫的元件變漂亮,並理解 CSS-first 設定怎麼跟 React 整合。
結語
明天,我們會進入樣式的世界:為什麼選 Tailwind 而非 styled-components 或 CSS Modules、Tailwind 4 的 CSS-first 設定怎麼寫、utility class 怎麼跟 React 元件搭配、有條件的 className 怎麼處理(clsx)、設計系統與設計 token 的概念。Day 8 結束時,你就能寫出「有設計感」的前端畫面,並理解 Tailwind 4 跟 v3 的差異。接著 Day 9 進入 useEffect——把元件跟外部世界(API、計時器、瀏覽器 API)接起來。
延伸資源
- React 官方〈Reconciliation〉(內部演算法說明):
https://react.dev/learn/preserving-and-resetting-state - React 官方〈Rendering Lists〉:
https://react.dev/learn/rendering-lists - React 官方〈Conditional Rendering〉:
https://react.dev/learn/conditional-rendering - React 官方〈Keeping Components Pure〉:
https://react.dev/learn/keeping-components-pure - MDN〈Virtual DOM 與 Reconciliation〉:
https://developer.mozilla.org/zh-TW/docs/Web/API/Document_Object_Model
留言
張貼留言