React 프로젝트: iPhone Stopwatch 만들기

Yongjun Park·2022년 9월 15일
0

2022 OSAM 해커톤

목록 보기
8/11

2022 OSAM 해커톤 사전 온라인 교육에서 배운 내용입니다.
모르는 내용만 발췌하여 정리한 것이기 때문에 내용의 연결성이 부족한 점 양해 부탁드립니다.

View 제작

1계층 (index → App → Stopwatch)

// index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(<App />);
// App.tsx
import React from 'react';
import Stopwatch from "./Stopwatch";

function App() {
  return <Stopwatch />;
}

export default App;
// Stopwatch.tsx
import * as react from "react";
import Controllers from "./Controllers"
import Time from "./Time"
import Laps from "./Laps"

const Stopwatch: React.FC = () => {
    return (
        <div>
            <Time seconds={10} />
            <Controllers
                state={"Processing"}
                record={() => {}}
                start={() => {}}
                stop={() => {}}
                reset={() => {}}
            />
	        <Laps nextLap="Next" laps={["l", "a", "p", "s"]} />
    	</div>
	);
};

export default Stopwatch;

2계층 (상단: Time, 중단: Controllers, 하단: Laps)

// Time.tsx
import * as react from "react";

interface IProps {
    seconds: number;
}

const Time: React.FC<IProps> = ({ seconds }) => {
    return <div>{seconds}</div>;
};

export default Time;
// Controllers.tsx
import * as react from "react";

// 작동중 : 랩      정지
// 정지   : 초기화   시작

interface IProps {
    state: unknown;
    record: () => void;
    stop: () => void;
    reset: () => void;
    start: () => void;
}

const Controllers: React.FC<IProps> = ({ 
    state,
    record,
    stop,
    reset,
    start,
}) => {
    return (
    	<div>
            {state === "Processing" ? (
                <>
             		<button onClick={record}></button>
                	<button onClick={stop}>정지</button>
                </>
              ) : (
            	<>
             		<button onClick={reset}>초기화</button>
                	<button onClick={start}>시작</button>
                </>
            )}
        </div>
    );
};

export default Controllers;
// Laps.tsx
import * as React from "react";

interface IProps {
    nextLap: string; // 지금 랩을 누르면 시간이 이렇게 찍힐거다~ 
    laps: string[];
}

const Laps: React.FC<IProps> = ({ 
    nextLap,
    laps,
}) => {
    return (
        <div>
            <div>{nextLap}</div>
            {laps.map((lap, index) => {
                return <div key={index}>{lap}</div>;
            })}
        </div>
    );
};

export default Laps;

Hooks 제작 - hooks/useStopwatch

💡 수정된 파일만 작성하였습니다.

1계층 (Stopwatch)

// Stopwatch.tsx
import * as react from "react";
import Controllers from "./Controllers"
import Time from "./Time"
import Laps from "./Laps"
import useStopwatch from "./hooks/useStopwatch";

const Stopwatch: React.FC = () => {
    // hooks
    const { seconds, status, laps, start, stop, record, reset } = useStopwatch();
    
    return (
        <div>
            <Time seconds={seconds} />
            <Controllers
                state={status}
                record={record} // record={() => {}}
                start={start}
                stop={stop}
                reset={reset}
            />
	        <Laps nextLap={{
                    title: "test",
                    id: 1,
                    seconds: 0,
                }} laps={laps} />
    	</div>
	);
};

export default Stopwatch;
// hooks/useStopwatch.tsx
import * as React from "react";

export enum STATUS {
    PROCESSING,
    STOP
}

export interface Lap {
    id: number; // 쌓이는 기록이니까
    title: string;
    seconds: number;
}

interface UseStopwatchReturnType {
    seconds: number;
    status: STATUS;
    laps: Lap[];
    
    start: () => void;
    stop: () => void;
		reset: () => void;
    record: () => void;
}

const useStopwatch: () => UseStopwatchReturnType = () => {
    // state
    const [seconds, setSeconds] = React.useState(0);
    const [status, setStatus] = React.useState<STATUS>(STATUS.STOP);
    const [laps, setLaps] = React.useState<Lap[]>([]);
    
    const start = React.useCallback(() => {}, []);
    const stop = React.useCallback(() => {}, []);
    const reset = React.useCallback(() => {}, []);
    const record = React.useCallback(() => {}, []);
    
    return {
        seconds,
        status,
        laps, 
        start,
        stop,
        reset,
        record,
    };
};

export default useStopwatch;

2계층 (중단: Controllers, 하단: Laps)

// Controllers.tsx
import * as react from "react";
import { STATUS } from "./hooks/useStopwatch"; // 이걸 가져옴

// 시작 후 : 랩      정지
// 정지    : 초기화   시작

