React 可以改變你看待設計與建立應用程式的思考方式。當你使用 React 建立使用者介面時,第一步你需要先把它拆解成「元件」。接著,你要描述每個元件不同的畫面狀態。最後,把這些元件連結起來,讓資料可以在它們之間流動。在本教學中,我們將引導你透過 React 打造一個可搜尋的產品資料表格,並了解其中的思考過程。
從 mockup 開始
想像你已經從設計師那裡拿到了 JSON API 和 mockup。
JSON API 回傳的資料如下所示:
[
{ category: "Fruits", price: "$1", stocked: true, name: "Apple" },
{ category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit" },
{ category: "Fruits", price: "$2", stocked: false, name: "Passionfruit" },
{ category: "Vegetables", price: "$2", stocked: true, name: "Spinach" },
{ category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin" },
{ category: "Vegetables", price: "$1", stocked: true, name: "Peas" }
]
Mockup 看起來像這樣:

在 React 中實作 UI ,通常會依照以下五個步驟進行。
第一步:將 UI 拆解成一層層的元件
首先,把 mockup 中每一個元件與子元件框起來,並為它們命名。如果你是跟設計師合作的話,他們可能已經在設計工具中幫這些元件命名好了。問問他們吧!
依據你的專業背景,你可以用不同的方式來思考如何將設計拆解成元件:
- 程式設計—就像你寫程式時會判斷是否該建立新的函式或物件一樣,也可以用相同的技巧來拆元件。其中一個常見的技巧叫做 單一職責原則,也就是說,理想的情況下,每個元件應該只做一件事情。如果某個元件隨著開發越來越複雜,它就應該被分解成更小的子元件。
- CSS—思考會在那些地方使用類別選擇器 (不過元件通常並不會拆解得像 CSS 那麼細)
- Design—思考你會如何安排設計稿的圖層結構
假如你的 JSON 架構設計非常棒,通常會發現它可以很自然地對應到 UI 的元件架構。那是因為 UI 與資料模型通常會擁有相同的資訊架構 — 也就是相同的結構。將 UI 拆成一個個元件,讓每一個元件都能對應到資料模型中的一部分。
以下畫面包含了五個元件:

FilterableProductTable
(灰色)是整個應用程式的容器。SearchBar
(藍色)用來接收使用者的輸入。ProductTable
(淡紫色)會根據使用者的輸入顯示並篩選清單。ProductCategoryRow
(綠色)用來顯示每個類別的標題。ProductRow
(黃色)顯示每筆產品資料的一列。
若你查看 ProductTable
(淡紫色)時,會發現表格的表頭(包含 “Name” 和 “Price” 標籤 )並不是獨立的元件。這純粹是偏好的問題,你可以選擇任何一種作法。以這裡為例,表頭被視為 ProductTable
的一部分,因為它出現在 ProductTable
的清單中。不過,當這個表頭變得更加複雜(例如加入了排序功能),你就可以把它抽出成獨立的 ProductTableHeader
元件。
現在你已經辨識出所有 mockup 裡的元件了,接下來要將它們整理成層級結構。只要在 mockup 中出現在其他元件裡的元件,都應該以子元件的層級呈現:
FilterableProductTable
SearchBar
ProductTable
ProductCategoryRow
ProductRow
第二步:使用 React 建立靜態版本
現在你已經有元件的層級結構,是時候開始實作你的應用程式了。最直接的方式是先建立一個根據資料模型來渲染 UI ,且暫時不增加任何互動功能的版本…沒錯!通常都會先完成靜態版本,再慢慢加入互動。建立靜態版本的過程需要大量的打字,但幾乎不太需要思考;而加入互動則相反,它需要大量思考,但打得字並不多。
為了建立一個根據資料模型來渲染畫面的靜態版本應用程式,你需要建立可以重複使用的 元件 ,並透過 props. 來傳遞資料,Props 是一個可以將資料從父元件傳遞到子元件的方法。(如果你已經熟悉 state的概念,請不要在這個靜態版本中使用到 state 。 State 是專門用來處理互動的,也就是那些會隨時間改變的資料。因此在這個靜態版本的應用程式中,你不需要用到它。)
你可以選擇「由上而下」的方式來開發,從層級較高的元件(像是 FilterableProductTable
)開始建立,也可以選擇「由下而上」的方式,從層級較低的元件(像是 ProductRow
)開始。在簡單的例子中,通常由上而下會比較簡單,但在較大型的專案中,則由下而上會比較容易。
function ProductCategoryRow({ category }) { return ( <tr> <th colSpan="2"> {category} </th> </tr> ); } function ProductRow({ product }) { const name = product.stocked ? product.name : <span style={{ color: 'red' }}> {product.name} </span>; return ( <tr> <td>{name}</td> <td>{product.price}</td> </tr> ); } function ProductTable({ products }) { const rows = []; let lastCategory = null; products.forEach((product) => { if (product.category !== lastCategory) { rows.push( <ProductCategoryRow category={product.category} key={product.category} /> ); } rows.push( <ProductRow product={product} key={product.name} /> ); lastCategory = product.category; }); return ( <table> <thead> <tr> <th>Name</th> <th>Price</th> </tr> </thead> <tbody>{rows}</tbody> </table> ); } function SearchBar() { return ( <form> <input type="text" placeholder="Search..." /> <label> <input type="checkbox" /> {' '} Only show products in stock </label> </form> ); } function FilterableProductTable({ products }) { return ( <div> <SearchBar /> <ProductTable products={products} /> </div> ); } const PRODUCTS = [ {category: "Fruits", price: "$1", stocked: true, name: "Apple"}, {category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit"}, {category: "Fruits", price: "$2", stocked: false, name: "Passionfruit"}, {category: "Vegetables", price: "$2", stocked: true, name: "Spinach"}, {category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin"}, {category: "Vegetables", price: "$1", stocked: true, name: "Peas"} ]; export default function App() { return <FilterableProductTable products={PRODUCTS} />; }
(如果你覺得這段程式碼有點困難,不妨先看看 快速開始 章節)
當你建立好元件後,你就會擁有一個可重複使用的元件庫,用來渲染你的資料模型。由於這是一個靜態應用程式,所以元件只會回傳 JSX ,不包含任何互動邏輯。最上層的元件(FilterableProductTable
)會將資料模型作為 prop 傳入。這種資料從頂層元件一路向下傳遞到樹狀結構底部元件的方式,被稱為 單向資料流。
第三步:找到最小但最完整的 UI 狀態
為了使 UI 具備互動性,你需要讓使用者可以改變底層的資料模型。這時就會用到 state 。
可以把 state 想成是應用程式中必須記住,且會變動的最小資料集合。在設計 state 時最重要的原則是保持 DRY (Don’t Repeat Yourself,不要自我重複) 。也就是說,你要找出應用程式中真正需要紀錄的最小 state,其他的部分則在需要時再去即時計算出來。舉例來說,假如你在建立一個購物清單,商品項目以陣列方式儲存在 state 裡。如果你還想顯示清單中的項目數量,不要再用額外的 state 來儲存 — 反之,你應該直接讀取陣列的長度來取得數量。
現在來思考一下這個應用程式範例中所有的資料:
- 原始的產品清單
- 使用者輸入的搜尋文字
- Checkbox 的勾選狀態
- 篩選後的產品清單
其中哪些資料應該放進 state 呢?試著辨識出其中不是 state 的項目:
- 它會隨時間變化 保持不變 嗎? 會的話,那它就不是 state。
- 它是透過 props 從父元件傳進來的 嗎? 是的話,那它就不是 state。
- 你能根據元件中現有的 state 或 props 計算出它嗎? 可以的話,那它 絕對 不是 state!
剩下的那些資料,很可能就是要放進 state 的了。
讓我們再一起逐項看過這些資料:
- 原始的產品清單會 透過 props 傳遞,所以它不能放進 state。
- 搜尋文字看起來是 state ,因為它會隨時間變化而改變,且不能從任何東西計算出來。
- checkbox 的狀態看起來可以放進 state ,因為它會隨時間變化而改變,且不能從任何東西計算出來。
- 篩選後的產品清單 不能放進 state ,因為它可以透過原始的產品清單被計算出來 ,且篩選本身是根據搜尋文字與 checkbox 狀態而來。
這代表只有搜尋文字與 checkbox 狀態應該被放進 state 中!做得好!
Deep Dive
在 React 中有兩種資料「模型」: props 和 state 。 它們兩者非常的不同:
- Props 就像你傳給函式的參數 。它們讓父元件能傳遞資料給子元件,並且客製化子元件的顯示。舉例來說,
Form
可以傳遞color
prop 到Button
。 - State 就像元件的記憶 。它讓元件可以持續追蹤一些資訊,並且根據互動來改變這些資訊。舉例來說,
Button
可能會持續追蹤isHovered
這個 state 。
Props 和 state 雖然不同,但它們會一起運作。父元件通常會在 state 中存放一些資訊(這樣才能去改變它),然後再透過 props 往下傳遞 到子元件。如果第一次閱讀到這裡,對於它們的不同還是感到模糊也沒關係。這需要一些練習,才能真正熟悉!
第四步:辨識 state 應該放在哪裡
辨識完應用程式最小的 state 資料後,你需要辨識出哪個元件應該負責改變這個 state ,或者說 擁有 這個 state。記住: React 使用單向資料流,資料會透過元件階層,從父元件往下傳遞到子元件。剛開始也許沒辦法立刻清楚哪個元件該擁有哪個 state 。如果你第一次接觸到這個概念將會是一大挑戰,但你可以根據以下步驟來理解!
針對你應用程式中的每一個 state:
- 找出 所有 依賴該 state 來渲染畫面的元件。
- 找出這些元件最近的共同父元件 — 也就是在元件層級中,位於它們之上的某個元件。
- 決定 state 該放在哪裡:
- 通常可以將 state 放在它們的共同父元件中。
- 你也可以將 state 放在它們共同父元件之上的元件。
- 如果沒辦法找到合理的元件來擁有 state,單獨建立一個新元件來處理 state,並且將這個新元件新增在元件階層中高於共同父元件上的位置。
在前一個步驟中,你會發現這個應用程式中會有兩個 state:使用者輸入的搜尋文字,和 checkbox 的勾選狀態。在這個範例中,它們總是一起出現,所以將它們放在同一個元件中是合理的。
現在,讓我們來為這些 state 套用我們的策略 :
- 找出使用 state 的元件:
ProductTable
需要透過 state(搜尋文字與 checkbox 勾選狀態)來過濾產品清單。SearchBar
需要顯示這些 state(搜尋文字與 checkbox 勾選狀態)的內容。
- 找出它們的共同父元件: 兩個 state 最近的共同父元件是
FilterableProductTable
。 - 決定 state 要放在哪裡: 我們會把搜尋文字與勾選狀態這兩個 state 值保存在
FilterableProductTable
。
所以這兩個 state 值會被放在 FilterableProductTable
裡。
使用 useState()
Hook 將 state 新增進元件裡。 Hook 是一種特殊的函式,可以讓你在 React 中「鉤住」元件的生命週期與行為。在 FilterableProductTable
的頂端新增兩個 state 變數,並且為它們設定初始值:
function FilterableProductTable({ products }) {
const [filterText, setFilterText] = useState('');
const [inStockOnly, setInStockOnly] = useState(false);
接著,將 filterText
和 inStockOnly
這兩個值作為 props,傳遞給 ProductTable
和 SearchBar
:
<div>
<SearchBar
filterText={filterText}
inStockOnly={inStockOnly} />
<ProductTable
products={products}
filterText={filterText}
inStockOnly={inStockOnly} />
</div>
你開始可以看見應用程式是如何運作的了。在下方 sandbox 程式碼中,將 useState('')
改成 useState('fruit')
來改變 filterText
的初始值。你將會看到搜尋輸入的文字與表格兩者都會隨之更新:
import { useState } from 'react'; function FilterableProductTable({ products }) { const [filterText, setFilterText] = useState(''); const [inStockOnly, setInStockOnly] = useState(false); return ( <div> <SearchBar filterText={filterText} inStockOnly={inStockOnly} /> <ProductTable products={products} filterText={filterText} inStockOnly={inStockOnly} /> </div> ); } function ProductCategoryRow({ category }) { return ( <tr> <th colSpan="2"> {category} </th> </tr> ); } function ProductRow({ product }) { const name = product.stocked ? product.name : <span style={{ color: 'red' }}> {product.name} </span>; return ( <tr> <td>{name}</td> <td>{product.price}</td> </tr> ); } function ProductTable({ products, filterText, inStockOnly }) { const rows = []; let lastCategory = null; products.forEach((product) => { if ( product.name.toLowerCase().indexOf( filterText.toLowerCase() ) === -1 ) { return; } if (inStockOnly && !product.stocked) { return; } if (product.category !== lastCategory) { rows.push( <ProductCategoryRow category={product.category} key={product.category} /> ); } rows.push( <ProductRow product={product} key={product.name} /> ); lastCategory = product.category; }); return ( <table> <thead> <tr> <th>Name</th> <th>Price</th> </tr> </thead> <tbody>{rows}</tbody> </table> ); } function SearchBar({ filterText, inStockOnly }) { return ( <form> <input type="text" value={filterText} placeholder="Search..."/> <label> <input type="checkbox" checked={inStockOnly} /> {' '} Only show products in stock </label> </form> ); } const PRODUCTS = [ {category: "Fruits", price: "$1", stocked: true, name: "Apple"}, {category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit"}, {category: "Fruits", price: "$2", stocked: false, name: "Passionfruit"}, {category: "Vegetables", price: "$2", stocked: true, name: "Spinach"}, {category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin"}, {category: "Vegetables", price: "$1", stocked: true, name: "Peas"} ]; export default function App() { return <FilterableProductTable products={PRODUCTS} />; }
請注意,目前編輯表格的功能還無法運作。在上方 sandbox 中,有一個 console 的錯誤訊息解釋為什麼無法運作:
在上方的 sandbox 中,ProductTable
和 SearchBar
會讀取從 props傳入的 filterText
和 inStockOnly
來渲染表格、文字輸入框與 checkbox。舉例來說,下面這段程式碼就是 SearchBar
顯示值的方式:
function SearchBar({ filterText, inStockOnly }) {
return (
<form>
<input
type="text"
value={filterText}
placeholder="Search..."/>
不過,你目前還沒有加入任何的程式碼來處理使用者的操作,例如輸入文字。這將會是你接下來的最後一個步驟。
第五步:加入反向資料流
目前應用程式已經能透過 props 和 state 隨著階層結構往下流正確地渲染。但為了能根據使用者的輸入來改變 state,就需要支援資料往反方向流動:也就是在階層中最底部的表單元件,要能更新 FilterableProductTable
中的 state。
React 讓這種資料流變得更加明確,但它比起雙向資料綁定需要多寫一點程式碼。如果你試著在上方的範例中輸入文字或勾選 checkbox,你將發現 React 會忽略你的輸入。這是刻意設計的。當寫下 <input value={filterText} />
時,其實是把 input
的 value
prop 設定為 FilterableProductTable
提供的 filterText
state。因此只要 filterText
不被更新,輸入框內的文字也就永遠無法改變。
你希望無論使用者何時修改表單輸入,state 都能依據這些變化來更新。但因為這些 state 是被 FilterableProductTable
所擁有的,所以只有它可以呼叫 setFilterText
和 setInStockOnly
。為了使 SearchBar
能夠更新 FilterableProductTable
的 state,你需要傳遞將這些函式往下傳遞到 SearchBar
:
function FilterableProductTable({ products }) {
const [filterText, setFilterText] = useState('');
const [inStockOnly, setInStockOnly] = useState(false);
return (
<div>
<SearchBar
filterText={filterText}
inStockOnly={inStockOnly}
onFilterTextChange={setFilterText}
onInStockOnlyChange={setInStockOnly} />
在 SearchBar
裡,你將新增 onChange
事件處理器,並透過它們來更新父元件的 state:
function SearchBar({
filterText,
inStockOnly,
onFilterTextChange,
onInStockOnlyChange
}) {
return (
<form>
<input
type="text"
value={filterText}
placeholder="Search..."
onChange={(e) => onFilterTextChange(e.target.value)}
/>
<label>
<input
type="checkbox"
checked={inStockOnly}
onChange={(e) => onInStockOnlyChange(e.target.checked)}
現在應用程式可以完整運作了!
import { useState } from 'react'; function FilterableProductTable({ products }) { const [filterText, setFilterText] = useState(''); const [inStockOnly, setInStockOnly] = useState(false); return ( <div> <SearchBar filterText={filterText} inStockOnly={inStockOnly} onFilterTextChange={setFilterText} onInStockOnlyChange={setInStockOnly} /> <ProductTable products={products} filterText={filterText} inStockOnly={inStockOnly} /> </div> ); } function ProductCategoryRow({ category }) { return ( <tr> <th colSpan="2"> {category} </th> </tr> ); } function ProductRow({ product }) { const name = product.stocked ? product.name : <span style={{ color: 'red' }}> {product.name} </span>; return ( <tr> <td>{name}</td> <td>{product.price}</td> </tr> ); } function ProductTable({ products, filterText, inStockOnly }) { const rows = []; let lastCategory = null; products.forEach((product) => { if ( product.name.toLowerCase().indexOf( filterText.toLowerCase() ) === -1 ) { return; } if (inStockOnly && !product.stocked) { return; } if (product.category !== lastCategory) { rows.push( <ProductCategoryRow category={product.category} key={product.category} /> ); } rows.push( <ProductRow product={product} key={product.name} /> ); lastCategory = product.category; }); return ( <table> <thead> <tr> <th>Name</th> <th>Price</th> </tr> </thead> <tbody>{rows}</tbody> </table> ); } function SearchBar({ filterText, inStockOnly, onFilterTextChange, onInStockOnlyChange }) { return ( <form> <input type="text" value={filterText} placeholder="Search..." onChange={(e) => onFilterTextChange(e.target.value)} /> <label> <input type="checkbox" checked={inStockOnly} onChange={(e) => onInStockOnlyChange(e.target.checked)} /> {' '} Only show products in stock </label> </form> ); } const PRODUCTS = [ {category: "Fruits", price: "$1", stocked: true, name: "Apple"}, {category: "Fruits", price: "$1", stocked: true, name: "Dragonfruit"}, {category: "Fruits", price: "$2", stocked: false, name: "Passionfruit"}, {category: "Vegetables", price: "$2", stocked: true, name: "Spinach"}, {category: "Vegetables", price: "$4", stocked: false, name: "Pumpkin"}, {category: "Vegetables", price: "$1", stocked: true, name: "Peas"} ]; export default function App() { return <FilterableProductTable products={PRODUCTS} />; }
你可以在加入互動性章節中,學到所有關於事件處理與更新 state 的內容。
接下來該往哪裡走
這一張只是一個簡單的入門介紹,目的是告訴你如何用 React 思維建立元件與應用程式。現在你可以從安裝章節開始,或深入了解所有在本章節使用到的語法。