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;
Articles liés
App.js
App.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/App.js).
Lire l'article →App.test.js
App.test.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/App.test.js).
Lire l'article →index.js
index.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/index.js).
Lire l'article →reportWebVitals.js
reportWebVitals.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/reportWebVitals.js).
Lire l'article →setupTests.js
setupTests.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/setupTests.js).
Lire l'article →Project_HelloReact.jsx
Project_HelloReact.jsx — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/projects/Project_HelloReact.jsx).
Lire l'article →