스톱워치 구현 (Vanilla JS & React)

Vincent·2023년 5월 16일
0

요구사항

1. 시작, 중단 기능

시작 S 버튼을 클릭하면 아래와 같은 작업이 이루어져야 합니다.

  • 스톱워치가 시작하고, 스톱워치의 시간이 올라갑니다.
  • 좌측의 리셋 L 버튼이 랩 L 버튼으로, 우측의 시작 S 버튼이 중단 S 버튼으로 변경됩니다.

중단 S 버튼을 클릭하면 아래와 같은 작업이 이루어져야 합니다.

  • 스톱워치가 일시 정지되고, 스톱워치의 시간이 멈춥니다.
  • 좌측의 랩 L 버튼이 리셋 L 버튼으로, 우측의 중단 S 버튼이 시작 S 버튼으로 변경됩니다.

라벨 뿐만 아니라 버튼의 스타일도 사진과 동일하게 변경되어야 합니다. 붉은색 버튼의 class명은 bg-red-600을, 초록색 버튼의 class명은 bg-green-600을 사용해 주세요.

2. 시간 포맷팅 구현

스톱워치 모듈에서 내려받은 centisecond는 '[분]:[초].[100/1 초 = centisecond]'과 같은 포맷을 가져야 합니다.

  • 예시1) 355 centisecond = 00:03.55
  • 예시2) 6000 centisecond = 01:00.00
  • 예시3) 8540 centisecond = 01:25.40

3. 랩 기능

랩 L 버튼을 클릭하면 아래와 같은 작업이 이루어져야 합니다.

  • Lap Count가 함께 명시된 랩이 하나씩 기록됩니다.
  • 최신 Lap이 순서대로 맨 위에 추가됩니다.

4. 리셋 기능

리셋 L 버튼을 클릭하면 아래와 같은 작업이 이루어져야 합니다.

  • 스톱워치의 시간이 초기화 됩니다. (00:00.00)
  • 모든 랩이 사라집니다.

5. 키보드 조작 기능

버튼을 키보드로 조작할 수 있도록 해야 합니다.

키보드 L: 랩 L, 리셋 L 키보드 S: 시작 S, 중단 S

6. 최단, 최장 기록 강조 효과

Lap 중 최장 Lap 기록은 붉은색으로 (text-red-600), 최단 Lap 기록은 초록색으로 (text-green-600) 표시되어야 합니다.

Vanilla JS

우선 구현에 사용할 stopWatch 객체를 만든다.

import Stopwatch from './stopwatch.js';
const stopWatch = new Stopwatch();

1. 시작, 중단 기능

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);

2. 시간 포맷팅 구현

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;
};

3. 랩 기능 & 4. 리셋 기능 & 6. 최단, 최장 기록 강조 효과

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);

5. 키보드 조작 기능

const onKeyDown = (e) => {
    switch (e.code) {
        case 'KeyL':
            onClickLapResetBtn();
            break;
        case 'KeyS':
            onClickStartStopBtn();
            break;
    }
};
document.addEventListener('keydown', onKeyDown);

React

0. 컴포넌트화

전체 구조

1. 시작, 중단 기능

커스텀 훅(useTimer) 활용
(여러 개의 자바스크립트 함수에서 같은 로직을 공유)

2. 시간 포맷팅 구현

Vanilla JS 때 만들었던 formatTime 함수를 가져와서 util이라는 공용 함수로 따로 빼두어 필요한 컴포넌트에서 import하여 활용할 수 있도록 한다.

3. 랩 기능 구현

랩 버튼을 누르면 useTimer 내부의 createLap 함수가 실행되도록 하여 (새로운 [lapCount, lapTime]이 laps 배열 맨 앞에 추가)되도록 하고 laps 컴포넌트에서 map 활용하여 laps 펼치기

반복 순회문 사용 시 key가 필요한 이유?

  • 기존에 있던 내용과 새롭게 추가 되는 내용 (이전 상태와 현재 상태 비교)을 구분하기 위함 (효율적 렌더링을 위해)
  • 그런 의미에서 idx를 key값으로 사용하는 것은 좋지 못한 방법. (배열이 변경될 때 변할 소지 있음)

4. 리셋 기능 구현

isRunning이 아닌 상태에서 lapResetBtn을 눌렀을 때 onClickHandler로 reset 기능 button에 전달

5. 키보드 조작 기능

keydown시 각 버튼 클릭한 것 처럼 작동하는 handler 함수 활용
최적화 용도로 useEffect에 clean up 함수 추가

6. 최단, 최장 기록 강조 효과

laps 배열 돌면서 lapTime 값만 들고와서 새로운 배열 만들기 (reduce 활용하여 이어붙이기)
그 배열에서 최댓값, 최솟값 인덱스 저장해두었다가, map 함수로 배열 돌면서 랩 펼칠 때

App.js

import './App.css';
import Footer from './components/Footer';
import Stopwatch from './components/Stopwatch';
function App() {
    return (
        <>
            <Stopwatch />
            <Footer />
        </>
    );
}

export default App;

useTimer.jsx (커스텀 훅)

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;

formatTime.js (util 함수)

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;

Stopwatch.jsx

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;

Timer.jsx (분, 초 표시 부분)

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;

Button.jsx

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;

Laps.jsx

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;

React 리팩토링 (최적화)

  • React.memo : 메모이제이션 된 리액트 컴포넌트를 반환한다. (고차 컴포넌트)
    React.memo에 전달된 컴포넌트는 prop이 변할때만 리렌더 된다.
  • useMemo : 메모이제이션 된 연산 값(함수 실행의 결과)을 반환한다.
    useMemo에 전달된 함수는 의존성이 변경되었을 때만 다시 실행되어 값을 리턴한다.
  • useCallback : 메모이제이션 된 함수를 리턴한다.
    useCallback에 전달된 함수는 의존성이 변경되었을 때만 새롭게 생성되어 참조값이 변경된다.

현재 코드에서는 centisecond가 변화할때마다 stopwatch 내 자식으로 있는 모든 컴포넌트가 리렌더링이 되고 있기 때문에 그럴 필요가 없는 컴포넌트(Button, Laps)들은 prop 혹은 의존성이 변화할때만 리렌더링 되도록 해줘야 한다.

하지만 지나친 성능 최적화도 좋지 않다. 이 또한 또다른 연산을 수반하기 때문이다. 꼭 필요한 경우가 아니라면 하지말자.

useTimer.jsx

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;

Laps.jsx (새롭게 랩이 추가될 때 리렌더링)

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);
profile
Frontend & Artificial Intelligence

0개의 댓글