閱讀書架包含多個重複部分:標題、搜尋欄、書籍項目,以及選取項目後顯示的說明。直接操作 DOM 時,我們逐步指示程式尋找及修改元素。使用 React 時,我們描述目前資料對應的介面,並在事件發生時更新資料。React 計算新的介面描述,再把所需變更提交至瀏覽器 DOM。
當多個元素依賴同一個選擇時,這種組織方法尤其清楚。選中書籍的按鈕文字、按下狀態及說明應該一致;一個狀態值便能共同決定這些輸出。
元件描述資料的呈現方式
定義
元件、props 與 state
元件是描述介面某一部分的可重用單位。Props 是父元件傳入的值。State 是 React 在多次渲染之間為元件保留的資料。一次渲染會按照當次可用的 props 與 state,計算介面描述。
函式元件傳回 React 元素。元件名稱以大寫字母開頭,讓 JSX 能把它與 p、button 等內建元素區分。
function ShelfHeading({ title, total }) {
return (
<header>
<h1>{title}</h1>
<p>{total} books available</p>
</header>
);
}
function ShelfIntro() {
return <ShelfHeading title="Reading shelf" total={3} />;
}
ShelfIntro 傳入兩個 props,ShelfHeading 透過參數解構讀取它們。字串放在引號內;數字則以大括號內的 JavaScript 運算式傳入。同一個元件定義可以產生兩個具有不同 props 的 ShelfHeading,重用結構並顯示不同內容。
元件應把 props 視為唯讀輸入。以 prop 傳入的物件仍然是 JavaScript 物件,改動其欄位會修改共用資料。元件應透過資料擁有者傳入的回呼函式提出變更要求。釐清資料由誰擁有,介面的變化便更容易追蹤。
React 是描述介面的程式庫;較完整的應用程式可以在它周圍加入路由、資料存取及建構工具。元件重用或虛擬樹狀結構本身並不保證每種實作都快。首先要建立的是資料與顯示結果之間清晰的關係。
JSX 是描述元素的 JavaScript 語法
JSX 外觀近似 HTML,但一般瀏覽器執行之前,必須先把它轉換成 JavaScript。轉換結果是描述 React 元素的值,而非 HTML 原始碼字串。專案的建構程序可以完成轉換,並解析已安裝 React 套件的匯入。
本單元的例子是在已配置 JSX 轉換的 React 專案內使用的模組。客戶端渲染的進入點可以把匯出的 ReadingShelf 元件掛載到已存在、內容為空而 ID 為 app 的 div:
import { createRoot } from "react-dom/client";
import ReadingShelf from "./ReadingShelf.jsx";
const container = document.getElementById("app");
const root = createRoot(container);
root.render(<ReadingShelf />);
進入點執行前,容器必須已存在。對這個根節點呼叫一次 createRoot,其後由元件管理內部內容。若已有 React 在伺服器產生的 HTML,應採用 hydration,而非這個空容器掛載程序。較早的 ReactDOM.render 屬於舊版進入點 API,React 19 已移除它。
即使很小的元件,也涉及多項 JSX 規則:
| 規則 | 例子 | 原因 |
|---|---|---|
| 把相鄰 JSX 元素組合起來 | <>...</> | 一個 return 運算式產生一個值。Fragment 能組合子元素而不額外加入 DOM 包裝。 |
| 閉合每個元素 | <input /> | 即使是 HTML 的 void element,JSX 亦要求閉合語法。 |
| 使用 JSX 屬性名稱 | className、htmlFor、onClick | 屬性遵循 React DOM 介面;aria-* 與 data-* 保留連字號。 |
| 把 JavaScript 運算式放入大括號 | {book.title} | 括號內按 JavaScript 求值。if 等陳述式應放在運算式外或函式內。 |
| 以物件提供樣式 | style={{ maxWidth: "36rem" }} | 外層大括號進入 JavaScript;內層大括號建立物件。樣式屬性名稱採用 camelCase。 |
以大括號插入的普通字串會顯示為文字。例如,書名包含 <draft> 時,介面會顯示這些字元。物件本身不適合作為文字子節點;應渲染 book.title,而非 {book}。
由一個元件擁有書架的選擇狀態
例題
把小元件組合成完整介面
在 React 專案內把以下模組存為 ReadingShelf.jsx。所有資料及互動都保留在瀏覽器目前的元件樹內。
import { useState } from "react";
const books = [
{ id: "markup", title: "Meaningful markup", note: "Describe content relationships." },
{ id: "events", title: "Event traces", note: "Follow one action through its state changes." },
{ id: "layout", title: "Flexible layouts", note: "Adapt presentation to available space." },
];
function BookEntry({ book, selected, onChoose }) {
return (
<li>
<h2>{book.title}</h2>
<button
type="button"
aria-pressed={selected}
onClick={() => onChoose(book.id)}
>
{selected ? "Hide discussion note" : "Show discussion note"}
</button>
{selected && <p>{book.note}</p>}
</li>
);
}
export default function ReadingShelf() {
const [query, setQuery] = useState("");
const [selectedId, setSelectedId] = useState(null);
const search = query.trim().toLowerCase();
const visibleBooks = books.filter(book =>
book.title.toLowerCase().includes(search)
);
function chooseBook(id) {
setSelectedId(previous => previous === id ? null : id);
}
return (
<main style={{ maxWidth: "36rem" }}>
<h1>Reading shelf</h1>
<label htmlFor="reading-query">Search title</label>
<input
id="reading-query"
value={query}
onChange={event => setQuery(event.target.value)}
/>
<p>{visibleBooks.length} matching books</p>
{visibleBooks.length === 0 ? (
<p>No titles match this search.</p>
) : (
<ul>
{visibleBooks.map(book => (
<BookEntry
key={book.id}
book={book}
selected={selectedId === book.id}
onChoose={chooseBook}
/>
))}
</ul>
)}
</main>
);
}
從初始狀態開始,追蹤搜尋與選擇的變化:
| 動作 | query | selectedId | 可見結果 |
|---|---|---|---|
| 初次渲染 | "" | null | 顯示全部三個書名,沒有討論說明。 |
輸入 event | "event" | null | 只顯示 Event traces,符合數量是 1。 |
| 選取該項目 | "event" | "events" | 顯示其說明 “Follow one action through its state changes.”。 |
把搜尋改成 layout | "layout" | "events" | 只顯示 Flexible layouts,沒有說明;已選中的書被篩走。 |
| 清除搜尋 | "" | "events" | 全部三個書名再次出現,Event traces 的說明亦再次顯示。 |
書架擁有 query 與 selectedId。BookEntry 接收書籍物件、布林選擇值及回呼函式。按下按鈕時,回呼接收書籍 ID。書架更新自己的狀態,下一次渲染便為每個可見項目計算新的 props。搜尋只改變 query,不會清除 selectedId,因此產生表格最後兩行的結果。
說明文字、按鈕標籤及 aria-pressed 都由同一個 selected prop 推導,因此一致地表達同一選擇,無須分別保留三個 state。同樣地,visibleBooks 及其長度由 books 與 query 計算;另外保留一個獨立更新的「符合數量」,反而會增加維持資料一致的工作。
資料透過 props 從書架流向各項目。事件則透過傳入的函式到達資料擁有者。這並非讓子元件直接改寫父元件狀態;回呼如何處理事件仍由父元件決定。
思考檢查
沿元件樹追蹤一次選擇
起初 selectedId 是 null。依次按下 ID 為 events 的項目、ID 為 layout 的項目,再按一次 layout。每次點擊後,哪段說明會顯示?
解答 · 追蹤父元件狀態與推導出的 props
狀態先變成 events,所以只有該項目接收 selected=true。接着變成 layout,events 說明消失,layout 說明顯示。最後一次更新發現前一個 ID 已是 layout,於是傳回 null,兩段說明都隱藏。一個選中的 ID 決定所有可見項目的狀態。
狀態更新會安排另一次渲染
useState(initialValue) 傳回當次渲染的狀態值及 setter 函式。呼叫 setter 是提出更新要求,不會改寫正在執行的事件處理函式已捕捉的區域變數。當新狀態依賴前一狀態時,更新函式可以明確表達這個依賴。
例題
追蹤兩個待處理的狀態更新
假設元件顯示 pages,以 useState(4) 初始化它,並在按鈕被點擊時呼叫以下處理函式一次:
function recordTwoPages() {
setPages(previous => previous + 1);
setPages(previous => previous + 1);
}
若起初 pages = 4,第一個更新函式產生 5;第二個接收這個待處理結果,產生 6。若同一次渲染的處理函式改為呼叫兩次 setPages(pages + 1),兩次都要求以 5 取代狀態。在該處理函式內,即使已呼叫 setter,立即讀取捕捉到的 pages 仍會得到 4。
因此,兩個更新函式使下一次提交顯示 6;兩個取代要求則使它顯示 5。遞增幅度取決於佇列中的操作,並非只看 setter 呼叫了多少次。
| 階段 | 當時可用的資料 | 結果 |
|---|---|---|
| 事件處理函式 | 所屬渲染的狀態快照 | 可以計算值及把更新放入佇列。 |
| 渲染 | 更新後的狀態及目前 props | React 呼叫元件,計算下一個介面。 |
| 提交 | 已計算的變更 | React 更新瀏覽器 DOM,使它反映結果。 |
渲染應計算介面,不應在其中改動無關物件或建立計時器。React 可能再次呼叫渲染邏輯,因此放在其中的可觀察動作可能比作者預期執行更多次。使用者按鈕動作放在事件處理函式;與外部瀏覽器功能的同步工作則放在適當的生命週期位置。
常見錯誤
改寫變數或直接修改物件,並不會提出預期的狀態更新
函式元件每次執行都會重新建立區域變數;對它賦值不會要求 React 渲染。同樣地,直接修改已儲存在 state 的物件,再傳回同一個物件參照,可能無法觸發預期更新。應建立下一個物件或陣列,再使用 setter,例如 setBook(previous => ({ ...previous, read: true }))。
事件、條件與受控欄位
onClick={chooseBook} 傳入函式;onClick={chooseBook(id)} 則在渲染期間立即呼叫函式,再把其傳回值放入事件 prop。要在點擊發生時才傳入引數,可以寫 onClick={() => chooseBook(id)}。
條件運算式 selected ? a : b 選擇其中一個結果。selected && <p>...</p> 在 selected 為真時產生段落,為假時則產生不顯示的布林值。數量判斷應寫成 count > 0 && ... 等布林條件;count && ... 可能顯示 0,因為零是可以渲染的數字。
搜尋輸入框是受控欄位。它顯示的值來自 query,而變更處理函式把新輸入的文字存入該狀態。
例題
從輸入到清單,追蹤一次搜尋編輯
書架起初的 query 是空字串,三本書全部符合,輸入框為空,符合數量是 3。假設一次編輯把輸入文字改成 event。
- 變更處理函式讀取
event.target.value,其值是字串"event"。 setQuery要求以該字串作為下一狀態。- 下一次渲染計算
search = "event",再篩選書名。只有Event traces包含這段搜尋文字。 - 提交後顯示一本書及數量
1,並把"event"作為value傳回輸入框。
欄位與清單都由 query 推導,因此兩者現在一致。若處理函式沒有更新狀態,受控欄位會繼續接收原先的空值,清單亦仍然顯示全部三本書。
受控文字欄位應以字串初始化,並在整個生命週期維持字串值。受控核取方塊使用 checked,從 event.target.checked 讀取選取狀態,而非讀取文字值。
Key 在多次渲染之間辨認項目
map 把可見資料轉成項目元素。React 使用每個 key,把不同渲染中的同層項目配對。書籍原有的 ID 能辨認該書,即使另一書被移除或順序改變亦然。Key 是協調元素身分的特殊資料;若子元件本身需要 ID,亦須透過 book 等普通 prop 傳入。
以陣列索引作為 key,辨認的是位置。若位置零原先是 markup 書,後來改成 events 書,React 可能把該 key 位置上的狀態套到另一項目。穩定 ID 能在篩選及排序後保留原本的項目身分。每次渲染產生隨機 key 則相反:它每次都賦予項目新的身分。
本例的選擇狀態由父元件擁有,篩選不會改動 selectedId。因此,用搜尋隱藏選中書籍時,父元件仍保留選擇;清除搜尋後,該書的說明再次出現。若狀態由書籍項目自己擁有,從渲染樹移除項目便會卸載它;往後重新加入時,會初始化一個新實例。
閱讀 class 元件與生命週期工作
函式元件與 class 元件都把輸入、保留狀態及渲染分開。Class 元件繼承 Component,讀取 this.props,初始化 this.state,並透過 this.setState 更新狀態。其 render 方法傳回介面描述。例如:
import { Component } from "react";
class PageCounter extends Component {
state = { pages: 0 };
recordPage = () => {
this.setState(previous => ({ pages: previous.pages + 1 }));
};
render() {
return (
<button type="button" onClick={this.recordPage}>
Pages recorded: {this.state.pages}
</button>
);
}
}
箭頭函式欄位保留實例的 this。普通方法若作為回呼並存取實例,就需要合適的綁定。Class 的 setState 會把物件更新作淺層合併;useState setter 則取代該 Hook 的整個值。若在建構函式內用 props 建立 class 狀態,使用 this 前應先呼叫 super(props)。
掛載會把元件加入樹中;更新會提供改變後的 props 或 state;卸載會把它移除。Class 生命週期方法包括 componentDidMount、componentDidUpdate 及 componentWillUnmount。較早的 componentWillMount、componentWillReceiveProps 及 componentWillUpdate 屬於舊式 API,不宜作為新元件程式的起點。
函式元件需要與外部系統同步時,可以使用 Effect。計時器是一個簡單的本地例子:
例題
從掛載到卸載,追蹤計時器
考慮一個狀態從零開始的新 ReadingClock,先追蹤沒有額外開發模式 Strict Mode 檢查的正常生命週期:
import { useEffect, useState } from "react";
function ReadingClock() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setSeconds(previous => previous + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
return <p>Elapsed timer ticks: {seconds}</p>;
}
把 setup 建立的 interval 稱為 A。更新函式避免捕捉過時的 seconds 值,因此每次觸發會作用於前一個待處理狀態。
| 時刻 | 狀態與顯示結果 | Effect 與 interval |
|---|---|---|
| 初次渲染及提交 | seconds 是 0,段落顯示 0。 | Setup 尚未建立 interval。 |
| Effect setup | 狀態與文字仍是 0。 | Setup 建立 interval A。 |
| 第一次觸發、更新及提交 | 更新函式把 0 改成 1,段落顯示 1。 | [] 依賴未改變,A 繼續運作。 |
| 狀態未變的另一次渲染提交 | 段落仍顯示 1。 | 不會建立新的 interval。 |
| 卸載 | 段落被移除。 | Cleanup 對 A 呼叫 clearInterval,不再有 interval 運作。 |
這裏 setup 沒有讀取會改變的 props 或 state,因此空依賴陣列適用。若省略依賴引數,Effect 會在此元件每次渲染提交後先清理再設定,包括由計時器觸發更新的那次。只要保留 cleanup,就仍然只有一個 interval,但其排程會不斷重新開始。若 Effect 讀取會改變的 props 或 state,就需要在依賴中反映那些響應式值。
若在開發模式下於應用程式根部啟用 Strict Mode,初次 Effect 亦會經歷 setup → cleanup → setup 檢查。Interval A 會在替代計時器啟動前取消,最後保留一個運作中的 interval。這是在檢查設定與清理是否平衡,並非要求第二個永久計時器。瀏覽器排程延誤時,計時器觸發次數亦未必等於精確的實際秒數。
練習
思考檢查
練習 1:預測兩次排隊的取代更新
某次渲染的 pages 為 7,其事件處理函式呼叫兩次 setPages(pages + 1)。兩次要求的取代值是多少?請改寫成依照前一個待處理值增加兩次。
解答 · 練習 1 解答
兩次都計算 7 + 1,因此都要求 8。改為呼叫兩次 setPages(previous => previous + 1),便會把兩次遞增放入佇列,得到 9。更新函式的參數是前一個待處理狀態;捕捉到的 pages 則屬於處理函式所在的渲染。
思考檢查
練習 2:解釋無法編輯的搜尋欄
元件渲染 <input value={query} />,其中 query 是初始化為空字串的 state。為何輸入無法形成可編輯的狀態驅動欄位?請補上缺少的 prop。
解答 · 練習 2 解答
元件控制了顯示值,卻沒有提供更新該值的處理函式。加入 onChange={event => setQuery(event.target.value)};每次編輯便會要求新狀態,讓下一次渲染把它作為 value 傳回輸入框。
思考檢查
練習 3:保留書籍身分
三本書的 ID 是 markup、events 及 layout,篩選移除了第一本。解釋以陣列索引作 key 時,篩選前後各辨認甚麼,再選擇合適的 key。
解答 · 練習 3 解答
索引零原先辨認 markup 的位置,其後辨認 events 的位置。這可能保留位置上的元件狀態,而非對應書籍的狀態。使用 key={book.id},events 書在篩選前後便保留同一 ID。
思考檢查
練習 4:找出資料擁有者與清理工作
選中 layout 書後,以搜尋把它隱藏,再清除搜尋。解釋書架為何保留選擇。另外,若每次渲染都建立一個計時器卻從不取消,會發生甚麼事?
解答 · 練習 4 解答
selectedId 屬於書架,搜尋只改變 query。因此隱藏期間它仍是 layout,項目再出現時便接收 selected=true。每次渲染都建立計時器會累積多個獨立 interval。計時器 Effect 應配對 setup 與 cleanup,使同步停止後不會留下仍在運作的 interval。
同樣的狀態與事件推理,亦可從普通瀏覽器物件開始理解,見DOM 事件與物件狀態。