S SmartDocs
系列: React.js javascript 221 行 · 更新於 2026-04-02

Project_Stopwatch.jsx

React.js/lessons/11_useRef/projects/Project_Stopwatch.jsx

/**
 * Project: 精確碼錶 — useRef 練習
 *
 * 學習重點:
 * - useRef 保存 interval ID(不觸發渲染的可變值)
 * - useRef 保存累計時間
 * - useRef vs useState 的差異
 * - DOM ref(自動聚焦)
 *
 * 使用方式:將此檔案內容複製到你的 App.jsx 中
 */

import { useState, useRef, useEffect } from 'react';

function Stopwatch() {
  const [time, setTime] = useState(0);
  const [isRunning, setIsRunning] = useState(false);
  const [laps, setLaps] = useState([]);

  const intervalRef = useRef(null);
  const startTimeRef = useRef(0);
  const accumulatedRef = useRef(0);
  const lapListRef = useRef(null);

  useEffect(() => {
    return () => {
      if (intervalRef.current) clearInterval(intervalRef.current);
    };
  }, []);

  useEffect(() => {
    if (lapListRef.current && laps.length > 0) {
      lapListRef.current.scrollTop = lapListRef.current.scrollHeight;
    }
  }, [laps]);

  const start = () => {
    if (isRunning) return;
    setIsRunning(true);
    startTimeRef.current = Date.now();
    intervalRef.current = setInterval(() => {
      setTime(accumulatedRef.current + (Date.now() - startTimeRef.current));
    }, 10);
  };

  const stop = () => {
    if (!isRunning) return;
    clearInterval(intervalRef.current);
    accumulatedRef.current += Date.now() - startTimeRef.current;
    setIsRunning(false);
  };

  const reset = () => {
    clearInterval(intervalRef.current);
    intervalRef.current = null;
    startTimeRef.current = 0;
    accumulatedRef.current = 0;
    setTime(0);
    setIsRunning(false);
    setLaps([]);
  };

  const lap = () => {
    if (!isRunning) return;
    const prevLapTime = laps.length > 0 ? laps[laps.length - 1].total : 0;
    setLaps((prev) => [
      ...prev,
      {
        id: Date.now(),
        number: prev.length + 1,
        split: time - prevLapTime,
        total: time,
      },
    ]);
  };

  const formatTime = (ms) => {
    const minutes = Math.floor(ms / 60000);
    const seconds = Math.floor((ms % 60000) / 1000);
    const centiseconds = Math.floor((ms % 1000) / 10);
    return {
      minutes: minutes.toString().padStart(2, '0'),
      seconds: seconds.toString().padStart(2, '0'),
      centiseconds: centiseconds.toString().padStart(2, '0'),
    };
  };

  const formatTimeString = (ms) => {
    const t = formatTime(ms);
    return `${t.minutes}:${t.seconds}.${t.centiseconds}`;
  };

  const mainTime = formatTime(time);

  const bestLap = laps.length > 0 ? Math.min(...laps.map((l) => l.split)) : null;
  const worstLap = laps.length > 0 ? Math.max(...laps.map((l) => l.split)) : null;

  return (
    <div style={{
      minHeight: '100vh',
      backgroundColor: '#0f172a',
      display: 'flex',
      flexDirection: 'column',
      alignItems: 'center',
      justifyContent: 'center',
      fontFamily: "'SF Mono', 'Fira Code', monospace",
      color: 'white',
      padding: '20px',
    }}>
      {/* 計時顯示 */}
      <div style={{ textAlign: 'center', marginBottom: '40px' }}>
        <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center' }}>
          <span style={{ fontSize: '6rem', fontWeight: '200', letterSpacing: '4px', color: '#f8fafc' }}>
            {mainTime.minutes}:{mainTime.seconds}
          </span>
          <span style={{ fontSize: '3rem', fontWeight: '200', color: '#64748b', marginLeft: '4px' }}>
            .{mainTime.centiseconds}
          </span>
        </div>
      </div>

      {/* 控制按鈕 */}
      <div style={{ display: 'flex', gap: '16px', marginBottom: '40px' }}>
        {!isRunning && time === 0 && (
          <button onClick={start} style={{
            width: '80px', height: '80px', borderRadius: '50%', border: '2px solid #22c55e',
            backgroundColor: 'rgba(34, 197, 94, 0.1)', color: '#22c55e',
            cursor: 'pointer', fontSize: '0.9rem', fontWeight: 'bold',
          }}>
            開始
          </button>
        )}

        {isRunning && (
          <>
            <button onClick={lap} style={{
              width: '80px', height: '80px', borderRadius: '50%', border: '2px solid #64748b',
              backgroundColor: 'rgba(100, 116, 139, 0.1)', color: '#94a3b8',
              cursor: 'pointer', fontSize: '0.9rem', fontWeight: 'bold',
            }}>
              分段
            </button>
            <button onClick={stop} style={{
              width: '80px', height: '80px', borderRadius: '50%', border: '2px solid #ef4444',
              backgroundColor: 'rgba(239, 68, 68, 0.1)', color: '#ef4444',
              cursor: 'pointer', fontSize: '0.9rem', fontWeight: 'bold',
            }}>
              暫停
            </button>
          </>
        )}

        {!isRunning && time > 0 && (
          <>
            <button onClick={reset} style={{
              width: '80px', height: '80px', borderRadius: '50%', border: '2px solid #64748b',
              backgroundColor: 'rgba(100, 116, 139, 0.1)', color: '#94a3b8',
              cursor: 'pointer', fontSize: '0.9rem', fontWeight: 'bold',
            }}>
              重置
            </button>
            <button onClick={start} style={{
              width: '80px', height: '80px', borderRadius: '50%', border: '2px solid #22c55e',
              backgroundColor: 'rgba(34, 197, 94, 0.1)', color: '#22c55e',
              cursor: 'pointer', fontSize: '0.9rem', fontWeight: 'bold',
            }}>
              繼續
            </button>
          </>
        )}
      </div>

      {/* 分段列表 */}
      {laps.length > 0 && (
        <div style={{ width: '100%', maxWidth: '400px' }}>
          <div style={{
            display: 'flex', justifyContent: 'space-between', padding: '8px 16px',
            color: '#64748b', fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '1px',
            borderBottom: '1px solid #1e293b',
          }}>
            <span style={{ width: '60px' }}>分段</span>
            <span style={{ width: '120px', textAlign: 'center' }}>分段時間</span>
            <span style={{ width: '120px', textAlign: 'right' }}>累計時間</span>
          </div>

          <div ref={lapListRef} style={{ maxHeight: '250px', overflowY: 'auto' }}>
            {laps.map((l) => {
              const isBest = l.split === bestLap && laps.length > 1;
              const isWorst = l.split === worstLap && laps.length > 1;

              return (
                <div
                  key={l.id}
                  style={{
                    display: 'flex', justifyContent: 'space-between', padding: '10px 16px',
                    borderBottom: '1px solid #1e293b', fontSize: '0.95rem',
                    color: isBest ? '#22c55e' : isWorst ? '#ef4444' : '#e2e8f0',
                  }}
                >
                  <span style={{ width: '60px' }}>
                    #{l.number}
                    {isBest && ' 🏆'}
                    {isWorst && ' 🐢'}
                  </span>
                  <span style={{ width: '120px', textAlign: 'center' }}>
                    {formatTimeString(l.split)}
                  </span>
                  <span style={{ width: '120px', textAlign: 'right' }}>
                    {formatTimeString(l.total)}
                  </span>
                </div>
              );
            })}
          </div>
        </div>
      )}
    </div>
  );
}

export default Stopwatch;

相關文章