interface IProps {
    state: STATUS;
    record: () => void;
    stop: () => void;
    reset: () => void;
    start: () => void;
}

const Controllers: React.FC<IProps> = ({ 
    state,
    record,
    stop,
    reset,
    start,
}) => {
    return (
    	<div>
            {state === STATUS.PROCESSING ? (
                <>
             		<button onClick={record}></button>
                	<button onClick={stop}>정지</button>
                </>
              ) : (
            	<>
             		<button onClick={reset}>초기화</button>
                	<button onClick={start}>시작</button>
                </>
            )}
        </div>
    );
};

export default Controllers;
// Laps.tsx
import * as React from "react";
import { Lap } from "./hooks/useStopwatch"; // 이걸 가져옴

interface IProps {
    nextLap: Lap; // 지금 랩을 누르면 시간이 이렇게 찍힐거다~ 
    laps: Lap[];
}

const Laps: React.FC<IProps> = ({ nextLap, laps }) => {
    return (
        <div>
            <div>
                {nextLap.title} with {nextLap.seconds}
            </div>
            {laps.map((lap, index) => {
                return (
                    <div key={lap.id}>
                        {lap.title} with {lap.seconds}
                	</div>
                );
            })}
        </div>
    );
};

export default Laps;

Hooks 제작 - start, stop, reset, record 구현

1계층 (Stopwatch)

import * as react from "react";
import Controllers from "./Controllers"
import Time from "./Time"
import Laps from "./Laps"
import useStopwatch from "./hooks/useStopwatch";

const Stopwatch: React.FC = () => {
    // hooks
    const { seconds, status, laps, nextLap, start, stop, record, reset } = useStopwatch();
    
    return (
        <div>
            <Time seconds={seconds} />
            <Controllers
                state={status}
                record={record}
                start={start}
                stop={stop}
                reset={reset}
            />
	        <Laps nextLap={nextLap} laps={laps} />
    	</div>
	);
};

export default Stopwatch;
import * as React from "react";
import { INTERVAL, MILLISEC_PER_SECOND } from "./constants";

export enum STATUS {
    PROCESSING,
    STOP
}

export interface Lap {
    id: number; // 쌓이는 기록이니까
    title: string;
    ****lapTime: number; // 직전 랩으로부터의 시간
    seconds: number;
}

interface UseStopwatchReturnType {
    seconds: number;
    status: STATUS;
    laps: Lap[];
    nextLap: Lap;
    
    start: () => void;
    stop: () => void;
	reset: () => void;
    record: () => void;
}

const useStopwatch: () => UseStopwatchReturnType = () => {
    // state
    const [seconds, setSeconds] = React.useState(0);
    const [status, setStatus] = React.useState<STATUS>(STATUS.STOP);
    const [laps, setLaps] = React.useState<Lap[]>([]);
    
    const nextLap = React.useMemo<Lap>(() => {
        return {
            id: laps.length + 1,
            title: `${laps.length + 1}`,
            lapTime: seconds - (laps[0]?.seconds ?? 0),
            seconds,
        };
    }, [seconds, laps]);
    
    const start = React.useCallback(() => {
        if (status !== STATUS.STOP) {
            console.debug("Status is not STOP");
            return;
        }
        
        setStatus(STATUS.PROCESSING);
        console.debug("Stopwatch just starts");
    }, [status]);
    
    const stop = React.useCallback(() => {
        if (status !== STATUS.PROCESSING) {
            console.debug("Status is not PROCESSING");
            return;
        }
        
        setStatus(STATUS.STOP);
        console.debug("Stopwatch just stops");
    }, [status]);
    const reset = React.useCallback(() => {
        // 이미 상태가 정지일 때야 가능
        if (status !== STATUS.STOP) {
            console.debug("Status is not STOP")
        }
        
        setSeconds(0);
        setLaps([]);
        console.debug("Stopwatch just resets");        
    }, [status]);
    
    const record = React.useCallback(() => {
        if (status !== STATUS.PROCESSING) {
            console.debug("Status is not PROCESSING");
            return;
        }
        
        setLaps(prev => [nextLap, ...prev]); // 배열을 단순히 추가만 하면 안됨. 의존성은 주소를 참조하고 있어서 안 바뀜
        console.debug("Stopwatch just records");        
    }, [status, nextLap]);
    
    React.useEffect(() => {
        let intervalId: number;
        
        if (status === STATUS.PROCESSING) {
            intervalId = window.setInterval(() => {
                setSeconds((prev) => {
                    return prev + INTERVAL /  MILLISEC_PER_SECOND;
                });
            }, INTERVAL); // 0.01s마다            
        }
        
        return () => { // cleanup 함수로, 컴포넌트가 사라질 때 실행됨
            window.clearInterval(intervalId);
        }
    }, [status]); // status가 바뀔 때 useEffect에 있는 함수 실행
    
    return {
        seconds,
        status,
        laps, 
        
        nextLap,
        
        start,
        stop,
        reset,
        record,
    };
};

