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 來分組顯示

本課重點回顧

  1. 使用 map() 將陣列轉為 JSX
  2. Key 是列表元素的唯一識別符
  3. 使用穩定的 ID 作為 key,避免 index
  4. 使用 filter() 過濾、sort() 排序
  5. 排序前要用 [...arr] 建立副本
  6. 巢狀列表用巢狀的 map()