6.1 React 事件基礎

React 使用合成事件 (Synthetic Events),提供跨瀏覽器一致的事件 API。

React 事件 vs HTML 事件

<!-- HTML -->
<button onclick="handleClick()">Click</button>

<!-- React JSX -->
<button onClick={handleClick}>Click</button>
差異 HTML React
命名 全小寫 onclick camelCase onClick
字串 "handleClick()" 函式參考 {handleClick}
阻止預設 return false e.preventDefault()

6.2 事件處理函式的寫法

import { useState } from 'react';

function EventExamples() {
  const [message, setMessage] = useState('');

  // 方式 1:獨立函式(推薦 — 可複用、好測試)
  const handleClick = () => {
    setMessage('按鈕被點擊了!');
  };

  // 方式 2:帶參數的函式
  const handleGreet = (name) => {
    setMessage(`你好,${name}!`);
  };

  // 方式 3:接收事件物件
  const handleInputChange = (event) => {
    setMessage(`輸入了:${event.target.value}`);
  };

  // 方式 4:同時接收事件物件和自訂參數
  const handleItemClick = (itemId, event) => {
    setMessage(`點擊了項目 ${itemId},標籤:${event.target.tagName}`);
  };

  return (
    <div style={{ padding: '20px' }}>
      {/* 方式 1:直接傳函式參考 */}
      <button onClick={handleClick}>點擊我</button>

      {/* 方式 2:使用箭頭函式包裝(需要傳參數時) */}
      <button onClick={() => handleGreet('Alice')}>打招呼</button>

      {/* ❌ 錯誤:這會在渲染時就立刻執行! */}
      {/* <button onClick={handleGreet('Alice')}>打招呼</button> */}

      {/* 方式 3:事件物件會自動傳入 */}
      <input onChange={handleInputChange} placeholder="輸入文字" />

      {/* 方式 4:同時傳事件和參數 */}
      <button onClick={(e) => handleItemClick(42, e)}>項目 42</button>

      {/* 行內處理(簡單邏輯時可用) */}
      <button onClick={() => setMessage('行內處理')}>行內</button>

      <p>{message}</p>
    </div>
  );
}

6.3 常用事件類型

滑鼠事件

function MouseEvents() {
  const [info, setInfo] = useState('');

  return (
    <div style={{ padding: '20px' }}>
      <div
        onClick={() => setInfo('單擊')}
        onDoubleClick={() => setInfo('雙擊')}
        onMouseEnter={() => setInfo('滑鼠進入')}
        onMouseLeave={() => setInfo('滑鼠離開')}
        onMouseMove={(e) => setInfo(`座標:(${e.clientX}, ${e.clientY})`)}
        onContextMenu={(e) => {
          e.preventDefault();
          setInfo('右鍵選單被阻止了');
        }}
        style={{
          width: '300px',
          height: '200px',
          backgroundColor: '#edf2f7',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          borderRadius: '8px',
          cursor: 'pointer',
          userSelect: 'none',
        }}
      >
        在此區域操作滑鼠
      </div>
      <p>{info}</p>
    </div>
  );
}

鍵盤事件

function KeyboardEvents() {
  const [keys, setKeys] = useState([]);

  const handleKeyDown = (e) => {
    const keyInfo = `${e.key} (code: ${e.code})`;

    // 快捷鍵偵測
    if (e.ctrlKey && e.key === 's') {
      e.preventDefault();
      alert('Ctrl+S 被按下了!');
      return;
    }

    if (e.key === 'Enter') {
      alert('Enter 被按下了!');
      return;
    }

    if (e.key === 'Escape') {
      setKeys([]);
      return;
    }

    setKeys((prev) => [...prev.slice(-9), keyInfo]);
  };

  return (
    <div style={{ padding: '20px' }}>
      <input
        onKeyDown={handleKeyDown}
        onKeyUp={(e) => console.log('Key up:', e.key)}
        placeholder="在這裡按鍵盤..."
        style={{ padding: '10px', width: '300px', fontSize: '1rem' }}
      />
      <div style={{ marginTop: '12px' }}>
        <h3>最近按下的鍵:</h3>
        {keys.map((key, i) => (
          <span key={i} style={{
            display: 'inline-block',
            margin: '4px',
            padding: '4px 8px',
            backgroundColor: '#e2e8f0',
            borderRadius: '4px',
            fontSize: '0.85rem',
          }}>
            {key}
          </span>
        ))}
      </div>
      <p style={{ color: '#999', fontSize: '0.85rem' }}>
        提示:試試 Ctrl+S、Enter、Escape
      </p>
    </div>
  );
}

