第 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 的選擇原則

  1. 唯一性: Key 在同級元素中必須是唯一的
  2. 穩定性: Key 不應該在重新渲染時改變
  3. 可預測性: 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 的最佳實踐:

  1. 優先使用唯一且穩定的 ID:如資料庫主鍵
  2. 避免使用陣列索引:除非列表是靜態且不會改變
  3. 組合 Key:對於複雜的資料結構,可以組合多個值
  4. 保持 Key 簡單:不要在 Key 中包含過於複雜的邏輯

效能優化技巧:

  1. 使用 React.memo:避免不必要的重新渲染
  2. 穩定的函式引用:使用 useCallback 創建穩定的事件處理函式
  3. 合理的資料結構:設計便於渲染和更新的資料格式

掌握列表渲染和 Keys 的正確用法,能讓你建立高效能、行為正確的 React 應用程式。在下一章中,我們將學習表單處理,這是建立互動式應用程式的另一個重要主題。

results matching ""

    No results matching ""