export default useStopwatch;

// hooks/constants.ts
export const INTERVAL = 10 as const;
export const MILLISEC_PER_SECOND = 1000 as const;
  • useStopwatch 빼면 전부 다 View라서, 거의 동일하다.

💡 useState, useEffect, useCallback, useMemo의 쓰임에 집중하자!!

Time 컴포넌트 스타일링

// styles/reset.ts
import { css } from "@emotion/react";

/* http://meyerweb.com/eric/tools/css/reset/ 
   v2.0 | 20110126
   License: none (public domain)
*/

export const reset = css`
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed, 
figure, figcaption, footer, header, hgroup, 
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
	margin: 0;
	padding: 0;
	border: 0;
	font-size: 100%;
	font: inherit;
	vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure, 
footer, header, hgroup, menu, nav, section {
	display: block;
}
body {
	line-height: 1;
}
ol, ul {
	list-style: none;
}
blockquote, q {
	quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
	content: '';
	content: none;
}
table {
	border-collapse: collapse;
	border-spacing: 0;
}`;

1계층 (App → Stopwatch)

// App.tsx
import React from 'react';
import Stopwatch from "./Stopwatch";
import { Global } from "@emotion/react";
import styled from "@emotion/styled";
import { reset } from "./styles/reset";

function App() {
    return (
			<Container>
				<Global styles={reset} />
        <Stopwatch />
    	</Container>
    ); // Global을 쓰면, 전역에 해당 스타일이 적용됨. 
				// reset은 HTML에 기본으로 있는 padding 등을 모두 삭제하는 것. 
}

const Container = styled.div`
    position: absolute;
    left: 50%;
		top: 50%;
		transform: translate(-50%, -50%);
`;

export default App;
// Stopwatch.tsx

import * as react from "react";
import styled from "@emotion/styled";
import Controllers from "./Controllers"
import Time from "./Time"
import Laps from "./Laps"
import useStopwatch from "./hooks/useStopwatch";

const Stopwatch: React.FC = () => {
    // hooks
    const { seconds, status, laps, nextLap, start, stop, record, reset } = useStopwatch();
    
    return (
        <IPhone>
            <Screen>
							...
            </Screen>
    	</IPhone>
	);
};

// 하얀색 테두리
const IPhone = styled.div`
    border-radius: 30px;
    width: 400px;
    height: 800px;
    
    background-color: #fbfbfd;
    padding: 20px;
    box-shadow: 7px 7px 10px rgba(0, 0, 0, 0.4),
    	inset -5px -5px 15px rgba(0, 0, 0, 0.2),
      inset 2px 0px 15px rgba(0, 0, 0, 0.2);
`;

// 검은색 액정
const Screen = styled.div`
    width: 100%;
		height: 100%;
    border-radius: 30px;
    overflow: hidden;
    background-color: black;
    
    display: flex;
    flex-direction: column;
    justify-content: stretch;
    align-items: stretch;
`;

export default Stopwatch;

2계층 (상단: Time)

// Time.tsx

import * as react from "react";
import styled from "@emotion/styled";
import { stopwatchTime } from "./utils"; // 어떻게 폴더로부터 import한거지?!

interface IProps {
    seconds: number;
}

// 00:00:00:00
const Time: React.FC<IProps> = ({ seconds }) => {
    return <Container>{stopwatchTime(seconds)}</Container>;
};

const Container = styled.div`
    color: white;
    font-size: 60px;
    
    display: flex;
    justify-content: center;
    align-items: center;
    
    flex: 1;
`;

export default Time;
// utils/stopwatchTime.ts
import { SECOND_PER_HOUR, SECOND_PER_MINUTE } from "../hooks/constants";

