第 10 章:列表與 Keys (Lists and Keys)
在許多應用程式中,我們需要渲染資料列表,例如使用者列表、商品列表、待辦事項等。React 提供了強大的列表渲染功能,讓我們可以使用 JavaScript 的陣列方法來動態生成 UI 元素。在這一章中,我們將學習如何渲染列表,以及為什麼 Keys 對於效能和正確性如此重要。
10.1 基本列表渲染
在 React 中,最常用的列表渲染方法是使用 map()
函式:
import React, { useState } from 'react';
function BasicList() {
const fruits = ['蘋果', '香蕉', '橙子', '葡萄', '草莓'];
return (
<div>
<h2>水果列表</h2>
<ul>
{fruits.map((fruit, index) => (
<li key={index}>{fruit}</li>
))}
</ul>
</div>
);
}
export default BasicList;
10.2 渲染物件陣列
更常見的情況是渲染物件陣列:
import React, { useState } from 'react';
function ProductList() {
const [products] = useState([
{ id: 1, name: 'iPhone 15', price: 32900, category: '手機', inStock: true },
{ id: 2, name: 'MacBook Pro', price: 59900, category: '筆記型電腦', inStock: true },
{ id: 3, name: 'iPad Air', price: 19900, category: '平板', inStock: false },
{ id: 4, name: 'AirPods Pro', price: 7490, category: '耳機', inStock: true },
{ id: 5, name: 'Apple Watch', price: 12900, category: '智慧手錶', inStock: true }
]);
return (
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
<h2>產品列表</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
gap: '20px'
}}>
{products.map(product => (
<div
key={product.id} // 使用唯一的 id 作為 key
style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '15px',
backgroundColor: product.inStock ? '#fff' : '#f5f5f5'
}}
>
<h3 style={{ margin: '0 0 10px 0' }}>{product.name}</h3>
<p style={{ color: '#666', margin: '5px 0' }}>
分類: {product.category}
</p>
<p style={{
fontSize: '1.2em',
fontWeight: 'bold',
color: '#2196F3',
margin: '10px 0'
}}>
NT$ {product.price.toLocaleString()}
</p>
<p style={{
color: product.inStock ? '#4CAF50' : '#f44336',
fontWeight: 'bold'
}}>
{product.inStock ? '有現貨' : '缺貨'}
</p>
</div>
))}
</div>
</div>
);
}
export default ProductList;
10.3 為什麼需要 Keys?
Keys 是 React 用來識別列表中哪些項目發生變化、被新增或被移除的特殊屬性。正確使用 Keys 對於應用程式的效能和正確性至關重要。
10.3.1 沒有 Key 時的問題
import React, { useState } from 'react';
function ProblematicList() {
const [items, setItems] = useState([
{ id: 1, text: '項目 1' },
{ id: 2, text: '項目 2' },
{ id: 3, text: '項目 3' }
]);
const addItemToStart = () => {
const newItem = {
id: Date.now(),
text: `新項目 ${items.length + 1}`
};
setItems([newItem, ...items]);
};
const removeFirstItem = () => {
setItems(items.slice(1));
};
return (
<div>
<h3>❌ 問題示範:使用索引作為 Key</h3>
<button onClick={addItemToStart} style={{ marginRight: '10px' }}>
在開頭新增項目
</button>
<button onClick={removeFirstItem}>
移除第一個項目
</button>
<ul>
{items.map((item, index) => (
<li key={index}> {/* 使用索引作為 key - 這是錯誤的做法 */}
<input type="text" defaultValue={item.text} />
<span> - ID: {item.id}</span>
</li>
))}
</ul>
<p style={{ color: 'red', fontSize: '0.9em' }}>
試試在輸入框中輸入一些文字,然後點擊按鈕。注意輸入的內容會出現在錯誤的項目上!
</p>
</div>
);
}
function CorrectList() {
const [items, setItems] = useState([
{ id: 1, text: '項目 1' },
{ id: 2, text: '項目 2' },
{ id: 3, text: '項目 3' }
]);
const addItemToStart = () => {
const newItem = {
id: Date.now(),
text: `新項目 ${items.length + 1}`
};
setItems([newItem, ...items]);
};
const removeFirstItem = () => {
setItems(items.slice(1));
};
return (
<div>
<h3>✅ 正確示範:使用唯一 ID 作為 Key</h3>
<button onClick={addItemToStart} style={{ marginRight: '10px' }}>
在開頭新增項目
</button>
<button onClick={removeFirstItem}>
移除第一個項目
</button>
<ul>
{items.map(item => (
<li key={item.id}> {/* 使用唯一的 id 作為 key - 這是正確的做法 */}
<input type="text" defaultValue={item.text} />
<span> - ID: {item.id}</span>
</li>
))}
</ul>
<p style={{ color: 'green', fontSize: '0.9em' }}>
現在輸入的內容會正確地保持在對應的項目上。
</p>
</div>
);
}
function KeyExample() {
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
<ProblematicList />
<hr style={{ margin: '40px 0' }} />
<CorrectList />
</div>
);
}
export default KeyExample;
10.4 選擇合適的 Key
10.4.1 Key 的選擇原則
- 唯一性: Key 在同級元素中必須是唯一的
- 穩定性: Key 不應該在重新渲染時改變
- 可預測性: Key 應該與資料的身份相關,而不是位置
import React, { useState } from 'react';
function KeySelectionExamples() {
const [users] = useState([
{ id: 'user_001', name: '張三', email: 'zhang@example.com' },
{ id: 'user_002', name: '李四', email: 'li@example.com' },
{ id: 'user_003', name: '王五', email: 'wang@example.com' }
]);
const [todos] = useState([
{ id: 1, text: '學習 React', completed: false, createdAt: '2024-01-01' },
{ id: 2, text: '寫程式碼', completed: true, createdAt: '2024-01-02' },
{ id: 3, text: '測試應用', completed: false, createdAt: '2024-01-03' }
]);
const mixedData = [
{ type: 'header', content: '歡迎來到我們的網站' },
{ type: 'user', id: 'user_001', name: '張三' },
{ type: 'user', id: 'user_002', name: '李四' },
{ type: 'footer', content: '版權所有 © 2024' }
];
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
<h2>Key 選擇範例</h2>
{/* 範例 1: 使用資料庫 ID */}
<section>
<h3>✅ 使用資料庫 ID</h3>
<ul>
{users.map(user => (
<li key={user.id}>
{user.name} ({user.email})
</li>
))}
</ul>
</section>
{/* 範例 2: 使用複合 Key */}
<section>
<h3>✅ 使用複合 Key(適用於混合資料)</h3>
<div>
{mixedData.map((item, index) => {
// 為不同類型的資料創建不同的 key 策略
const key = item.type === 'user' ? `user-${item.id}` : `${item.type}-${index}`;
return (
<div key={key} style={{
padding: '10px',
margin: '5px 0',
backgroundColor: item.type === 'user' ? '#e3f2fd' : '#f5f5f5',
borderRadius: '4px'
}}>
{item.type === 'user' ? `使用者: ${item.name}` : item.content}
</div>
);
})}
</div>
</section>
{/* 範例 3: 什麼時候可以使用索引 */}
<section>
<h3>⚠️ 何時可以使用索引作為 Key</h3>
<p style={{ fontSize: '0.9em', color: '#666' }}>
只有在以下所有條件都滿足時才可以使用索引:
</p>
<ul style={{ fontSize: '0.9em', color: '#666' }}>
<li>列表是靜態的(不會改變)</li>
<li>列表項目沒有 ID</li>
<li>列表不會重新排序或過濾</li>
</ul>
{/* 這是一個靜態列表,可以使用索引 */}
<div>
<strong>範例:靜態列表</strong>
{['首頁', '關於我們', '服務項目', '聯絡我們'].map((item, index) => (
<span key={index} style={{
display: 'inline-block',
padding: '5px 10px',
margin: '0 5px',
backgroundColor: '#f0f0f0',
borderRadius: '3px'
}}>
{item}
</span>
))}
</div>
</section>
</div>
);
}
export default KeySelectionExamples;
10.5 動態列表操作
import React, { useState } from 'react';
function TodoApp() {
const [todos, setTodos] = useState([
{ id: 1, text: '學習 React Hooks', completed: false, priority: 'high' },
{ id: 2, text: '建立 Todo 應用', completed: true, priority: 'medium' },
{ id: 3, text: '部署到生產環境', completed: false, priority: 'low' }
]);
const [newTodo, setNewTodo] = useState('');
const [filter, setFilter] = useState('all'); // all, active, completed
const [sortBy, setSortBy] = useState('id'); // id, priority, text
// 新增 Todo
const addTodo = () => {
if (newTodo.trim()) {
const todo = {
id: Date.now(),
text: newTodo.trim(),
completed: false,
priority: 'medium'
};
setTodos([...todos, todo]);
setNewTodo('');
}
};
// 切換完成狀態
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
// 刪除 Todo
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id));
};
// 更新優先級
const updatePriority = (id, priority) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, priority } : todo
));
};
// 過濾 Todos
const getFilteredTodos = () => {
let filtered = todos;
switch (filter) {
case 'active':
filtered = todos.filter(todo => !todo.completed);
break;
case 'completed':
filtered = todos.filter(todo => todo.completed);
break;
default:
filtered = todos;
}
// 排序
return filtered.sort((a, b) => {
switch (sortBy) {
case 'priority':
const priorityOrder = { high: 3, medium: 2, low: 1 };
return priorityOrder[b.priority] - priorityOrder[a.priority];
case 'text':
return a.text.localeCompare(b.text);
default:
return a.id - b.id;
}
});
};
const priorityColors = {
high: '#ffebee',
medium: '#fff3e0',
low: '#e8f5e8'
};
const priorityLabels = {
high: '高',
medium: '中',
low: '低'
};
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
<h2>Todo 應用程式</h2>
{/* 新增 Todo */}
<div style={{ marginBottom: '20px' }}>
<input
type="text"
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addTodo()}
placeholder="輸入新的待辦事項..."
style={{
padding: '8px',
width: '70%',
marginRight: '10px',
border: '1px solid #ddd',
borderRadius: '4px'
}}
/>
<button
onClick={addTodo}
style={{
padding: '8px 16px',
backgroundColor: '#4CAF50',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
新增
</button>
</div>
{/* 過濾和排序控制 */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: '20px',
padding: '10px',
backgroundColor: '#f5f5f5',
borderRadius: '4px'
}}>
<div>
<label>過濾: </label>
<select value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="all">全部</option>
<option value="active">未完成</option>
<option value="completed">已完成</option>
</select>
</div>
<div>
<label>排序: </label>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
<option value="id">建立順序</option>
<option value="priority">優先級</option>
<option value="text">名稱</option>
</select>
</div>
</div>
{/* Todo 列表 */}
<div>
{getFilteredTodos().length === 0 ? (
<p style={{ textAlign: 'center', color: '#666' }}>
{filter === 'all' ? '沒有待辦事項' : `沒有${filter === 'active' ? '未完成' : '已完成'}的事項`}
</p>
) : (
getFilteredTodos().map(todo => (
<div
key={todo.id} // 使用唯一 ID
style={{
display: 'flex',
alignItems: 'center',
padding: '10px',
margin: '5px 0',
backgroundColor: priorityColors[todo.priority],
border: '1px solid #ddd',
borderRadius: '4px',
opacity: todo.completed ? 0.7 : 1
}}
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
style={{ marginRight: '10px' }}
/>
<span
style={{
flex: 1,
textDecoration: todo.completed ? 'line-through' : 'none',
color: todo.completed ? '#666' : '#000'
}}
>
{todo.text}
</span>
<select
value={todo.priority}
onChange={(e) => updatePriority(todo.id, e.target.value)}
style={{ margin: '0 10px', padding: '2px' }}
disabled={todo.completed}
>
<option value="high">高優先級</option>
<option value="medium">中優先級</option>
<option value="low">低優先級</option>
</select>
<span style={{
padding: '2px 6px',
borderRadius: '3px',
fontSize: '0.8em',
backgroundColor: 'rgba(0,0,0,0.1)',
marginRight: '10px'
}}>
{priorityLabels[todo.priority]}
</span>
<button
onClick={() => deleteTodo(todo.id)}
style={{
padding: '4px 8px',
backgroundColor: '#f44336',
color: 'white',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '0.8em'
}}
>
刪除
</button>
</div>
))
)}
</div>
{/* 統計資訊 */}
<div style={{
marginTop: '20px',
padding: '10px',
backgroundColor: '#f9f9f9',
borderRadius: '4px',
fontSize: '0.9em',
color: '#666'
}}>
總計: {todos.length} 項 |
已完成: {todos.filter(t => t.completed).length} 項 |
未完成: {todos.filter(t => !t.completed).length} 項
</div>
</div>
);
}
export default TodoApp;
10.6 巢狀列表
當處理巢狀資料結構時,每一層都需要適當的 Keys:
import React, { useState } from 'react';
function NestedList() {
const [departments] = useState([
{
id: 'dept_1',
name: '技術部',
employees: [
{ id: 'emp_1', name: '張工程師', position: '前端工程師' },
{ id: 'emp_2', name: '李工程師', position: '後端工程師' },
{ id: 'emp_3', name: '王工程師', position: 'DevOps 工程師' }
]
},
{
id: 'dept_2',
name: '設計部',
employees: [
{ id: 'emp_4', name: '陳設計師', position: 'UI 設計師' },
{ id: 'emp_5', name: '林設計師', position: 'UX 設計師' }
]
},
{
id: 'dept_3',
name: '行銷部',
employees: [
{ id: 'emp_6', name: '黃經理', position: '行銷經理' },
{ id: 'emp_7', name: '吳專員', position: '行銷專員' },
{ id: 'emp_8', name: '許專員', position: '社群經理' }
]
}
]);
const [expandedDepts, setExpandedDepts] = useState(new Set(['dept_1']));
const toggleDepartment = (deptId) => {
const newExpanded = new Set(expandedDepts);
if (newExpanded.has(deptId)) {
newExpanded.delete(deptId);
} else {
newExpanded.add(deptId);
}
setExpandedDepts(newExpanded);
};
return (
<div style={{ maxWidth: '500px', margin: '0 auto', padding: '20px' }}>
<h2>公司組織架構</h2>
{departments.map(department => (
<div
key={department.id} // 部門層級的 key
style={{
border: '1px solid #ddd',
borderRadius: '8px',
marginBottom: '10px',
overflow: 'hidden'
}}
>
{/* 部門標題 */}
<div
onClick={() => toggleDepartment(department.id)}
style={{
padding: '15px',
backgroundColor: '#f5f5f5',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<h3 style={{ margin: 0 }}>{department.name}</h3>
<span style={{ fontSize: '1.2em' }}>
{expandedDepts.has(department.id) ? '▼' : '▶'}
</span>
</div>
{/* 員工列表 */}
{expandedDepts.has(department.id) && (
<div style={{ padding: '10px' }}>
{department.employees.length === 0 ? (
<p style={{ color: '#666', fontStyle: 'italic' }}>
暫無員工
</p>
) : (
department.employees.map(employee => (
<div
key={employee.id} // 員工層級的 key
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px',
margin: '5px 0',
backgroundColor: '#f9f9f9',
borderRadius: '4px'
}}
>
<span style={{ fontWeight: 'bold' }}>
{employee.name}
</span>
<span style={{ color: '#666', fontSize: '0.9em' }}>
{employee.position}
</span>
</div>
))
)}
</div>
)}
</div>
))}
<div style={{
marginTop: '20px',
padding: '10px',
backgroundColor: '#e3f2fd',
borderRadius: '4px',
fontSize: '0.9em'
}}>
<strong>統計:</strong>
<br />
部門數: {departments.length}
<br />
總員工數: {departments.reduce((total, dept) => total + dept.employees.length, 0)}
</div>
</div>
);
}
export default NestedList;
10.7 效能優化
10.7.1 使用 React.memo 優化列表項目
import React, { useState, memo, useCallback } from 'react';
// 使用 memo 優化列表項目
const ListItem = memo(function ListItem({ item, onToggle, onDelete, onUpdate }) {
console.log(`渲染項目: ${item.name}`); // 用於觀察重新渲染
return (
<div style={{
display: 'flex',
alignItems: 'center',
padding: '10px',
border: '1px solid #ddd',
borderRadius: '4px',
margin: '5px 0',
backgroundColor: item.completed ? '#f0f8ff' : '#fff'
}}>
<input
type="checkbox"
checked={item.completed}
onChange={() => onToggle(item.id)}
style={{ marginRight: '10px' }}
/>
<input
type="text"
value={item.name}
onChange={(e) => onUpdate(item.id, e.target.value)}
style={{
flex: 1,
border: 'none',
backgroundColor: 'transparent',
textDecoration: item.completed ? 'line-through' : 'none'
}}
/>
<button
onClick={() => onDelete(item.id)}
style={{
marginLeft: '10px',
padding: '4px 8px',
backgroundColor: '#f44336',
color: 'white',
border: 'none',
borderRadius: '3px',
cursor: 'pointer'
}}
>
刪除
</button>
</div>
);
});
function OptimizedList() {
const [items, setItems] = useState([
{ id: 1, name: '學習 React', completed: false },
{ id: 2, name: '建立專案', completed: true },
{ id: 3, name: '優化效能', completed: false }
]);
// 使用 useCallback 確保函式引用穩定
const handleToggle = useCallback((id) => {
setItems(prev => prev.map(item =>
item.id === id ? { ...item, completed: !item.completed } : item
));
}, []);
const handleDelete = useCallback((id) => {
setItems(prev => prev.filter(item => item.id !== id));
}, []);
const handleUpdate = useCallback((id, newName) => {
setItems(prev => prev.map(item =>
item.id === id ? { ...item, name: newName } : item
));
}, []);
const addItem = () => {
const newItem = {
id: Date.now(),
name: `新項目 ${items.length + 1}`,
completed: false
};
setItems(prev => [...prev, newItem]);
};
return (
<div style={{ maxWidth: '500px', margin: '0 auto', padding: '20px' }}>
<h2>效能優化的列表</h2>
<button
onClick={addItem}
style={{
marginBottom: '10px',
padding: '8px 16px',
backgroundColor: '#4CAF50',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
新增項目
</button>
<div>
{items.map(item => (
<ListItem
key={item.id}
item={item}
onToggle={handleToggle}
onDelete={handleDelete}
onUpdate={handleUpdate}
/>
))}
</div>
<p style={{ fontSize: '0.9em', color: '#666', marginTop: '20px' }}>
打開開發者工具的 Console 查看重新渲染情況。
使用 React.memo 和 useCallback 可以避免不必要的重新渲染。
</p>
</div>
);
}
export default OptimizedList;
10.8 總結
列表渲染和 Keys 是 React 開發中的基礎概念,但它們對應用程式的效能和正確性有重要影響。
本章重點:
- 列表渲染基礎:使用
map()
函式渲染動態列表 - Keys 的重要性:識別列表項目變化,確保正確的更新行為
- Key 選擇原則:唯一性、穩定性、可預測性
- 動態列表操作:新增、刪除、排序、過濾
- 巢狀列表:處理複雜的資料結構
- 效能優化:使用 React.memo 和 useCallback
Key 的最佳實踐:
- 優先使用唯一且穩定的 ID:如資料庫主鍵
- 避免使用陣列索引:除非列表是靜態且不會改變
- 組合 Key:對於複雜的資料結構,可以組合多個值
- 保持 Key 簡單:不要在 Key 中包含過於複雜的邏輯
效能優化技巧:
- 使用 React.memo:避免不必要的重新渲染
- 穩定的函式引用:使用 useCallback 創建穩定的事件處理函式
- 合理的資料結構:設計便於渲染和更新的資料格式
掌握列表渲染和 Keys 的正確用法,能讓你建立高效能、行為正確的 React 應用程式。在下一章中,我們將學習表單處理,這是建立互動式應用程式的另一個重要主題。