시작하며
해당 글은 똑똑한개발자 github에 있는 next-ts
을 기준으로 작성되었습니다.
VSCode 기준으로 설정 되었으며, 다른 IDE를 쓸 경우 ESLint와 Prettier가 다르게 보일 수 있습니다.
공통 사용 스택
- Next.js, PWA, TypeScript, styled-components, Storybook
- axios
- 현재(글 마지막 업데이트 기준) 프로젝트 패키지 버전
{
"dependencies": {
"@material-ui/core": "^4.11.0",
"@types/react-select": "^3.0.26",
"@types/react-slick": "^0.23.4",
"axios": "^0.20.0",
"mobx": "^6.0.4",
"mobx-react": "^6.2.2",
"next": "^10.0.5",
"next-pwa": "^3.1.4",
"react": "^16.12.0",
"react-device-detect": "^1.13.1",
"react-dom": "^16.12.0",
"react-flip-toolkit": "^7.0.13",
"react-select": "^3.1.0",
"react-slick": "^0.27.13",
"slick-carousel": "^1.8.1",
"styled-components": "^5.1.1",
"styled-theming": "^2.2.0"
},
"devDependencies": {
"@babel/core": "^7.12.3",
"@babel/plugin-proposal-decorators": "^7.10.1",
"@storybook/addon-actions": "^6.0.28",
"@storybook/addon-essentials": "^6.0.28",
"@storybook/addon-links": "^6.0.28",
"@storybook/addon-storysource": "^6.1.14",
"@storybook/addons": "^6.1.10",
"@storybook/react": "^6.0.28",
"@storybook/theming": "^6.0.28",
"@svgr/webpack": "^5.5.0",
"@types/node": "12.12.21",
"@types/react": "16.9.16",
"@types/react-dom": "16.9.4",
"@types/styled-components": "^5.1.0",
"awesome-typescript-loader": "^5.2.1",
"babel-loader": "^8.1.0",
"babel-plugin-module-resolver": "^4.0.0",
"babel-plugin-styled-components": "^1.10.7",
"cross-env": "7.0.2",
"eslint-config-prettier": "^7.0.0",
"eslint-plugin-prettier": "^3.2.0",
"eslint-plugin-react": "^7.21.5",
"fork-ts-checker-webpack-plugin": "^5.2.1",
"prettier": "^2.2.1",
"react-docgen-typescript-loader": "^3.7.2",
"react-is": "^17.0.1",
"tsconfig-paths-webpack-plugin": "^3.3.0",
"typescript": "3.7.3"
}
}
mobx
는 원할 경우 v6로 업데이트 가능. 현재 글에서는 v5로 진행
상황별 사용할 라이브러리
- Admin 페이지 같은 경우, material-ui 적극 활용
- material-ui/datepicker: 날짜를 달력으로 선택
-
moment:날짜
⇒ dayjs 사용, moment의 경우 라이브러리가 너무 크며 업데이트 중단- react-hook-form: form
- react-use-gesture: 모바일 드래그 모션
- component는 스토리북을 열어 직접 확인 부탁드립니다. [Storybook 활용법]
마지막 업데이트: 2021.01.25
ESLint, Prettier 설정
VSCode에 ESLint, Prettier Extension이 설치 되어 있어야 합니다.
- ESLint는 코드의 잠재적 에러나 오류 탐지, Prettier는 코드 포매터에 집중하여 코드를 깔끔하게 만들어주는 역할을 합니다.
-
VSCode 설정 (window:
ctrl + ,
mac:command + ,
)에서Format On Save
를 체크하면 저장할 때마다 바뀝니다.참고: [VSCode Extension 추천 및 설정]
next-ts
폴더 구조
.next
: 프로젝트 build로 생성되는 폴더.storybook
: Storybook 설정 폴더api
: 공통으로 사용 되는 api (Axios 커스텀)components
: 공통으로 사용 되는 컴포넌트 모음containers
: 해당 프로젝트에서 사용 되는 화면 구현layout
: 레이아웃에서 공통으로 사용 되는 stylepages
: 파일 이름으로 라우터 생성public
: 이미지, 아이콘 등stores
: 전역상태관리 폴더utils
: 프로젝트마다 공통으로 사용될 만한 hooks, format 등 모음
프론트 개발의 경우, 주로 containers
폴더에서 진행되며, 이미지는 public
폴더에 저장합니다.
아래부터는 프론트 개발 흐름에 맞춰 설명을 진행합니다.
Git branch
- 2명 이상이서 작업할 경우,
feature/이름이니셜-작업하는컴포넌트 혹은 페이지
로 생성하여 작업합니다.
git branch -m feature/kmj-modal
git branch -m feature/kmj-main
공통
- tsx가 없을 경우
.ts
확장자를 사용합니다. (styled, type만 선언하는 코드) - 한 파일 당 최대 300줄이 넘지 않게 작성합니다.
- 이미지는
img
태그가 아니라div
의 background로 주어 깨지지 않도록 합니다. -
변수, 함수 명은 카멜 케이스로 작성합니다. (ex. dailyUserTable)
- 함수 첫 글자는 동사로 시작합니다 (예시)
-
최대한 누가 봐도 바로 알아볼 수 있는 변수, 함수 명을 짓습니다.
-
Bad
import UpperContainer from "./UpperContainer"; import One from "./One";
-
Good
import UserLoginContainer from "./UserLoginContainer"; import LoginStepOne from "./LoginStepOne";
-
-
폴더명, 파일명
- 폴더명
- 위의
next-ts
의 최상단 폴더와pages
안의 폴더를 제외하고 모두 대문자로 시작합니다. - 단,
index
는 소문자로 시작합니다.
- 위의
-
파일명
pages
안의 모든 파일은 소문자입니다.- 각 폴더의 import 할 메인 파일은
index.tsx
로 합니다.
// container/Main/index.tsx 일 경우 import Main from "container/Main"; // 자동으로 index.tsx를 불러옴
- 폴더명
- 가급적
import * as React
을 줄입니다.- 전부다 import 해오기 때문에 크기가 크며, 자칫하면 다른 라이브러리, 모듈과 겹쳐 overriding 이슈가 있을 수 있습니다.
- 어떤 것을 사용할 건지 알 수 없어 가독성이 떨어집니다. 따라서 정확히 어떤 것을 import 했는지 나열하는 것이 좋습니다.
-
import 순서는 아래와 같으며, 순서 마다 개행(enter)합니다.
- 패키지, 라이브러리
- layout, api, utils
- container, components, 내부 폴더의 컴포넌트
- 나머지 (ex. 이미지, store, styled, types)
import React from "react"; import Router from "next/router"; import styled, { css } from "styled-components"; import theme from "layout/theme"; import * as AuthAPI from "api/Auth"; import { StorageSetToken } from "utils/Storage"; import MainContainer from "container/Main"; import Layout from "components/layout"; import HomeIcon from "./HomeIcon"; import SearchProducts from "./fragment/SearchProducts"; import CountStore from "stores/Count"; import { Button, Icon } from "./Social.styled";
- 최대한 같은 카테고리 끼리 묶는 것을 권장하지만, 컴포넌트 구조에 따라 import 순서를 바꾸는 것이 더 알아보기 쉽다면 유동적으로 순서를 바꾸어도 괜찮습니다.
-
타입 정의
-
컴포넌트 안에 사용될 props의 타입을 import와 컴포넌트 사이에 정의하고, 컴포넌트 옆에 컴포넌트 타입과 해당 props 타입을 작성합니다. (ex.
React.FC<ButtonProps>
)// components/Button/index.tsx export type ButtonProps = { outline?: boolean; disabled?: boolean; round?: boolean; color?: string; style?: any; onClick?: any; children?: any; }; const ButtonComponent: React.FC<ButtonProps> = ({ children, ...props }) => { return <Button {...props}>{children}</Button>; };
-
다른 곳에서도 다중으로 사용하는 것이라면, 해당 폴더에 따로 타입 파일을 만드는 것이 좋습니다.
Button ├─ Social/ ├─ index.stories.tsx ├─ index.tsx └─ types.ts (type 파일 생성)
- 최대한
any
가 없도록 작성합니다. -
배열 안에 객체가 있다면 아래와 같은 형식으로 작성합니다.
type User = { id: number; name: string; }; type Props = { items: User[]; }; const List = ({ items }: Props) => ( <ul> {items.map((item) => ( <li key={item.id}> <ListItem data={item} /> </li> ))} </ul> );
-
styled-components에서 사용 될 type은
export default 컴포넌트
아래 작성합니다.style 타입 작성 법에는 두 가지가 있습니다.
첫 번째:
// ... 생략 interface actionProps { search?: boolean; } const ActionButton = styled.div<actionProps>` cursor: pointer; width: 100px; height: 40px; font-size: 15px; display: flex; justify-content: center; align-items: center; border: 1px solid ${(props) => props.theme.color.PRIMARY}; background-color: ${(props) => props.search ? props.theme.color.PRIMARY : "white"}; color: ${(props) => (props.search ? "white" : props.theme.color.PRIMARY)}; &:first-child { margin-right: 10px; } `;
두 번째:
// ...생략 type styleProps = { active?: boolean; noMobile?: boolean; centered?: boolean; }; const TabsBox = styled.div` z-index: 999; border-bottom: 1px solid ${theme.color.GRAY1}; /* Mobile */ ${theme.window.mobile} { > div { width: 100%; padding: 0px; } ${(props: styleProps) => props.noMobile && css` display: none; `} } `;
- 두 번째 방법의 경우, 사용하려는 곳에서만 styleProps를 넘겨주면 되지만, 첫 번째 처럼 전역의 props를 같이 사용할 경우 타입 에러가 뜹니다. 상황에 맞게 사용하면 됩니다.
-
-
type vs. interface
-
가장 큰 차이점은 확장 여부입니다.
interface
는 확장이 가능하지만,type
은 불가능합니다.// components/Icon/types.ts export interface SvgProps { color?: string; style?: any; className?: any; } export interface SvgWithFillProps extends SvgProps { fill?: boolean; } // 예시입니다 interface Person { name: string; age: number; } interface Developer extends Person { language: string; } const joo: Developer = { name: "joo", age: 20, language: "ts" };
-
따라서
type
보다는interface
사용을 권장합니다.
-
page
pages/index.tsx
import React from "react";
import MainContainer from "containers/Main";
import Layout from "components/Layout";
const IndexPage = () => {
return (
<Layout title="똑똑한개발자">
<MainContainer />
</Layout>
);
};
export default IndexPage;
pages
안에는 어떠한 로직도 넣지 않으며, 모두containers
에 작성합니다.- Layout은 사용하는 기기에 따라 유동적으로 height를 바꾸기 위해 사용합니다.
- 페이지에 중복되어 사용 되는 컴포넌트(ex. Nav, Footer)도 포함되어 있습니다.
- components 활용법은 스토리북 글에서 작성할 예정입니다.
container
page
하나 당container
안에 하나의 폴더가 생성됩니다.-
내부 폴더 관리
-
한 폴더에 세 개 이상의 컴포넌트가 생긴다면, 그 안에
_fragments
폴더를 생성하여 관리합니다. -
만약 fragments 안의 컴포넌트에서 또 다른 파일이 2개 이상 생긴다면, 따로 폴더를 생성합니다.
-
(예시로 만든 것이며, 해당 repo에는 없습니다.)
container
└─ Main
├─ _fragments (하나 혹은 두개만)
│ ├─ Banner (폴더 생성)
│ │ ├─ index.tsx
│ │ ├─ BannerAction.tsx
│ │ └─ BannerSlide.tsx
│ ├─ Menu.tsx
│ └─ Title.tsx
├─ index.tsx
└─ types.ts
- 만약, Main에 event라는 하위 페이지가 필요하다면 아래와 같이 폴더를 생성하여 작업합니다. (url은
/main/event
)
(예시로 만든 것이며, 해당 repo에는 없습니다.)
container
└─ Main
├─ _fragments (하나 혹은 두개만)
│ ├─ Banner (폴더 생성)
│ │ ├─ index.tsx
│ │ ├─ BannerAction.tsx
│ │ └─ BannerSlide.tsx
│ ├─ Menu.tsx
│ └─ Title.tsx
├─ Event (페이지는 대문자로 시작)
│ ├─ index.tsx
│ ├─ EventBanner.tsx
│ └─ EventContents.tsx
├─ index.tsx
└─ types.ts
-
함수 내 선언 순서 (ex. useState, useEffect, 함수 등)
- 내부 import > 외부 import > 내부 함수
- 내부 import는 보편적으로 store를 의미합니다.
- 외부에서 import 해온 것을 먼저 사용합니다. (ex. useState, useEffect, useRef 등)
-
그 후, 내부에서 선언한 함수를 차례로 작성합니다.
// 예시이며, import 구문은 생략합니다. const TestComponents: React.FC = () => { const countStore = useContext(CountStore); const [number, setNumber] = useState<number>(0); const divRef = useRef<any>(null); const handleClick = (e: any) => { console.log(e); }; // ...이하 생략 };
-
중복되는 코드는 최대한 줄입니다.
- 묶을 수 있다면 최대한 묶습니다.
- Bad
// container/Main/fragment/Menu
{
<>
<Item>
<div>
<ColorIcon name="search" />
</div>
<T.Text>AI 스마트검색</T.Text>
</Item>
<Item>
<div>
<ColorIcon name="fix" />
</div>
<T.Text>시공업체</T.Text>
</Item>
<Item>
<div>
<ColorIcon name="ai" />
</div>
<T.Text>AI 가상시공</T.Text>
</Item>
<Item>
<div>
<ColorIcon name="add" />
</div>
<T.Text>업체등록</T.Text>
</Item>
</>
}
```
- Good
```tsx
const itemsData = [
{
id: 1,
name: "search",
text: "AI 스마트검색",
},
{
id: 2,
name: "fix",
text: "시공업체",
},
{
id: 3,
name: "ai",
text: "AI 가상시공",
},
{
id: 4,
name: "add",
text: "업체등록",
},
];
// ...생략
{
itemsData.map((item) => (
<Item key={item.id}>
<div>
<ColorIcon name={item.name} />
</div>
<T.Text>{item.text}</T.Text>
</Item>
));
}
```
### api
- 각 페이지에서 사용하는 api들을 하나의 파일로 만들어 사용합니다.
- 예시
Api ├─ API ├─ Auth ├─ Payment.ts └─ Product.ts
Payment.ts
```tsx
import API from "./API";
export const postCreate = () => {
return API.post(`/api/v1/order/`);
};
export const postConfirm = (req: any) => {
return API.post(`/api/v1/order/confirm/`, req);
};
export const putLaterCrate = (req: any) => {
return API.put(`/api/v1/order/payment/${req.payment}/`, req);
};
사용하려는 컴포넌트에서 import 하여 사용합니다.
import * as PaymentAPI from 'api/Payment'
// ...
PaymentAPI.postCreate().then( res =>
// ...
)
store
- 해당 글은 똑똑한개발자의 mobx 컨벤션을 기점으로 작성되었으며 언제든 바뀔 수 있습니다.
- 2021.01에 mobx v6로 변경되었습니다.
stores/Count.ts
import { makeAutoObservable } from "mobx";
class Count {
number: number = 0;
constructor() {
makeAutoObservable(this);
}
increase = () => {
this.number++;
};
decrease = () => {
this.number--;
};
}
const countStore = new Count();
export default countStore;
pages/count.tsx
import React, { ReactNode, useContext } from "react";
import Link from "next/link";
import { observer } from "mobx-react"; // state를 실시간으로 감지
import Layout from "components/Layout";
import countStore from "stores/Count";
type Props = {
children?: ReactNode;
};
// 함수 전체를 observer로 감싸줌
const AboutPage: React.FC<Props> = observer(() => {
return (
<Layout title="About | Next.js + TypeScript Example">
<div style=>
<h1>Count</h1>
<p>This is the count page</p>
{countStore.number} {/* 전역의 number state 가져오기 */}
<button onClick={countStore.increase}>+</button>
<button onClick={countStore.decrease}>-</button>
<p>
<Link href="/">
<a>Go home</a>
</Link>
</p>
</div>
</Layout>
);
});
export default AboutPage;
- 각 기능이 생길 때마다 새로운 Store를 생성하고, 필요한 컴포넌트 안에서 import 해서 사용합니다.
-
createContext
를 사용해야 하는 경우-
contextAPI는 context 안의 값이 변경될 때마다 컴포넌트는
useContext
를 부릅니다.⇒ 이는 mobx의
observable
,observer
와 중복되는 기능이라 사용하지 않아도 됩니다. -
context는 주로 props drilling을 피하기 위해 사용되며, 최상위 컴포넌트에서 최하위 컴포넌트까지 store에 있는 state를 props로 넘겨줄 때 사용합니다.
const TimerView = observer(() => { const timer = useContext(TimerContext); return <span>Seconds passed: {timer.secondsPassed}</span>; }); ReactDOM.render( <TimerContext.Provider value={new Timer()}> <TimerView /> </TimerContext.Provider>, document.body );
-
위의 count 예제를 createContext 할 경우
sotres/Count.ts
import { createContext } from "react"; import { observable, action } from "mobx"; class Count { // 전역에서 관리할 state를 type과 함께 지정 @observable number: number = 0; // increase 됐을 때 @action increase = () => { this.number++; }; // decrease 됐을 때 @action decrease = () => { this.number--; }; } const countStore = new Count(); export default createContext(countStore);
page/count.tsx
import React, { ReactNode, useContext } from "react"; import Link from "next/link"; import Layout from "components/Layout"; import { observer } from "mobx-react-lite"; import CountStore from "stores/Count"; type Props = { children?: ReactNode; }; const AboutPage: React.FC<Props> = observer(() => { const countStore = useContext(CountStore); return ( <Layout title="About | Next.js + TypeScript Example"> <h1>Count</h1> <p>This is the count page</p> {countStore.number} <button onClick={countStore.increase}>+</button> <button onClick={countStore.decrease}>-</button> <p> <Link href="/"> <a>Go home</a> </Link> </p> </Layout> ); }); export default AboutPage;
-
-
-
mobx-react, mobx-react-lite 차이
- mobx-react v6부터 mobx-react-lite를 사용하지 않고도 hooks를 지원합니다
- mobx-react는 함수형, 클래스형을, mobx-react-lite는 함수형만 지원
// 함수형 예시 import { observable } from "mobx"; export interface TodoData { id: number; content: string; checked: boolean; } interface Todo { todoData: TodoData[]; currentId: number; addTodo: (content: string) => void; removeTodo: (id: number) => void; } export const todo = observable<Todo>({ todoData: [], currentId: 0, addTodo(content) { this.todoData.push({ id: this.currentId, content, checked: false }); this.currentId++; }, removeTodo(id) { const index = this.todoData.findIndex((v) => v.id === id); if (id !== -1) { this.todoData.splice(index, 1); } }, });
components
- components는 어느 프로젝트에서든 재사용 할 수 있는 것을 만들어 두는 곳입니다.
- 기존에 만들어 둔 것은
npm run storybook
oryarn storybook
을 하면 확인할 수 있습니다. (추후 components 활용 방법도 스토리북 Docs에 업데이트 예정)
-
components/Button
을 예로 들겠습니다.- Button의 기반이 되는 것은
index.tsx
이며 이를 스토리북 서버에 띄울 수 있는 것이index.stories.tsx
입니다. stories는 모두index.tsx
import하여 만들어 졌습니다.
- Button의 기반이 되는 것은
프로젝트 초기 세팅
- 디자인, 기획, 개발까지 똑똑한개발자에서 하는 프로젝트라면 내부 디자인 시스템을 사용합니다.
- 위에 해당하는 경우, 디자이너님이 공유해주신 제플린을 확인하고 공통으로 사용되는 컴포넌트를 찾은 후, color를 설정 해주는 것이 좋습니다.
- 제플린에서 상단 메뉴에 ‘Styleguide’ 탭을 클릭하면 재사용되는 색상을 저장했습니다.
- 이를
layout/theme
에서 수정하면 됩니다. - 다크 모드의 경우 해당 프로젝트를 담당하는 디자이너와 상의하여 추가합니다. (제플린에 바로 올릴 경우, 이름이 중복되어 덮어씌워지는 에러가 있다고 합니다.)
- 다크모드는 라이트모드와 1:1 매칭이 되어야 정상 작동합니다.