表單事件

function FormEvents() {
  const [formData, setFormData] = useState({
    username: '',
    password: '',
    remember: false,
  });

  const handleChange = (e) => {
    const { name, value, type, checked } = e.target;
    setFormData((prev) => ({
      ...prev,
      [name]: type === 'checkbox' ? checked : value,
    }));
  };

  const handleSubmit = (e) => {
    e.preventDefault(); // 阻止表單預設的頁面重新載入
    console.log('提交的資料:', formData);
    alert(JSON.stringify(formData, null, 2));
  };

  const handleReset = () => {
    setFormData({ username: '', password: '', remember: false });
  };

  return (
    <form onSubmit={handleSubmit} onReset={handleReset}>
      <div>
        <label htmlFor="username">帳號:</label>
        <input
          id="username"
          name="username"
          value={formData.username}
          onChange={handleChange}
          onFocus={() => console.log('帳號欄位獲得焦點')}
          onBlur={() => console.log('帳號欄位失去焦點')}
        />
      </div>
      <div>
        <label htmlFor="password">密碼:</label>
        <input
          id="password"
          name="password"
          type="password"
          value={formData.password}
          onChange={handleChange}
        />
      </div>
      <div>
        <label>
          <input
            name="remember"
            type="checkbox"
            checked={formData.remember}
            onChange={handleChange}
          />
          記住我
        </label>
      </div>
      <button type="submit">登入</button>
      <button type="reset">重置</button>
    </form>
  );
}

6.4 事件傳播 (Event Propagation)

function EventPropagation() {
  const handleOuterClick = () => console.log('外層被點擊');
  const handleMiddleClick = () => console.log('中層被點擊');
  const handleInnerClick = (e) => {
    console.log('內層被點擊');
    // 如果不想讓事件繼續往上冒泡,可以使用:
    // e.stopPropagation();
  };

  const boxStyle = (color, size) => ({
    width: size,
    height: size,
    backgroundColor: color,
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    cursor: 'pointer',
    borderRadius: '8px',
  });

  return (
    <div>
      <h2>事件冒泡 (Bubbling)</h2>
      <p>點擊最內層,觀察 Console 的輸出順序</p>

      <div onClick={handleOuterClick} style={boxStyle('#fed7d7', '300px')}>
        外層 (red)
        <div onClick={handleMiddleClick} style={boxStyle('#fefcbf', '200px')}>
          中層 (yellow)
          <div onClick={handleInnerClick} style={boxStyle('#c6f6d5', '100px')}>
            內層 (green)
          </div>
        </div>
      </div>

      <h2>阻止冒泡</h2>
      <div
        onClick={() => alert('外層')}
        style={{ padding: '20px', backgroundColor: '#eee' }}
      >
        <button onClick={(e) => {
          e.stopPropagation();
          alert('按鈕(冒泡已被阻止,外層不會觸發)');
        }}>
          點擊我
        </button>
      </div>

      <h2>阻止預設行為</h2>
      <a
        href="https://google.com"
        onClick={(e) => {
          e.preventDefault();
          alert('連結被阻止了,不會跳轉');
        }}
      >
        點擊這個連結(不會跳轉)
      </a>
    </div>
  );
}

6.5 合成事件 (Synthetic Event) 詳解

function SyntheticEventDemo() {
  const handleClick = (e) => {
    // React 的合成事件
    console.log('事件類型:', e.type);           // 'click'
    console.log('目標元素:', e.target);          // 被點擊的元素
    console.log('當前元素:', e.currentTarget);   // 綁定事件的元素
    console.log('時間戳:', e.timeStamp);
    console.log('是否冒泡:', e.bubbles);

    // 滑鼠位置
    console.log('clientX:', e.clientX);
    console.log('clientY:', e.clientY);
    console.log('pageX:', e.pageX);
    console.log('pageY:', e.pageY);

    // 修飾鍵
    console.log('Ctrl:', e.ctrlKey);
    console.log('Shift:', e.shiftKey);
    console.log('Alt:', e.altKey);
    console.log('Meta(Cmd):', e.metaKey);

    // 取得原生事件
    console.log('原生事件:', e.nativeEvent);
  };

  return <button onClick={handleClick}>檢查合成事件</button>;
}

