시작 S 버튼을 클릭하면 아래와 같은 작업이 이루어져야 합니다.
중단 S 버튼을 클릭하면 아래와 같은 작업이 이루어져야 합니다.
라벨 뿐만 아니라 버튼의 스타일도 사진과 동일하게 변경되어야 합니다. 붉은색 버튼의 class명은 bg-red-600을, 초록색 버튼의 class명은 bg-green-600을 사용해 주세요.
스톱워치 모듈에서 내려받은 centisecond는 '[분]:[초].[100/1 초 = centisecond]'과 같은 포맷을 가져야 합니다.
랩 L 버튼을 클릭하면 아래와 같은 작업이 이루어져야 합니다.
리셋 L 버튼을 클릭하면 아래와 같은 작업이 이루어져야 합니다.
버튼을 키보드로 조작할 수 있도록 해야 합니다.
키보드 L: 랩 L, 리셋 L 키보드 S: 시작 S, 중단 S
Lap 중 최장 Lap 기록은 붉은색으로 (text-red-600), 최단 Lap 기록은 초록색으로 (text-green-600) 표시되어야 합니다.
우선 구현에 사용할 stopWatch 객체를 만든다.
import Stopwatch from './stopwatch.js';
const stopWatch = new Stopwatch();
let isRunning = false; //타이머 돌아가고 있는지 여부
let interval;
const $timer = document.getElementById('timer');
const $startStopBtn = document.getElementById('start-stop-btn');
const $lapResetBtn = document.getElementById('lap-reset-btn');
const $lapResetLabel = document.getElementById('lap-reset-btn-label');
const $startStopLabel = document.getElementById('start-stop-btn-label');
const updateTime = (time) => {
$timer.innerText = formatTime(time);
}; //상단 분, 초 표시하는 함수
const onClickStartStopBtn = () => {
if (isRunning) { //돌아가고 있을 때는 중단 버튼
onClickStopBtn();
} else { // 돌아가고 있지 않을 때는 시작 버튼
onClickStartBtn();
}
isRunning = !isRunning; //상태 변경
toggleBtnStyle(); //버튼 색상 변경
};
const toggleBtnStyle = () => {
//초록색 <-> 붉은색 버튼 (toggle)
$startStopBtn.classList.toggle('bg-green-600');
$startStopBtn.classList.toggle('bg-red-600');
};
const onClickStartBtn = () => {
stopWatch.start();
interval = setInterval(() => {
updateTime(stopWatch.centisecond);
}, 10);
$lapResetLabel.innerText = '랩';
$startStopLabel.innerText = '중단';
};
const onClickStopBtn = () => {
stopWatch.pause();
clearInterval(interval);
$lapResetLabel.innerText = '리셋';
$startStopLabel.innerText = '시작';
};
$startStopBtn.addEventListener('click', onClickStartStopBtn);
updateTime에서 실행되는 formatTime 함수에서 stopWatch.centisecond를 받아 포맷팅 처리
const formatString = (num) => (num < 10 ? '0' + num : num);
const formatTime = (centisecond) => {
let formattedString = '';
//centisecond -> 분 : 초 : 1/100 초
const min = parseInt(centisecond / 6000); //정수형태의 값 (소수점 버리기);
const sec = parseInt((centisecond - 6000 * min) / 100);
const centisec = centisecond % 100;
formattedString = `${formatString(min)}:${formatString(sec)}.${formatString(
centisec
)}`;
return formattedString;
};
let $minLap, $maxLap;
const $laps = document.getElementById('laps');
const onClickLapResetBtn = () => {
if (isRunning) {
onClickLapBtn();
} else {
onClickResetBtn();
}
};
const colorMinMax = () => {
$minLap.classList.add('text-green-600');
$maxLap.classList.add('text-red-600');
};
const onClickLapBtn = () => {
const [lapCount, lapTime] = stopWatch.createLap();
const $lap = document.createElement('li'); //새롭게 추가할 랩
$lap.setAttribute('data-time', lapTime); //min, max 랩 타임 비교를 위해 랩 별로 Dom에 데이터 속성으로 랩 타임 저장
$lap.classList.add('flex', 'justify-between', 'py-2', 'px-3', 'border-b-2');
$lap.innerHTML = `
<span>랩 ${lapCount}</span>
<span>${formatTime(lapTime)}</span>
`;
$laps.prepend($lap); //이전 랩 상단에 요소 추가
//Lap 추가 시 min, max 저장해 둔 것과 현재 추가되는 lap을 비교해서 min, max 값 업데이트
//처음 lap 눌렀을 때 : 첫 Lap은 minlap으로 둔다.
if ($minLap === undefined) {
$minLap = $lap;
return;
}
//두번째 lap 눌렀을 때 : 첫번째 Lap이랑 비교해서 최소, 최대값을 결정한다.
if ($maxLap === undefined) {
if (lapTime < $minLap.dataset.time) {
//최소값 갱신
$maxLap = $minLap;
$minLap = $lap;
} else {
$maxLap = $lap;
}
colorMinMax();
return;
}
//Lap이 3개 이상 (min, max 다 존재)
if (lapTime < $minLap.dataset.time) {
$minLap.classList.remove('text-green-600');
$minLap = $lap;
} else if (lapTime > $maxLap.dataset.time) {
$maxLap.classList.remove('text-red-600');
$maxLap = $lap;
}
colorMinMax();
};
const onClickResetBtn = () => {
stopWatch.reset();
updateTime(0);
$laps.innerHTML = '';
$minLap = undefined;
$maxLap = undefined;
};
$lapResetBtn.addEventListener('click', onClickLapResetBtn);
const onKeyDown = (e) => {
switch (e.code) {
case 'KeyL':
onClickLapResetBtn();
break;
case 'KeyS':
onClickStartStopBtn();
break;
}
};
document.addEventListener('keydown', onKeyDown);
커스텀 훅(useTimer) 활용
(여러 개의 자바스크립트 함수에서 같은 로직을 공유)
Vanilla JS 때 만들었던 formatTime 함수를 가져와서 util이라는 공용 함수로 따로 빼두어 필요한 컴포넌트에서 import하여 활용할 수 있도록 한다.
랩 버튼을 누르면 useTimer 내부의 createLap 함수가 실행되도록 하여 (새로운 [lapCount, lapTime]이 laps 배열 맨 앞에 추가)되도록 하고 laps 컴포넌트에서 map 활용하여 laps 펼치기
isRunning이 아닌 상태에서 lapResetBtn을 눌렀을 때 onClickHandler로 reset 기능 button에 전달
keydown시 각 버튼 클릭한 것 처럼 작동하는 handler 함수 활용
최적화 용도로 useEffect에 clean up 함수 추가
laps 배열 돌면서 lapTime 값만 들고와서 새로운 배열 만들기 (reduce 활용하여 이어붙이기)
그 배열에서 최댓값, 최솟값 인덱스 저장해두었다가, map 함수로 배열 돌면서 랩 펼칠 때
import './App.css';
import Footer from './components/Footer';
import Stopwatch from './components/Stopwatch';
function App() {
return (
<>
<Stopwatch />
<Footer />
</>
);
}
export default App;
import { useState, useRef } from 'react';
const useTimer = () => {
const [centisecond, setCentisecond] = useState(0);
const [lapCount, setLapCount] = useState(1);
const [isRunning, setIsRunning] = useState(false);
const [timerInterval, setTimerInterval] = useState(null);
const [laps, setLaps] = useState([]);
let prevLap = useRef(0);
const start = () => {
let _interval = setInterval(() => {
setCentisecond((prev) => prev + 1);
}, 10);
setTimerInterval(_interval);
setIsRunning((prev) => !prev);
};
const pause = () => {
clearInterval(timerInterval);
setTimerInterval(null);
setIsRunning((prev) => !prev);
};
const createLap = () => {
setLapCount((prev) => prev + 1);
const lapTime = centisecond - prevLap.current;
setLaps((prev) => [[lapCount, lapTime], ...prev]);
prevLap.current = centisecond;
};
const reset = () => {
setCentisecond(0);
setLapCount(0);
prevLap.current = 0;
setLaps([]);
};
return { centisecond, start, pause, createLap, reset, isRunning, laps };
};
export default useTimer;
const formatString = (num) => (num < 10 ? '0' + num : num);
const formatTime = (centisecond) => {
let formattedString = '';
//centisecond -> 분 : 초 : 1/100 초
const min = parseInt(centisecond / 6000); //정수형태의 값 (소수점 버리기);
const sec = parseInt((centisecond - 6000 * min) / 100);
const centisec = centisecond % 100;
formattedString = `${formatString(min)}:${formatString(sec)}.${formatString(
centisec
)}`;
return formattedString;
};
export default formatTime;
import Timer from './Timer';
import Laps from './Laps';
import Button from './Button';
import useTimer from '../hooks/useTimer';
import { useEffect, useRef } from 'react';
const Stopwatch = () => {
const { start, pause, reset, centisecond, createLap, laps, isRunning } =
useTimer();
const lapResetBtnRef = useRef(null);
const startStopBtnRef = useRef(null);
const handler = (e) => {
if (e.code === 'KeyL') {
//lapButton을 클릭하도록
lapResetBtnRef.current.click();
}
if (e.code === 'KeyS') {
//StartButton을 클릭하도록
startStopBtnRef.current.click();
}
};
useEffect(() => {
document.addEventListener('keydown', handler);
return () => {
document.removeEventListener('keydown', handler); //CLEAN UP
};
}, []);
return (
<section className="w-fit bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4 flex flex-col justify-center m-auto mt-36 max-w-sm">
<Timer centisecond={centisecond} />
<div className="flex justify-between text-white pb-8 text-sm select-none">
<Button
label={isRunning ? '랩' : '리셋'}
code="L"
color="bg-gray-600"
onClickHandler={isRunning ? createLap : reset}
ref={lapResetBtnRef}
/>
<Button
label={isRunning ? '중단' : '시작'}
code="S"
color={isRunning ? 'bg-red-600' : 'bg-green-600'}
onClickHandler={isRunning ? pause : start}
ref={startStopBtnRef}
/>
</div>
<Laps laps={laps} />
</section>
);
};
export default Stopwatch;
import formatTime from '../util/formatTime';
const Timer = ({ centisecond }) => {
return (
<h1 className="text-5xl font-extrabold pb-8 text-center tracking-tighter break-words">
{formatTime(centisecond)}
</h1>
);
};
export default Timer;
import { forwardRef } from 'react';
//버튼 간의 차이점 : label, keycode, color
const Button = forwardRef(({ label, code, color, onClickHandler }, ref) => {
return (
<>
<button
className={`${color} rounded-full w-16 h-16 relative flex flex-col justify-center items-center cursor-pointer shadow-md`}
onClick={onClickHandler}
ref={ref}
>
<p id="lap-reset-btn-label" className="text-base">
{label}
</p>
<p className="text-xs">{code}</p>
</button>
</>
);
});
export default Button;
import formatTime from '../util/formatTime';
const Laps = ({ laps }) => {
//첫번째 방법
const lapTimeArr = laps.reduce((acc, cur) => [...acc, cur[1]], []);
// 두번쨰 방법
// const lapTimeArr2 = []
// laps.forEach((lap) => lapTimeArr2.push(lap[1]))
const maxIndex = lapTimeArr.indexOf(Math.max(...lapTimeArr));
const minIndex = lapTimeArr.indexOf(Math.min(...lapTimeArr));
const minMaxStyle = (idx) => {
if (laps.length < 2) return;
if (idx === maxIndex) return 'text-red-600';
if (idx === minIndex) return 'text-green-600';
};
return (
<article className="text-gray-600 h-64 overflow-auto border-t-2">
<ul>
{laps.map((lap, idx) => {
return (
<li
className={`flex justify-between py-2 px-3 border-b-2 ${minMaxStyle(
idx
)}`}
key={lap[0]}
>
<span>랩 {lap[0]}</span>
<span>{formatTime(lap[1])}</span>
</li>
);
})}
</ul>
</article>
);
};
export default Laps;
현재 코드에서는 centisecond가 변화할때마다 stopwatch 내 자식으로 있는 모든 컴포넌트가 리렌더링이 되고 있기 때문에 그럴 필요가 없는 컴포넌트(Button, Laps)들은 prop 혹은 의존성이 변화할때만 리렌더링 되도록 해줘야 한다.
하지만 지나친 성능 최적화도 좋지 않다. 이 또한 또다른 연산을 수반하기 때문이다. 꼭 필요한 경우가 아니라면 하지말자.
import { useState, useRef, useCallback } from 'react';
const useTimer = () => {
const [centisecond, setCentisecond] = useState(0);
const [lapCount, setLapCount] = useState(1);
const [isRunning, setIsRunning] = useState(false);
const [timerInterval, setTimerInterval] = useState(null);
const [laps, setLaps] = useState([]);
let prevLap = useRef(0);
const start = useCallback(() => {
let _interval = setInterval(() => {
setCentisecond((prev) => prev + 1);
}, 10);
setTimerInterval(_interval);
setIsRunning((prev) => !prev);
}, []);
const pause = useCallback(() => {
clearInterval(timerInterval);
setTimerInterval(null);
setIsRunning((prev) => !prev);
}, [timerInterval]);
const createLap = () => {
setLapCount((prev) => prev + 1);
const lapTime = centisecond - prevLap.current;
setLaps((prev) => [[lapCount, lapTime], ...prev]);
prevLap.current = centisecond;
};
const reset = useCallback(() => {
setCentisecond(0);
setLapCount(0);
prevLap.current = 0;
setLaps([]);
}, []);
return { centisecond, start, pause, createLap, reset, isRunning, laps };
};
export default useTimer;
import { memo } from 'react';
import formatTime from '../util/formatTime';
const Laps = ({ laps }) => {
//첫번째 방법
const lapTimeArr = laps.reduce((acc, cur) => [...acc, cur[1]], []);
// 두번쨰 방법
// const lapTimeArr2 = []
// laps.forEach((lap) => lapTimeArr2.push(lap[1]))
const maxIndex = lapTimeArr.indexOf(Math.max(...lapTimeArr));
const minIndex = lapTimeArr.indexOf(Math.min(...lapTimeArr));
const minMaxStyle = (idx) => {
if (laps.length < 2) return;
if (idx === maxIndex) return 'text-red-600';
if (idx === minIndex) return 'text-green-600';
};
return (
<article className="text-gray-600 h-64 overflow-auto border-t-2">
<ul>
{laps.map((lap, idx) => {
return (
<li
className={`flex justify-between py-2 px-3 border-b-2 ${minMaxStyle(
idx
)}`}
key={lap[0]}
>
<span>랩 {lap[0]}</span>
<span>{formatTime(lap[1])}</span>
</li>
);
})}
</ul>
</article>
);
};
export default memo(Laps);