2022 OSAM 해커톤 사전 온라인 교육에서 배운 내용입니다.
모르는 내용만 발췌하여 정리한 것이기 때문에 내용의 연결성이 부족한 점 양해 부탁드립니다.
// 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;
// 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/useStopwatch
💡 수정된 파일만 작성하였습니다.
// 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;
// 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;
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의 쓰임에 집중하자!!
// 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;
}`;
// 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;
// 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.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;
// 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할 수 있음.
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;