8.1 渲染列表
使用 JavaScript 的 map() 方法將陣列資料轉換為 JSX 元素。
function FruitList() {
const fruits = ['蘋果', '香蕉', '橘子', '葡萄', '西瓜'];
return (
<ul>
{fruits.map((fruit, index) => (
<li key={index}>{fruit}</li>
))}
</ul>
);
}
8.2 Key 的重要性
什麼是 Key?
Key 是 React 用來識別列表中每個元素的唯一標識符。它幫助 React 的差異比較演算法判斷哪些元素被新增、修改或刪除。
Key 的選擇
// ✅ 最佳:使用唯一且穩定的 ID
const users = [
{ id: 'u001', name: 'Alice' },
{ id: 'u002', name: 'Bob' },
];
users.map((user) => <UserCard key={user.id} user={user} />);
// ⚠️ 可接受:使用唯一的字串欄位
const countries = ['Taiwan', 'Japan', 'Korea'];
countries.map((country) => <li key={country}>{country}</li>);
// ❌ 避免:使用 index 作為 key(在列表會重新排序時有問題)
items.map((item, index) => <li key={index}>{item}</li>);
為什麼不該用 index 作為 Key?
import { useState } from 'react';
function KeyProblemDemo() {
const [items, setItems] = useState([
{ id: 1, text: '項目 A' },
{ id: 2, text: '項目 B' },
{ id: 3, text: '項目 C' },
]);
const addToTop = () => {
const newItem = { id: Date.now(), text: `新項目 ${items.length + 1}` };
setItems([newItem, ...items]); // 新增到最前面
};
return (
<div>
<button onClick={addToTop}>在最前面新增</button>
<h3>❌ 使用 index 作為 Key(有問題)</h3>
{items.map((item, index) => (
<div key={index} style={{ marginBottom: '8px' }}>
<span>{item.text}</span>
<input placeholder="輸入文字" />
</div>
))}
<h3>✅ 使用唯一 ID 作為 Key(正確)</h3>
{items.map((item) => (
<div key={item.id} style={{ marginBottom: '8px' }}>
<span>{item.text}</span>
<input placeholder="輸入文字" />
</div>
))}
</div>
);
}
在 input 中輸入文字後,點擊「在最前面新增」。 使用 index 作為 key 時,input 的內容會錯位; 使用 id 作為 key 時,一切正常。
Key 的規則
| 規則 | 說明 |
|---|---|
| 唯一性 | 在同一個列表中,key 必須唯一(兄弟之間) |
| 穩定性 | Key 不應該在渲染之間改變 |
| 不用 index | 如果列表可能重新排序、新增、刪除 |
| 不用 random | Math.random() 每次都不同,會導致每次都重新建立 DOM |
| 不是 prop | key 不會作為 prop 傳入子元件 |
8.3 渲染物件陣列
function StudentTable() {
const students = [
{ id: 1, name: 'Alice', grade: 95, subject: '數學' },
{ id: 2, name: 'Bob', grade: 82, subject: '英文' },
{ id: 3, name: 'Charlie', grade: 91, subject: '物理' },
{ id: 4, name: 'Diana', grade: 78, subject: '化學' },
{ id: 5, name: 'Eve', grade: 99, subject: '數學' },
];
const getGradeColor = (grade) => {
if (grade >= 90) return '#22543d';
if (grade >= 80) return '#744210';
return '#9b2c2c';
};
const getGradeBg = (grade) => {
if (grade >= 90) return '#c6f6d5';
if (grade >= 80) return '#fefcbf';
return '#fed7d7';
};
return (
<table style={{
width: '100%',
borderCollapse: 'collapse',
fontFamily: 'sans-serif',
}}>
<thead>
<tr style={{ backgroundColor: '#2d3748', color: 'white' }}>
<th style={{ padding: '12px', textAlign: 'left' }}>#</th>
<th style={{ padding: '12px', textAlign: 'left' }}>姓名</th>
<th style={{ padding: '12px', textAlign: 'left' }}>科目</th>
<th style={{ padding: '12px', textAlign: 'center' }}>成績</th>
</tr>
</thead>
<tbody>
{students.map((student, index) => (
<tr
key={student.id}
style={{
backgroundColor: index % 2 === 0 ? '#f7fafc' : 'white',
borderBottom: '1px solid #e2e8f0',
}}
>
<td style={{ padding: '12px' }}>{index + 1}</td>
<td style={{ padding: '12px', fontWeight: 'bold' }}>{student.name}</td>
<td style={{ padding: '12px' }}>{student.subject}</td>
<td style={{ padding: '12px', textAlign: 'center' }}>
<span style={{
padding: '4px 12px',
borderRadius: '20px',
backgroundColor: getGradeBg(student.grade),
color: getGradeColor(student.grade),
fontWeight: 'bold',
fontSize: '0.9rem',
}}>
{student.grade}
</span>
</td>
</tr>
))}
</tbody>
</table>
);
}
8.4 排序與過濾
import { useState } from 'react';
function SortAndFilter() {
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState('name');
const [sortOrder, setSortOrder] = useState('asc');
const [filterCategory, setFilterCategory] = useState('all');
const products = [
{ id: 1, name: 'MacBook Pro', price: 79900, category: '電腦' },
{ id: 2, name: 'iPhone 15', price: 34900, category: '手機' },
{ id: 3, name: 'AirPods Pro', price: 7490, category: '配件' },
{ id: 4, name: 'iPad Air', price: 19900, category: '平板' },
{ id: 5, name: 'Apple Watch', price: 12900, category: '配件' },
{ id: 6, name: 'iMac', price: 42900, category: '電腦' },
{ id: 7, name: 'iPad Pro', price: 34900, category: '平板' },
{ id: 8, name: 'iPhone 15 Pro', price: 42900, category: '手機' },
];
const categories = ['all', ...new Set(products.map((p) => p.category))];
// 先過濾,再搜尋,最後排序
const displayedProducts = products
.filter((p) => filterCategory === 'all' || p.category === filterCategory)
.filter((p) => p.name.toLowerCase().includes(searchTerm.toLowerCase()))
.sort((a, b) => {
const modifier = sortOrder === 'asc' ? 1 : -1;
if (sortBy === 'name') return a.name.localeCompare(b.name) * modifier;
if (sortBy === 'price') return (a.price - b.price) * modifier;
return 0;
});
const toggleSort = (field) => {
if (sortBy === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortBy(field);
setSortOrder('asc');
}
};
return (
<div style={{ maxWidth: '700px', margin: '20px auto', fontFamily: 'sans-serif' }}>
<h1>產品列表</h1>
{/* 搜尋 */}
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="搜尋產品..."
style={{
width: '100%',
padding: '10px 14px',
border: '2px solid #e2e8f0',
borderRadius: '8px',
fontSize: '1rem',
marginBottom: '12px',
boxSizing: 'border-box',
}}
/>
{/* 分類篩選 */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
{categories.map((cat) => (
<button
key={cat}
onClick={() => setFilterCategory(cat)}
style={{
padding: '6px 14px',
borderRadius: '20px',
border: 'none',
backgroundColor: filterCategory === cat ? '#4299e1' : '#edf2f7',
color: filterCategory === cat ? 'white' : '#4a5568',
cursor: 'pointer',
}}
>
{cat === 'all' ? '全部' : cat}
</button>
))}
</div>
{/* 排序按鈕 */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
<button onClick={() => toggleSort('name')} style={{ cursor: 'pointer' }}>
按名稱 {sortBy === 'name' && (sortOrder === 'asc' ? '↑' : '↓')}
</button>
<button onClick={() => toggleSort('price')} style={{ cursor: 'pointer' }}>
按價格 {sortBy === 'price' && (sortOrder === 'asc' ? '↑' : '↓')}
</button>
</div>
{/* 結果數量 */}
<p style={{ color: '#718096', marginBottom: '12px' }}>
顯示 {displayedProducts.length} / {products.length} 項產品
</p>
{/* 產品列表 */}
{displayedProducts.length === 0 ? (
<p style={{ textAlign: 'center', color: '#a0aec0', padding: '40px' }}>
找不到符合條件的產品
</p>
) : (
displayedProducts.map((product) => (
<div
key={product.id}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '16px',
borderBottom: '1px solid #edf2f7',
}}
>
<div>
<strong>{product.name}</strong>
<span style={{
marginLeft: '8px',
padding: '2px 8px',
borderRadius: '4px',
backgroundColor: '#edf2f7',
fontSize: '0.8rem',
}}>
{product.category}
</span>
</div>
<span style={{ fontWeight: 'bold', color: '#e53e3e' }}>
NT$ {product.price.toLocaleString()}
</span>
</div>
))
)}
</div>
);
}
export default SortAndFilter;
8.5 巢狀列表
function NestedList() {
const departments = [
{
id: 'd1',
name: '工程部',
teams: [
{
id: 't1',
name: '前端團隊',
members: [
{ id: 'm1', name: 'Alice', role: '技術主管' },
{ id: 'm2', name: 'Bob', role: '資深工程師' },
],
},
{
id: 't2',
name: '後端團隊',
members: [
{ id: 'm3', name: 'Charlie', role: '技術主管' },
{ id: 'm4', name: 'Diana', role: '工程師' },
],
},
],
},
{
id: 'd2',
name: '設計部',
teams: [
{
id: 't3',
name: 'UI 設計團隊',
members: [
{ id: 'm5', name: 'Eve', role: '設計主管' },
{ id: 'm6', name: 'Frank', role: '設計師' },
],
},
],
},
];
return (
<div style={{ fontFamily: 'sans-serif', padding: '20px' }}>
<h1>組織架構</h1>
{departments.map((dept) => (
<div key={dept.id} style={{ marginBottom: '24px' }}>
<h2 style={{ color: '#2d3748', borderBottom: '2px solid #4299e1', paddingBottom: '8px' }}>
{dept.name}
</h2>
{dept.teams.map((team) => (
<div key={team.id} style={{ marginLeft: '20px', marginBottom: '16px' }}>
<h3 style={{ color: '#4a5568' }}>{team.name}</h3>
<ul style={{ listStyle: 'none', padding: 0 }}>
{team.members.map((member) => (
<li key={member.id} style={{
display: 'flex',
justifyContent: 'space-between',
padding: '8px 16px',
marginLeft: '20px',
borderLeft: '3px solid #e2e8f0',
marginBottom: '4px',
}}>
<span>{member.name}</span>
<span style={{ color: '#718096', fontSize: '0.9rem' }}>
{member.role}
</span>
</li>
))}
</ul>
</div>
))}
</div>
))}
</div>
);
}
8.6 練習題
練習 1:建立一個通訊錄
- 顯示聯絡人列表(名字、電話、Email)
- 可以按名字搜尋
- 可以按名字或 Email 排序
- 可以新增和刪除聯絡人
練習 2:建立一個看板 (Kanban Board)
- 三個欄位:「待辦」、「進行中」、「已完成」
- 每個欄位顯示對應的任務卡片
- 使用 filter 來分組顯示
本課重點回顧
- 使用
map()將陣列轉為 JSX - Key 是列表元素的唯一識別符
- 使用穩定的 ID 作為 key,避免 index
- 使用
filter()過濾、sort()排序 - 排序前要用
[...arr]建立副本 - 巢狀列表用巢狀的
map()