6.6 完整範例:互動式畫板

import { useState } from 'react';

function DrawingBoard() {
  const [isDrawing, setIsDrawing] = useState(false);
  const [dots, setDots] = useState([]);
  const [currentColor, setCurrentColor] = useState('#4299e1');
  const [dotSize, setDotSize] = useState(10);

  const colors = ['#4299e1', '#48bb78', '#ed8936', '#e53e3e', '#805ad5', '#1a1a2e'];

  const handleMouseDown = () => setIsDrawing(true);
  const handleMouseUp = () => setIsDrawing(false);
  const handleMouseLeave = () => setIsDrawing(false);

  const handleMouseMove = (e) => {
    if (!isDrawing) return;

    const rect = e.currentTarget.getBoundingClientRect();
    const x = e.clientX - rect.left;
    const y = e.clientY - rect.top;

    setDots((prev) => [...prev, { x, y, color: currentColor, size: dotSize }]);
  };

  const clearBoard = () => setDots([]);

  return (
    <div style={{ maxWidth: '600px', margin: '20px auto', fontFamily: 'sans-serif' }}>
      <h1 style={{ textAlign: 'center' }}>互動式畫板</h1>

      {/* 工具列 */}
      <div style={{
        display: 'flex',
        alignItems: 'center',
        gap: '12px',
        marginBottom: '12px',
        padding: '12px',
        backgroundColor: '#f7fafc',
        borderRadius: '8px',
      }}>
        <span>顏色:</span>
        {colors.map((color) => (
          <div
            key={color}
            onClick={() => setCurrentColor(color)}
            style={{
              width: '28px',
              height: '28px',
              borderRadius: '50%',
              backgroundColor: color,
              cursor: 'pointer',
              border: currentColor === color ? '3px solid #333' : '2px solid transparent',
            }}
          />
        ))}

        <span style={{ marginLeft: '12px' }}>大小:</span>
        <input
          type="range"
          min="3"
          max="30"
          value={dotSize}
          onChange={(e) => setDotSize(Number(e.target.value))}
          style={{ width: '80px' }}
        />
        <span>{dotSize}px</span>

        <button
          onClick={clearBoard}
          style={{
            marginLeft: 'auto',
            padding: '6px 16px',
            border: '1px solid #e53e3e',
            backgroundColor: 'white',
            color: '#e53e3e',
            borderRadius: '6px',
            cursor: 'pointer',
          }}
        >
          清除
        </button>
      </div>

      {/* 畫布 */}
      <div
        onMouseDown={handleMouseDown}
        onMouseUp={handleMouseUp}
        onMouseMove={handleMouseMove}
        onMouseLeave={handleMouseLeave}
        style={{
          position: 'relative',
          width: '100%',
          height: '400px',
          backgroundColor: 'white',
          border: '2px solid #e2e8f0',
          borderRadius: '8px',
          cursor: 'crosshair',
          overflow: 'hidden',
        }}
      >
        {dots.map((dot, i) => (
          <div
            key={i}
            style={{
              position: 'absolute',
              left: dot.x - dot.size / 2,
              top: dot.y - dot.size / 2,
              width: dot.size,
              height: dot.size,
              borderRadius: '50%',
              backgroundColor: dot.color,
              pointerEvents: 'none',
            }}
          />
        ))}
      </div>

      <p style={{ textAlign: 'center', color: '#999', fontSize: '0.85rem' }}>
        按住滑鼠左鍵在畫布上拖曳來繪畫 | 點數:{dots.length}
      </p>
    </div>
  );
}

export default DrawingBoard;

6.7 練習題

練習 1:拖曳方塊

建立一個可以用滑鼠拖曳移動的方塊元件。

練習 2:快捷鍵系統

建立一個支援以下快捷鍵的應用: - Ctrl+N:新增項目 - Ctrl+D:刪除選中項目 - Escape:取消選擇 - ↑/↓:在列表中上下移動選擇


本課重點回顧

  1. React 事件使用 camelCase 命名
  2. 傳遞函式參考,不是字串
  3. 事件物件 e 是 React 的合成事件
  4. 使用 e.preventDefault() 阻止預設行為
  5. 使用 e.stopPropagation() 阻止冒泡
  6. 需要傳參數時用箭頭函式包裝onClick={() => fn(arg)}