FE Day 5 React 起步:元件、props 與 JSX
執行需求:CPU 可跑。今天是「前端開發實戰:React 與 Next.js 全套」系列的第五篇。前四天把環境、型別系統、泛型與工具型別都打好了,今天正式進入 React 本身。我們要回答三個核心問題:React 元件是什麼?JSX 怎麼運作?props 怎麼傳?讀完之後,你應該能用 React 寫出可重用的函式元件、理解「單向資料流」、把 Day 3 與 Day 4 的 TypeScript 型別套到元件 props 上,並看懂大部分 React 與社群套件的原始碼。整篇閱讀時間約三十分鐘,動手做約二十五分鐘。
引言
後端工程師第一次接觸 React,常會把「元件」想成「某個 UI 片段」。但 React 對元件的定義更精準:「一個函式,接收 props 與 state,回傳一段 UI 描述」。這個定義讓元件可以用普通函式的角度去思考——給一樣的輸入就會回傳一樣的輸出(純函式),可以被測試、可以被重用、可以被組合。Day 5 的目標就是把這個「函式視角」建立起來。
本系列 Day 1 已經示範過一個最簡單的元件。今天要把它擴充成「可重用、可組合、可型別化」的版本。具體會做四件事:第一,把函式元件的語法與 class 元件做對照,說明為什麼 React 19 之後幾乎只用函式元件;第二,把 JSX 的規則(單一根、屬性命名、條件渲染)一次講清楚;第三,把 props 的傳遞、解構、預設值、children 與鍵值完整介紹;第四,用一個完整的「卡片 + 按鈕 + 版面」範例把前述觀念串起來。
函式元件:React 的最小單位
函式元件(functional component)就是一個「回傳 JSX」的純函式。它跟一般函式的差別只有一點:呼叫時由 React 幫你處理(不是你自己呼叫),並把回傳值接到 DOM 上。下面是一個最小但完整的範例:
// src/components/Greeting.tsx
// 最小的函式元件:接受 props、回傳 JSX
type GreetingProps = {
name: string;
};
export default function Greeting({ name }: GreetingProps) {
return <h1 className="text-2xl font-bold">Hello, {name}!</h1>;
}
這支元件有三個重點:第一,function Greeting 是普通 JavaScript 函式,只是回傳 JSX 而不是字串或數字;第二,name 從 props 解構出來,型別由 TypeScript 把關;第三,export default 讓其他檔案可以 import Greeting from "./components/Greeting"。後端工程師可以把它想成「在瀏覽器跑的 view function」——給同樣的 name,一定回傳同樣的 HTML 結構。
React 19 之後,「預設匯出」與「具名匯出」都可以,看團隊慣例。本系列一律用具名匯出加 export function,原因有二:第一,命名匯入 import { Greeting } from "..." 在重構時更安全(重新命名元件會立刻報錯);第二,IDE 自動完成時不會被 default 的「任何名字」誤導。
為什麼函式元件勝過 class 元件
React 從 16.8 版(2019 年)引入 Hooks 之後,函式元件就能做到原本 class 元件才能做的事(state、副作用、refs)。到了 React 19,class 元件仍能跑,但官方建議新程式碼一律用函式元件。幾個關鍵理由:
- 邏輯更容易抽出:class 元件用繼承共用邏輯,函式元件用自訂 Hook 共用邏輯。後者組合性更好、測試更容易。
- TypeScript 推論更準:class 元件的
this在 strict 模式下經常要明確標型別,函式元件的 props 解構讓型別自動流入。 - 沒有 this 綁定陷阱:class 元件常要寫
this.handleClick = this.handleClick.bind(this),函式元件沒有這個問題。 - Bundle 較小:class 元件編譯後比較肥,函式元件通常比較精簡。
本系列從 Day 5 到 Day 45 都不會再碰 class 元件。如果你接手舊專案看到 class Foo extends React.Component,先觀察一下 props 與 state 的結構,再逐步抽成函式元件即可。
JSX:看起來像 HTML,但更強
JSX 是 React 的「元素描述語法」:它看起來像 HTML,但實際上是 JavaScript 的語法擴充。瀏覽器看不懂 JSX,所以 Vite、Babel 或 esbuild 會把它編譯成 React.createElement(...) 呼叫。理解這個對應關係,就能看懂所有奇怪的錯誤訊息。
// JSX 看起來像這樣:
const element = <h1 className="text-2xl">Hello</h1>;
// 編譯後等價於:
const element = React.createElement(
"h1",
{ className: "text-2xl" },
"Hello"
);
注意兩個差異:第一,JSX 用 className 而不是 class,因為 class 在 JavaScript 是保留字;第二,React.createElement 的第二參數是「props 物件」,第三個以後是「children」。這個轉譯過程對你來說是透明的,但當你看到 React DevTools 或錯誤訊息提到 createElement 時,就知道是從這裡來的。
JSX 的四條規則
新手最容易踩的雷都集中在 JSX 的語法。把它整理成四條規則,記住就能避免 90% 的「為什麼壞掉」的問題:
- 只能回傳單一根元素:一個元件的回傳值必須是一個 JSX 元素(或陣列、Fragment)。回傳兩個相鄰的
<div>會報錯——必須包成<div...</div>或用 Fragment<>...</>。 - 屬性命名用駝峰:HTML 原生的
class變className、tabindex變tabIndex、for變htmlFor。React 事件同樣用駝峰:onclick變onClick、onchange變onChange。 - 用大括號嵌入 JavaScript:JSX 裡看到
{}就是「跳回 JavaScript 模式」。變數{name}、運算式{count + 1}、三元{ok ? 'A' : 'B'}都可以,但不能放if/for這種「敘述」。 - 空標籤要 self-close:
必須寫成、寫成,JSX 不支援 HTML 那種不關標籤的寫法。
用大括號跳回 JavaScript
JSX 最重要的特性就是「用 {} 跳出 HTML 模式、回到 JavaScript」。下面是一個完整的展示:
// src/components/Profile.tsx
// 在 JSX 裡嵌入 JavaScript 變數、運算式、條件
type ProfileProps = {
user: { name: string; age: number; isAdmin: boolean };
};
export function Profile({ user }: ProfileProps) {
// 在函式本體裡先計算要顯示的內容
const greeting = `Hello, ${user.name}`;
const showBadge = user.isAdmin && user.age >= 18;
return (
<div className="rounded border p-4">
<h2 className="text-lg font-semibold">{greeting}</h2>
<p>年齡:{user.age}</p>
{/* 三元運算做條件渲染 */}
<p>{showBadge ? "管理員" : "一般使用者"}</p>
{/* 串接多個 className:用陣列.join */}
<span className={["badge", showBadge ? "bg-emerald-500" : "bg-slate-300"].join(" ")}>
狀態
</span>
</div>
);
}
注意幾件事:第一,{user.name} 是純 JavaScript,user.name 是 undefined 就會印「undefined」(不會報錯);第二,條件渲染用三元 ? :,或短路 &&,但不能放 if;第三,className 可以接受陣列、物件、字串,Tailwind 之後會用 clsx 工具處理;第四,{/* 註解 */} 是 JSX 註解的標準寫法。
props:元件之間的介面
props 是 React 元件之間「傳資料」的管道,也是元件對外的介面。理解 props 的三條鐵律,能讓你設計出可重用、可測試的元件。
鐵律一:props 是唯讀的。元件絕對不應該改變自己的 props(這是 React 的核心不變數)。如果你看到 props.name = "新名字",那是錯的;要改資料應該用 state(Day 6)或請父元件改(Day 11 的資料流)。
鐵律二:props 由父元件決定。子元件不能決定自己收到什麼 props;它只能宣告「我需要什麼」,由父元件決定「給什麼」。這就是「單向資料流」的基礎。
鐵律三:props 改變時元件重新渲染。父元件傳新的 props 給子元件時,子元件會重新執行函式並產生新的 JSX(reconciliation 機制會比對,沒變的 DOM 不動,Day 7 會展開)。
props 解構與預設值
實務上 props 幾乎都用解構(destructuring)寫法,順便給預設值:
// src/components/Button.tsx
// 用解構 + 預設值寫 props
import type { ReactNode } from "react";
type ButtonProps = {
label: string;
onClick?: () => void;
variant?: "primary" | "secondary" | "ghost";
disabled?: boolean;
children?: ReactNode;
};
export function Button({
label,
onClick,
variant = "primary",
disabled = false,
children,
}: ButtonProps) {
// 根據 variant 套用不同 className
const baseClass = "px-4 py-2 rounded font-medium";
const variantClass =
variant === "primary"
? "bg-slate-900 text-white"
: variant === "secondary"
? "bg-slate-200 text-slate-900"
: "bg-transparent text-slate-700";
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={[baseClass, variantClass].join(" ")}
>
{children ?? label}
</button>
);
}
這支 Button 元件展示了六個 props 設計原則:第一,label 必填(沒有預設值);第二,onClick 可選,沒給就不綁事件;第三,variant 是聯合型別,三個合法值用預設值 "primary";第四,disabled 預設 false;第五,children 是 ReactNode,可以放任意 JSX;第六,children ?? label 是「如果沒給 children 就顯示 label」,這是常見的彈性設計。
children:組合元件的關鍵
children 是 React 內建的特殊 prop,代表「元件標籤之間包住的內容」。它讓你能寫出「容器型元件」:
// src/components/Card.tsx
// 容器型元件:把 children 套上一致的卡片外觀
import type { ReactNode } from "react";
type CardProps = {
title?: string;
children: ReactNode; // 必填:卡片一定要有內容
};
export function Card({ title, children }: CardProps) {
return (
<div className="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
{title && <h3 className="mb-3 text-lg font-semibold">{title}</h3>}
<div>{children}</div>
</div>
);
}
呼叫端寫起來像普通 HTML:
// 在父元件裡使用 Card
<Card title="今日預約">
<p>王小明・10:00・剪髮</p>
<p>李小華・14:30・洗頭</p>
</Card>
children 的設計讓你不用為每種內容「量身打造」元件。後端寫過 Jinja 模板的 {% block content %} 會覺得很熟悉——Card 就是「在固定版面裡挖一個洞,讓呼叫端決定填什麼」。這個觀念延伸到 React 19 之後的 ReactNode 還能放 Promise、Fragment、字串、陣列等等,是組合元件最強大的工具。
完整實作:把元件組合成版面
把前面學的所有觀念整合:寫一個簡單的預約頁版面,由 Card、Button、Layout 三個元件組起來。先看 Layout:
// src/components/Layout.tsx
// 版面元件:把 children 包進 Header + Main 的標準結構
import type { ReactNode } from "react";
type LayoutProps = {
title: string;
children: ReactNode;
};
export function Layout({ title, children }: LayoutProps) {
return (
<div className="min-h-screen bg-slate-50">
<header className="border-b border-slate-200 bg-white">
<div className="mx-auto max-w-3xl px-6 py-4">
<h1 className="text-xl font-semibold">{title}</h1>
</div>
</header>
<main className="mx-auto max-w-3xl px-6 py-8">{children}</main>
</div>
);
}
接著寫一個 BookingSummary 子元件,把 Day 3 的 Booking 型別拿來用:
// src/features/booking/BookingSummary.tsx
// 預約摘要:把一筆 Booking 顯示成卡片
import type { Booking } from "../../types/booking";
import { Card } from "../../components/Card";
import { Button } from "../../components/Button";
type BookingSummaryProps = {
booking: Booking;
onCancel?: (id: number) => void;
};
export function BookingSummary({ booking, onCancel }: BookingSummaryProps) {
// 決定狀態對應的中文標籤
const statusLabel: Record<Booking["status"], string> = {
pending: "待確認",
confirmed: "已確認",
cancelled: "已取消",
completed: "已完成",
};
return (
<Card title={`預約 #${booking.id}`}>
<p className="text-slate-700">顧客:{booking.customerName}</p>
<p className="text-slate-700">電話:{booking.customerPhone}</p>
<p className="text-slate-700">時間:{booking.startAt}</p>
<p className="mt-2 inline-block rounded bg-slate-100 px-2 py-1 text-sm">
{statusLabel[booking.status]}
</p>
{onCancel && booking.status !== "cancelled" && (
<div className="mt-3">
<Button variant="ghost" onClick={() => onCancel(booking.id)}>
取消預約
</Button>
</div>
)}
</Card>
);
}
最後在頁面把這些元件組起來:
// src/features/booking/BookingPage.tsx
// 把 Layout + BookingSummary 組合成一個完整頁面
import { Layout } from "../../components/Layout";
import { BookingSummary } from "./BookingSummary";
import type { Booking } from "../../types/booking";
// 範例資料:實際接 API 時換成 fetch
const sampleBookings: Booking[] = [
{
id: 1,
customerName: "王小明",
customerPhone: "0912345678",
serviceId: 1,
startAt: "2026-03-20T10:00:00+08:00",
status: "confirmed",
createdAt: "2026-03-15T10:00:00+08:00",
updatedAt: "2026-03-15T10:00:00+08:00",
},
];
export function BookingPage() {
function handleCancel(id: number) {
// 實際接 API:呼叫後端取消預約
console.log("取消預約", id);
}
return (
<Layout title="預約管理">
<div className="space-y-4">
{sampleBookings.map((booking) => (
<BookingSummary
key={booking.id}
booking={booking}
onCancel={handleCancel}
/>
))}
</div>
</Layout>
);
}
這支範例展示五個組合元件的原則:第一,每個元件都是純函式,給一樣的 props 就回一樣的 JSX;第二,key={booking.id} 讓 React 識別每張卡片(Day 7 會專門講);第三,Booking["status"] 直接從型別查詢,避免重複宣告聯合型別;第四,handleCancel 在父元件宣告、由子元件透過 props 呼叫,這就是「事件向上傳」;第五,最上層是頁面,底層是 Button、Card,中間是 BookingSummary——三層分工剛好對應 Day 13 會講的元件設計原則。
常見錯誤與踩雷
第一次寫 React 元件最常踩的雷有三個。第一個是「回傳兩個根元素」。很多新手寫 ...return ,結果瀏覽器報「JSX element must have one parent」。修法是用 Fragment ...
<>...</> 包起來,或外層加一個 <div>。Fragment 不會在 DOM 額外產生節點,是最常用的做法。
第二個是「用 HTML 屬性命名」。例如寫 class="foo"、tabindex="0"、for="email",瀏覽器會忽略或報警告。正確寫法是 className、tabIndex、htmlFor。另外事件也駝峰:onclick → onClick、onchange → onChange。如果忘了某個屬性的命名,VS Code 的 JSX 檢查會直接畫紅線,順著錯誤訊息改即可。
第三個是「忘記給 props 型別」。很多人寫了幾支元件後開始偷懶,把 type Props = {...} 省略掉,結果元件長大後再回頭補就很痛苦。建議從第一支元件開始就標 props 型別,未來加欄位、拆元件都有完整的契約可以對照。Day 3 與 Day 4 學的工具型別在這裡會大量用到:Pick、Omit、Partial 都能直接套到 props 上。
效能與實務提醒
今天寫的元件都是「無狀態、純函式」的版本,效能上沒有太大問題。但有兩個小提醒:第一,元件函式每次 render 都會重新執行一次,這是 React 的設計——它用「重執行」換「簡單心智模型」。當元件樹很大時,重複執行會累積成本,這時候才需要 React.memo、useMemo、useCallback 這類最佳化工具(Day 13 與 Day 39 會展開)。
第二個提醒是「不要在元件本體裡宣告副作用」。所謂副作用是「讀取瀏覽器 API、訂閱事件、發 HTTP 請求」。這些應該放進 useEffect(Day 9)。如果你把 fetch("/api/...") 寫在元件函式本體,每次 render 都會打一次 API,畫面立刻爆掉。今天寫的 BookingSummary 之所以沒事,是因為它的 props 進來就是靜態資料;如果改成從 API 拿,就要用 Day 6 的 useState + Day 9 的 useEffect。
另一個實務提醒是「用具名匯出」。React 生態對 default export 與 named export 的看法分歧,但具名匯出有三個好處:第一,重構時不會被「任意命名」誤導;第二,IDE 自動完成更準;第三,tree-shaking 比較可靠(雖然 default export 在現代 bundler 也支援)。本系列一律用具名匯出:export function Button(...) 加 import { Button } from "..."。
條件渲染的三種寫法
寫 React 元件時,最常見的互動模式之一是「根據某個條件決定要不要渲染某段 UI」。JSX 裡有三種常見的寫法,各有適用情境:
// src/components/ConditionalDemo.tsx
// 條件渲染的三種寫法
import type { Booking } from "../types/booking";
type Props = {
booking: Booking;
isOwner: boolean;
};
export function ConditionalDemo({ booking, isOwner }: Props) {
// 寫法 1:三元運算,條件成立時渲染 A,否則渲染 B
const ownerArea = isOwner ? (
<button type="button" className="text-sm text-red-600">
取消預約
</button>
) : (
<span className="text-sm text-slate-400">(無權操作)</span>
);
return (
<div className="rounded border p-4">
<p>顧客:{booking.customerName}</p>
{/* 寫法 2:短路 AND,條件成立才渲染 */}
{booking.notes && <p className="mt-2 text-sm">備註:{booking.notes}</p>}
{/* 寫法 3:把條件渲染抽成變數,主 JSX 保持乾淨 */}
<div className="mt-3">{ownerArea}</div>
</div>
);
}
三種寫法的選擇原則:第一,「要嘛 A、要嘛 B」用三元;第二,「只在條件成立時渲染」用短路 &&(但要注意 falsy 值——例如 0 會被短路掉,要嘛轉 boolean {count > 0 && ...},要嘛用 {count ? ... : null});第三,條件太複雜(例如巢狀 if/else)就抽成變數或子元件,主 JSX 才能保持可讀。Day 7 會更深入談清單渲染與 key 的選擇,這是另一個「渲染模型」的核心議題。
另一個值得學的細節是「null 在 JSX 裡是合法的」。React 允許元件回傳 null(表示「這次什麼都不渲染」),這對權限控管、錯誤頁、A/B 測試都很有用。例如:
// 用 null 控制是否渲染
function AdminPanel({ isAdmin }: { isAdmin: boolean }) {
if (!isAdmin) return null; // 沒權限就什麼都不渲染
return <div className="rounded border p-4">管理員專區</div>;
}
回傳 null 跟「回傳 <></> Fragment」不同:前者完全沒有 DOM 節點,後者有 Fragment 包裹。後端寫過條件判斷「這個使用者能不能看後台」的人會覺得這個觀念很自然——權限不夠就直接 403,前端對應的就是「什麼都不渲染」。
把 Day 4 的工具型別套到元件 props
今天寫的 Button 與 Card 元件只是基本款。實務上 props 型別經常需要「從某個領域物件衍生」——例如「只挑出顯示需要的欄位」、「省略某些欄位」、「把所有欄位變可選」。Day 4 學的工具型別在這裡非常好用:
Pick:BookingSummaryProps 只要顯示姓名與編號,從 Booking 裡挑出需要的欄位。Omit:UserCardProps 不應該收到密碼,從 User 裡把 password 拿掉。Partial:表單初值可以是部分欄位,不必所有欄位都先給。
這三個用法把「領域型別」與「UI 型別」綁在一起:UI 不必自己重新宣告欄位,而是從領域型別衍生。當後端改欄位時,前端的元件型別會自動跟著改,編譯器會立刻指出哪些元件需要更新。這個觀念延伸到 Day 13 的元件設計原則、Day 16 的專案結構——所有 UI 型別都應該能追溯到領域型別,而不是各元件自己維護一份。
另一個常用模式是「擴充既有元件的 props」。例如 Day 14 會用 ComponentProps 拿到 Button 內部的所有 props,再加上一兩個新欄位,就能寫出「升級版的 Button」而不必重新宣告所有 props 欄位。這個模式 React 19 的社群套件(例如 shadcn/ui、Mantine、Chakra)幾乎都在用,理解之後看套件 API 文件會輕鬆很多。
小結
今天我們正式進入 React:函式元件的寫法、JSX 的規則(單一根、駝峰、大括號、self-close)、props 的三條鐵律(唯讀、父決定、改變觸發重渲染)、解構與預設值、children 的組合用法,並用一個完整的預約頁版面把 Card、Button、Layout 串起來。明天 Day 6 我們會進入「狀態」:useState 怎麼用、為什麼 React 要把狀態託管起來、事件處理怎麼接。讀完 Day 6,你寫的元件就能「根據互動改變內容」了。
結語
明天,我們會把 useState 與事件處理一次學完:「按按鈕換數字」、「按按鈕切換頁籤」、「按按鈕送出表單」這些最常見的互動怎麼寫。Day 6 結束時,你的元件就能從「死的展示」變成「活的互動」。Day 7 接著進入渲染模型:條件渲染、清單渲染、key 的角色——理解 React 怎麼決定「哪些 DOM 要重畫、哪些不動」。
延伸資源
- React 官方〈Thinking in React〉(19.x):
https://react.dev/learn/thinking-in-react - React 官方〈Your First Component〉:
https://react.dev/learn/your-first-component - React 官方〈Passing Props to a Component〉:
https://react.dev/learn/passing-props-to-a-component - React 官方〈Writing Markup with JSX〉:
https://react.dev/learn/writing-markup-with-jsx - MDN〈React 入門〉:
https://developer.mozilla.org/zh-TW/docs/Learn/Tools_and_testing/Client-side_JavaScript_frameworks/React_getting_started
留言
張貼留言