export function stopwatchTime(seconds: number) {
    const h = Math.floor(seconds / SECOND_PER_HOUR);
    const m = Math.floor((seconds % SECOND_PER_HOUR) / SECOND_PER_MINUTE);
    const s = seconds - h * SECOND_PER_HOUR - m * SECOND_PER_MINUTE;
    
    // 00:00:00:00
    return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${s.toFixed(2).padStart(5, "0")}`;
}

// utils/index.ts
export * from "./stopwatchTime";
  • index 에서 모아서 한꺼번에 export해주면, import { stopwatchTime } from "./utils";가 가능해진다.

Controllers 컴포넌트 스타일링

2계층 (중단: Controllers)

// Controllers.tsx
import * as React from "react";
import styled from "@emotion/styled";
import Button from "./Button";
import { STATUS } from "./hooks/useStopwatch";

// 시작 후 : 랩      정지
// 정지    : 초기화   시작

interface IProps {
    state: STATUS;
    record: () => void;
    stop: () => void;
    reset: () => void;
    start: () => void;
}

const Controllers: React.FC<IProps> = ({ 
    state,
    record,
    stop,
    reset,
    start,
}) => {
    return (
    	<Container>
            {state === STATUS.PROCESSING ? (
                <>
             		<Button type="NORMAL" onClick={record}></Button>
                	<Button type="ERROR" onClick={stop}>정지</Button>
                </>
              ) : (
            	<>
             		<Button type="NORMAL" onClick={reset}>초기화</Button>
                	<Button type="SUCCESS" onClick={start}>시작</Button>
                </>
            )}
        </Container>
    );
};

const Container = styled.div`
    flex: none;
    
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 30px;
    
    border-bottom: 1px solid #1d1c1e;
`;

export default Controllers;

3계층 (Button of Controllers)

// Button.tsx
import * as React from "react";
import { css } from "@emotion/react";
import styled from "@emotion/styled";

type ButtonType = "NORMAL" | "SUCCESS" | "ERROR";

interface IProps {
    type: ButtonType;
    onClick: () => void;
    children: React.ReactNode;
}

// IProps에서 onClick만 제외한 타입 -> type과 children만 있으면 됨. 
type StyledProps = Omit<IProps, "onClick" | "children">;

const Button: React.FC<IProps> = ({ type, onClick, children }) => {
    return (
        <Container type={type} onClick={onClick}>
            {children}
        </Container>
    );
};

// CSS 안에서 쓸 타입 모음
const getStyleByType = (props: StyledProps) => {
    let color = "white";
    let backgroundColor = "#333333";
    
    switch(props.type) {
        case "NORMAL":
            color = "white";
            backgroundColor = "#333333";
            break;
        case "SUCCESS":
            color = "#095D22";
            backgroundColor = "#0A2A12";
            break;
        case "ERROR":
            color = "#EB594F";
            backgroundColor = "#340D0B";
    }
    
    return css`
    	color: ${color};
        background-color: ${backgroundColor};
    `;
};

const Container = styled.div<StyledProps>`
    width: 100px;
    height: 100px;
    border-radius: 50px;

    display: flex;
    justify-content: center;
    align-items: center;
    ${getStyleByType}
    
    &:hover {
    	opacity: 0.8;
        cursor: pointer;
    }
`;

export default Button;
// export default는 파일 하나에 하나만 할 수 있다! 
// import할 때 이름을 마음대로 지어도 되고, { } 없이 import할 수 있음.

Laps 컴포넌트 스타일링

2계층 (하단: Laps)

import * as React from "react";
import styled from "@emotion/styled";
import { Lap, STATUS } from "./hooks/useStopwatch";
import { stopwatchTime } from "./utils";

interface IProps {
    status: STATUS;
    nextLap: Lap; // 지금 랩을 누르면 시간이 이렇게 찍힐거다~ 
    laps: Lap[];
}

const LapItem: React.FC<Lap> = ({title, lapTime}) => {
    return (
        <Box>
            <span>{title}</span>
            <span>{stopwatchTime(lapTime)}</span>
        </Box>
	);
};

const Laps: React.FC<IProps> = ({ status, nextLap, laps }) => {
    
    // 랩 1도 안 찍은 상태에서 정지했을 때 사라지는 버그 -> STATUS 값을 PROCESSING, STOP 말고 INIT도 만들어야 됨. 
    const showNextLap = React.useMemo(() => {
        return status === STATUS.PROCESSING || laps.length;
    }, [status, laps]);
    
    return (
        <Container>
            {showNextLap && <LapItem {...nextLap} />}
            {laps.map((lap, index) => {
                return <LapItem key={lap.id} {...lap} />;
            })}
        </Container>
    );
};

const Container = styled.div`
    flex: 1;
    
    display: flex;
    flex-direction: column;
    align-items: stretch;
    justify-content: stretch;
    overflow: auto;
    
    &::-webkit-scrollbar {
    	display: none;
    }
`;

const Box = styled.div`
    display: flex;
    align-items: center;
    justify-content: space-between;
    color: white;
    font-size: 24px;
    
    padding: 20px;
    
    &:not(last-of-type) {
    	border-bottom: 1px solid #1d1c1e;
    }
    
    &:last-of-type:not(:nth-of-type(1)):not(:nth-of-type(2)) {
    	color: #EB594F;
    }
    
    &:nth-of-type(2) {
    	color: #095D22;
    }
`;

export default Laps;

profile
추상화되었던 기술을 밑단까지 이해했을 때의 쾌감을 잊지 못합니다

0개의 댓글