리액트 무한 스크롤 라이브러리 사용해보기

react-infinite-scroller

위 라이브러리를 사용하여 리액트에서 무한 스크롤을 사용해보자.

1
2
3
4
npm i react-infinite-scroller

# typeScript를 사용한다면 추가
npm i --save-dev @types/react-infinite-scroller
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import { useEffect, useState } from "react";
import InfiniteScroll from "react-infinite-scroller";

function App() {
const [data, setData] = useState<{ label: string }[]>([]);
// const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

function handleLoadMore() {
console.log("more load!");
}

// if (loading) {
// return <div>Loading ...</div>;
// }

if (error) {
return <div>Error: {error}</div>;
}

useEffect(() => {
fetch(Markdown)
.then((res) => res.text())
.then((data) => setMarkdown(data));
}, [markdown]);

useEffect(() => {
// setLoading(true);
try {
setData(customAssetList);
// setLoading(false);
} catch (error) {
setError(error);
// setLoading(false);
}
});

return (
<div className="App">
<InfiniteScroll
// 사용자에 의해 많은 아이템이 요청될 실행하는 콜백함수
loadMore={handleLoadMore}
// 이벤트 리스너를 component의 부모요소에 추가
useWindow={false}
// 해당 아이템을 감싸는 태그
element="article"
className="index"
// loading 중일 보여줄 요소
loader={
<div className="loader" key={0}>
Loading ...
</div>
}
>
{data.map((da, index) => (
<div key={index}>
<h3>종목: {index + 1}</h3>
<p>{da.label}</p>
</div>
))}
</InfiniteScroll>
</div>
);
}
  • data를 state로 관리해주고 useEffect 훅을 사용하여 처음 화면이 렌더링 될 때, loading 상태를 변경시키고 data를 업데이트한다.
  • InfiniteScroll 컴포넌트는 내부적으로 loading 상태일 때를 관리하고 있어 개발자가 따로 loading state를 가질 필요가 없다.

검색 기능 활성화 시키기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import debounce from "lodash/debounce";

function App() {
const [data, setData] = useState<{ label: string }[]>([]);
const [searchValue, setSearchValue] = useState("");
const [filteredData, setFilteredData] = useState<{ label: string }[]>([]);

const handleSearchValue = debounce((value) => {
setSearchValue(value);
const filtered = data.filter((item) => item.label.includes(value));
setFilteredData(filtered);
}, 300);

const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.currentTarget.value;
handleSearchValue(value);
};

return (
<div>
<InfiniteScroll
loadMore={handleLoadMore}
useWindow={false}
element="article"
className="index"
loader={
<div className="loader" key={3}>
Loading ...
</div>
}
>
{searchValue !== ""
? filteredData.map((item, index) => (
<div key={index}>
<h3>종목: {index + 1}</h3>
<p>{item.label}</p>
</div>
))
: data.map((da, index) => (
<div key={index}>
<h3>종목: {index + 1}</h3>
<p>{da.label}</p>
</div>
))}
</InfiniteScroll>
</div>
);
}
  • handleChange를 onChange 이벤트 핸들러로 등록하여 change 이벤트가 발생할 때 마다 검색값으로 필터된 데이터를 보여준다.
  • onChange 이벤트가 너무 많이 발생하면 1만개의 데이터가 자주 렌더링 되므로 성능에 문제가 있을 수 있어 debounce를 사용하여 이벤트가 끝난 후 0.3초 후에 이벤트를 캐치하도록 구현

댓글 공유

여러 input 값 상태 관리하기

이번 프로젝트를 하다가 한 페이지에 3가지 케이스의 멀티 input을 가지는 화면이 렌더링되는 부분이 있었다.

가령 코드 구조는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// Backtest.tsx
import { ChangeEvent, useState } from "react";
import { useRecoilState } from "recoil";
import { backtestState } from "../atom";
import BacktestInputs from "../components/BacktestInputs/BacktestInputs";

function Backtest() {
const [backtest, setBacktest] = useRecoilState(backtestState);

const [backtestInputs, setBacktestInputs] = useState({
invest: "",
port: "",
dol: "",
});
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
const { value, name } = e.target;
setBacktestInputs({ ...backtestInputs, [name]: value });
};

const onSave = () => {
setBacktest(backtestInputs);
};

const onReset = () => {
setBacktestInputs({ invest: "", port: "", dol: "" });
};
return (
<>
<button onClick={onSave}>저장</button>
<BacktestInputs value={backtestInputs} onChange={onChange} />
<button onClick={onReset}>input 값 리셋</button>

<h2>전역상태</h2>
<ul>
<li>invest: {backtest["invest"]}</li>
<li>port: {backtest["port"]}</li>
<li>dol: {backtest["dol"]}</li>
</ul>
</>
);
}

export default Backtest;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// BacktestInputs.tsx
import styled from "@emotion/styled";
import { ChangeEvent } from "react";

interface Props {
value: { invest: string, port: string, dol: string };
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
}

function BacktestInputs({ value, onChange }: Props) {
const { invest, port, dol } = value;

return (
<>
<h1>Backtest</h1>
<InputWrap>
<label htmlFor="invest">Investment: </label>
<input
type="text"
name="invest"
value={invest}
onChange={onChange}
id="invest"
/>
</InputWrap>
<InputWrap>
<label htmlFor="port">Port: </label>
<input
type="text"
name="port"
value={port}
onChange={onChange}
id="port"
/>
</InputWrap>
<InputWrap>
<label htmlFor="dol">Dual: </label>
<input
type="text"
name="dol"
value={dol}
onChange={onChange}
id="dol"
/>
</InputWrap>
</>
);
}

export default BacktestInputs;

const InputWrap = styled.div`
height: 50px;
`;

나는 고민을 하였다. Backtest 페이지에서 Input을 감싸고 있는 컴포넌트가 3종류가 있고, 3종류의 컴포넌트 내부에는 다수의 input 값을 가지고 있을 때, 어떻게 input을 관리하면 좋을지 고민했다.

그렇게 하다가 생각해낸 방법이 각 컴포넌트의 input 값의 name을 가지는 객체로 input들의 상태를 관리하기로 했다.

전역상태와 input 상태

나는 전역상태를 직접 onChange 이벤트가 일어날 때 마다 바꿔주는 코드를 작성했었다. 전역상태는 최소한으로 조작을 해야지만 에러가 덜 발생하기 때문에 최종적으로 저장 버튼을 클릭했을 때만 전역상태에 저장하기로 했다.

그래서 input 상태를 객체로 가지고 저장 버튼을 클릭했을 때, 전역 상태 update 함수에게 input 상태를 파라미터로 전달하도록 구현하였다.

소감

처음에는 그 많은 input을 직접 전달해줘야하나 … 걱정을 했었다. 하지만 객체로 input을 관리하고, input 마다 name 속성을 사용하여 onChange 이벤트로 controlled input을 다룰 수 있다는 것을 알게되어서 앞으로 다중 input 상태관리는 이러한 방법으로 접근하면 된다는 것을 알게되었다.

댓글 공유

React로 카카오 로그인 연동하기

이번 프로젝트에서 카카오 로그인 기능을 구현하였다. 배우는 곳은 무조건 공식문서가 짱이지 !

하고 공식문서에 들어가봤다.

인증과 인가

인증과인가

오… OAuth 2.0 기반으로 인증과 인가를 간편, 안전하게 처리할 수 있다는 장점이 있군?!

토큰

토큰이라는 사용자의 카카오 로그인 인증 및 인가 정보를 담은 권한 증명을 제공한다. Access Token, Refresh Token 두 종류를 발급해준다.

token

로그인 과정 이해하기

로그인 과정

1단계. 카카오 로그인

  1. 클라이언트에서 백엔드서버로 로그인 요청을 보낸다.

  2. 백엔드 서버에서 인가 코드를 카카오에게 요청한다.

  3. 카카오는 인증과 동의 요청을 클라이언트에게 보낸다.

  4. 클라이언트가 동의 항목 체크하고 로그인한다.

  5. 앱에 등록된 Redirect URI로부터 인가 코드를 받고 이를 가지고 토큰을 요청한다.

  6. 카카오가 토큰을 발급해준다.

  7. 카카오 로그인이 완료된다 !

2단계. 회원 확인 및 가입

  1. 클라이언트가 발급받은 토큰으로 카카오 API 서버로 요청을 보내 사용자 정보를 가져온다.

  2. 카카오 API 서버는 요청의 토큰을 검증하여 처리한다.

  3. 백엔드 서버는 제공받은 사용자 정보로 서비스 회원인지 확인하고 신규 사용자인 경우 회원가입 시킨다.

3단계. 서비스 로그인

  1. 백엔드에서 세션을 발급해주고 클라이언트에서 로그인 완료 처리해준다.

필수 설정 항목

1. 플랫폼 등록

나의 서비스를 연결해줄 플랫폼을 등록한다.

플랫폼 등록에 가보면 Android, ios, Web을 선택할 수 있고 생성하면 API Key를 발급해준다.

  • 사이트 도메인을 설정해줘야한다.
  • http://, https://, file:// 형식의 도메인을 등록할 수 있으며, http와 https 도메인은 둘 중 한 가지만 등록해도 사용할 수 있습니다.
  • 최대 10개의 도메인을 등록할 수 있습니다.

2. 카카오 로그인 활성화

카카오 로그인에 가서 활성화 버튼을 ON으로 바꿔줘야한다.

3. Redirect URI 등록

인가 코드를 요청받은 URI를 등록해준다.

1
http://localhost:3000/oauth/callback/kakao

해당 Redirect URI로 요청을 보낼 때 필수 파라미터들이 있다.

  • cliend_id : 내 애플리케이션의 REST API 앱 키
  • redirect_uri : Redirect URI
  • response_type : code 고정값

4. 동의 항목

어떤 동의항목을 받을 것인지 설정해준다. 필수만 체크해주자 편의상

5. 구현 방법 선택

  • REST API
  • JavaScript
  • Android
  • IOS
  • Flutter

로 구현할 수 있는데, REST API로 하자.

실습

1. 카카오 로그인 요청을 보낼 버튼을 하나 만들자

1
2
3
// api_key.js
export const REST_API_KEY = `alksdjfoasidnfaofj`;
export const REDIRECT_URI = "http://localhost:3000/auth/callback/kakao";
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import { REST_API_KEY, REDIRECT_URI } from "./api_key.js";

const App = () => {
const KAKAO_AUTH_URL = `https://kauth.kakao.com/oauth/authorize?client_id=${REST_API_KEY}&redirect_uri=${REDIRECT_URI}&response_type=code`;

const handleLogin = () => {
window.location.href = KAKAO_AUTH_URL;
};

return (
<div className="App">
<button onClick={handleLogin}>카카오 로그인</button>
</div>
);
};
1
2
GET /oauth/authorize?client_id=${REST_API_KEY}&redirect_uri=${REDIRECT_URI}&response_type=code HTTP/1.1
Host: kauth.kakao.com
  • 카카오 로그인 인가 코드받기 부분에 가면 Host와 GET 요청 URI가 나와있다.
  • 이렇게 하여 로그인 버튼을 누르면 URL이 이동하여 카카오 로그인과 회원인증을 처리한다.

2. 카카오 로그인 동안 수행 내용을 컴포넌트에 작성하자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function KakaoLogin() {
const location = useLocation();
const navigate = useNavigate();
const KAKAO_CODE = location.search.split("=")[1];

const getKakaoToken = () => {
fetch(`https://kauth.kakao.com/oauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
},
body: `grant_type=authorization_code&client_id=${REST_API_KEY}&redirect_uri=${REDIRECT_URI}&code=${AUTHORIZE_CODE}`,
})
.then((res) => res.json())
.then((data) => {
if (data.access_token) {
localStorage.setItem("kakao_token", data.access_token);
} else {
navigate("/");
}
});
};

useEffect(() => {
if (!location.search) return;
getKakaoToken();
}, []);
}
1
2
3
POST /oauth/token HTTP/1.1
Host: kauth.kakao.com
Content-type: application/x-www-form-urlencoded;charset=utf-8
1
2
3
4
5
6
7
<!-- Request -->
curl -v -X POST "https://kauth.kakao.com/oauth/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "client_id=${REST_API_KEY}" \
--data-urlencode "redirect_uri=${REDIRECT_URI}" \
-d "code=${AUTHORIZE_CODE}"
  • 토큰을 얻기 위해서는 POST 요청을 위 URL로 요청을 보내라고 알려주고 있다.
  • Request에서 나온대로 fetch 함수에 적어주면 된다.

-d로 나와있는 부분이 body부분에 넣어주면된다. “”로 구분된 것들은 &기호로 구분해준다.

  • 인가 코드를 사용하기 위해서 Redirect URI에서 localhost:3000/auth/callback/kakao?code= 뒷부분을 사용하기 위해 split 코드를 사용하였다.

URLSearchParams를 사용하여도 가능하다.

결과

결과

이렇게 코드를 짜면 localStorage에 토큰까지 저장되며 로그인이 성공한 것을 알 수 있다.

이제는 토큰을 가지고 로그인 했다고 처리를 해주면 된다.

소감

이번에 OAuth2.0 기반의 카카오 로그인을 연동해보았는데 처음 해봐서 신기하기도 하고 토큰을 받고 이후에 어떻게 로그인 유지와 로그아웃 처리를 해줘야하는지도 고민할 수 있는 시간이 있어서 배운 점이 많았다.

앞으로 OAuth 연동에 대해 두려움이 줄어든 것 같다.

댓글 공유

React에서 TypeScript 올바르게 사용하기

Select 컴포넌트를 만든다고 가정해보자.

1
2
3
4
5
6
7
8
9
10
11
12
const Select = ({ label, value, options, onChange }) => {
return (
<label>
{label}
<select value={value} onChange={onChange}>
{options.map((option) => (
<option value={option.value}>{option.label}</option>
))}
</select>
</label>
);
};

1. 무지성으로 타입 정의

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const Select = ({
label,
value,
options,
onChange,
}: {
label: string;
value: string;
options: { label: string; value: string }[];
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => void;
}) => {
return (
<label>
{label}
<select value={value} onChange={onChange}>
{options.map((option) => (
<option value={option.value}>{option.label}</option>
))}
</select>
</label>
);
};

2. type, interface로 타입 정의

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type Option = { label: string; value: string };

type Options = Option[];

type SelectProps = {
label: string;
value: string;
options: Options;
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => void;
};

const Select = ({ label, value, options, onChange }: SelectProps) => {
return (
<label>
{label}
<select value={value} onChange={onChange}>
{options.map((option) => (
<option value={option.value}>{option.label}</option>
))}
</select>
</label>
);
};

3. 타입 파일 분리하기

1
2
3
4
5
6
7
8
9
10
11
// Select.d.ts
type Option = { label: string; value: string };

type Options = Option[];

type SelectProps = {
label: string;
value: string;
options: Options;
onChange: (event: React.ChangeEvent<HTMLSelectElement>) => void;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Select.tsx
const Select: React.FC<SelectProps> = ({
label,
value,
options,
onChange,
}): JSX.Element => {
return (
<label>
{label}
<select value={value} onChange={onChange}>
{options.map((option) => (
<option value={option.value}>{option.label}</option>
))}
</select>
</label>
);
};
  • 정답은 없지만 팀 내부에서 하나를 결정하고 통일할 필요는 있다.

댓글 공유

Markdown 파일 React에서 불러오기

이번 프로젝트에서 .md 파일로 저장된 데이터를 React 화면에 렌더링해야하는 작업이 필요했다.

그래서 찾아본 결과 마크다운 파일을 리액트에서 불러오기 편하도록 react-markdown 라이브러리를 찾았다.

설치

1
npm install react-markdown

사용법

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import "./App.css";
import { useEffect, useState } from "react";
import { ReactMarkdown } from "react-markdown/lib/react-markdown";
import Markdown from "./assets/markdown.md";

function App() {
const [markdown, setMarkdown] = useState("");

useEffect(() => {
fetch(Markdown)
.then((res) => res.text())
.then((data) => setMarkdown(data));
}, [markdown]);
return (
<div className="App">
<ReactMarkdown children={markdown} />
</div>
);
}

export default App;
  • 마크다운 파일을 import 해온 뒤 useEffect를 사용하여 컴포넌트가 마운트 될 때, 지정된 경로로 마크다운 파일을 로드한다.

결과

마크다운 업로드 사진

  • 마크다운 형식에 맞춰서 HTML형식으로 변환까지 잘 되었다.

TroubleShooting

1. import type 오류

나는 vite를 사용하여 프로젝트를 시작하였다.

이 때, 마크다운 파일을 리액트에서 import 해오면 vite에서는 md 파일을 경로에서 가져온다는 것을 기본적으로 포함하고 있지 않기 때문에 경로에 빨간 밑줄(error)이 생긴다.

이를 해결하기 위해서는 TypeScript에게 md 파일을 경로에서 가져온다는 타입을 선언해줘야한다.

1
2
3
4
5
// d.ts
declare module "*.md" {
const content: string;
export default content;
}

2. vite md파일 형식 오류

markdown 오류

vite 에서는 md 파일을 다룰 적절한 플러그인을 설치하거나 만약 이것이 asset이라면, vite.config.ts안에서 **/*.md를 assetsInclude에 할당하라고 알려주고 있다.

1
2
3
4
5
6
7
8
9
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
assetsInclude: ["**/*.md"],
});
  • 나는 **/*.md를 assetsInclude에 할당하라는 것이 어떤 파일에서 해야하는지 몰라서 tsconfig.json에 넣어서 해결하는데 시간이 걸렸다.

댓글 공유

useEffect 초기 렌더링 스킵하기

종속성 배열에 요소에 의존하여 어떤 로직을 실행하고 싶을 때, useEffect를 사용한다.

그런데 useEffect는 무조건 처음에 렌더링 되기 전에 실행된다.

이번 프로젝트에서 Nickname 페이지에서 Nickname을 수정하고 Mypage로 이동하여 Modal을 띄워야하는 경우가 있었다.

처음에는 Mypage에서 useEffect로 종속성 배열에 user를 넣어서 user가 변경되었음을 감지하고 effect 함수가 실행되기를 바랬지만, 이는 두가지 문제점이 있었다.

문제점1. effect는 처음에 한번 무조건 실행된다.

Nickname이 변경되었을 때만 Modal을 띄워야 하는데, Mypage가 렌더링 될 때마다 모달이 뜨는 문제가 발생하였다.

이를 해결하기 위해서는 effect 함수의 초기 렌더링때는 스킵해줘야한다.

1
2
3
4
5
6
7
8
9
10
11
12
import { useEffect, useRef } from "react";

export function useDidUpdateEffect(fn, inputs) {
const didMountRef = useRef(false);

useEffect(() => {
if (didMountRef.current) {
return fn;
}
didMountRef.current = true;
}, inputs);
}

useRef를 사용하여 초기 렌더링 때 effect 함수가 실행되는 것을 스킵할 수 있다.

문제점2. user 상태를 종속성배열로 넣어도 변화를 감지하지 못한다.

Mypage 안의 useEffect는 결국 Mypage 컴포넌트가 불러와져서 렌더링 되기 이전에 실행되는 코드이다.

그러므로 Mypage가 렌더링 되기 이전에 Nickname 페이지에서 상태를 변경한 것을 감지할 수 없다는 것이다.

이를 망각한 체 useEffect에서 어떻게하면 전역 상태의 변경을 감지할 수 있을지에 대해 오랜 시간 고민하며 시간을 허비했다…

이를 해결하기 위해서는 전역 상태로 nickname 수정이 완료되었다는 상태(NicknameModal)을 갖도록 하여 간단하게 해결할 수 있었다.

댓글 공유

onClick 이벤트 전파

카테고리 Daily

onClick 이벤트 전파

React에서 onClick 이벤트 핸들러를 등록하여 클릭 이벤트가 발생하면 이벤트가 발생한 요소 뿐만 아니라 부모 요소까지 이벤트가 전파된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function App() {
return (
<div className="App" onClick={() => console.log("container clicked.")}>
<div className="parent" onClick={() => console.log("parent clicked.")}>
<button
className="child"
onClick={() => {
console.log("child clicked.");
}}
>
버튼
</button>
</div>
</div>
);
}

export default App;
1
2
3
child clicked
parent clicked
container clicked
  • child를 클릭하면 console 창에 다음과 같이 출력된다.
  • 이벤트가 발생한 요소부터 이벤트 핸들러가 호출되고 부모 방향으로 이벤트가 전파된다.

간혹 부모 요소에서 이벤트가 발생하기를 원하지 않을 경우 이러한 이벤트 전파를 막아야한다.

그 방법으로는 event.stopPropagation()이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function App() {
return (
<div className="App" onClick={() => console.log("container clicked.")}>
<div className="parent" onClick={() => console.log("parent clicked.")}>
<button
className="child"
onClick={(e) => {
e.stopPropagation();
console.log("child clicked.");
}}
>
버튼
</button>
</div>
</div>
);
}

export default App;
1
child clicked.
  • 이벤트 전파가 발생하지 않고 이벤트가 발생한 요소만 이벤트 핸들러가 호출된다.

댓글 공유

확장성 있는 Text 컴포넌트

1
2
<Text typography="h1">나는 머릿말 1이야</Text>
<Text typography="content">나는 콘텐츠야</Text>

위와 같은 컴포넌트를 만들기 위한 작업을 시작해보자.

컴포넌트 재사용을 높이기 위해서는 누구나 이해하기 쉽게 작성하는 것이 좋다. 그러기 위해서는 선언형 코드인 HTML 방식을 따르는 것이 이득이다.

HTML 처럼 만든 컴포넌트

1
2
3
4
type Props = HTMLAttributes<"span">;
const Text = (props: Props) => {
/* ... */
};

이런식으로만 사용해도 HTML Element를 똑같이 따라할 수 있다.

하지만 HTML 속성은 ref, key 같은 리액트 속성들을 없다.

이 때 사용하는 것이 forwardRef 함수로 ref 값은 전달해준다.

1
2
3
4
5
6
const Text = forwardRef(function Text(
props: ComponentPropsWithoutRef<"span">,
ref: Ref<HTMLSpanElement>
) {
return <span ref={ref}>{props.children}</span>;
});
  • ComponentPropsWithoutRef<"span">은 ref만 제외한 나머지 리액트 프로퍼티를 모두 포함한다.

커스텀 프로퍼티 추가하기

지금까지의 컴포넌트는 그저 span element와 동일하다. 다른 커스텀 프로퍼티도 받을 수 있도록 타입스크립트의 & (intersection)을 사용하여 타입을 확장해보자.

1
2
3
4
5
6
7
8
9
10
type TextProps = {
typography?: string;
} & ComponentPropsWithoutRef<"span">;

const Text = forwardRef(function Text(
{ typography, ...props }: Props,
ref: Ref<HTMLSpanElement>
) {
//...
});

하지만, ComponentPropsWithoutRef<"span">가 이미 갖고 있는 프로퍼티와 커스텀 프로퍼티가 동일한 경우 대응하기 어렵다.

1
2
3
type TextProps = {
customId?: number;
} & ComponentPropsWithoutRef<"span">;

예를 들어 위와 같은 커스텀 프로퍼티는 ComponentPropsWithoutRef<"span">와 동일한 프로퍼티가 없기 때문에 제대로 타입이 추론된다.

1
2
3
type TextProps = {
id?: number;
} & ComponentPropsWithoutRef<"span">;

하지만 id 프로퍼티는 ComponentPropsWithoutRef<"span">가 소유하고 있어 동일한 프로퍼티 2개가 생기게 되므로 타입스크립트가 제대로 추론하지 못하고 undefined로 추론된다.

이를 해결하기 위해서는 Omit 타입을 사용하여 오버라이딩하려는 프로퍼티를 먼저 제거한 후 병합해야지만 문제가 없다.

1
type Combine<T, K> = T & Omit<K, keyof T>;
  • Combine 유틸타입은 2개의 타입을 받은 후 K타입에서 T 타입이 가진 프로퍼티와 중복되는 프로퍼티를 제거한 후 & (intersection)으로 병합한다.

이런식으로 타입을 병합하려면 상당히 귀찮기 때문에 유틸 타입을 하나 만들어둔다.

원하는 요소로 렌더링 하기

지금까지는 span 요소로만 작동하기 때문에 확장성을 위해 ComponentPropsWithoutRef<"span"> 에서 span 위치에 변수를 할당하도록 구현한다.

1
2
3
4
5
6
7
8
9
// Text 컴포넌트의 커스텀 프로퍼티 선언
type TextBaseProps<T> = {
typography?: string;
as?: T;
};

// Props<T>는 ComponentPropsWithoutRef<T>에 이 값을 그대로 넘겨준다.
// 그리고 커스텀 프로퍼티 내부의 as에도 T 타입을 바인딩해준다.
type TextProps<T> = Combine<TextBaseProps<T>, ComponentPropsWithoutRef<T>>;

하지만, 이는 오류를 발생시킨다. ComponentPropsWithoutRef가 받을 수 있는 제네릭 타입이 ElementType으로 정해져 있기 때문이다.

그래서 type TElementType을 상속한 타입이여야 한다고 명시해야한다.

1
2
3
4
5
6
7
8
9
10
11
type TextBaseProps<T extends ElementType> = {
typography?: string;
as?: T;
};

type TextProps<T extends ElementType> = Combine<
TextBaseProps<T>,
ComponentPropsWithoutRef<T>
>;

function Text<T extends ElementType>(props: TextProps<T>) {}

이제 모든 type T가 동일하다는 것을 보장할 수 있다.

  1. Text<T>의 타입 변수 T
  2. TextProps<T>의 타입 변수 T
  3. TextBaseProps<T>의 타입 변수 T
  4. as 프로퍼티에 바인딩 된 타입 변수 T
  5. ComponentPropsWithoutRef<T>의 타입 변수 T

즉, 이 중 한곳이라도 T에 대해서 명확하게 알 수 있다면 나머지 부분에서도 자연스럽게 추론이 가능하다.

as 프로퍼티로 타이핑 추상화 하기

as라는 프로퍼티는 Text 컴포넌트 뿐만 아니라 다양한 컴포넌트에서도 사용될 수 있기에 이 부분을 최대한 추상화 해둘 필요가 있다.

1
2
3
4
5
6
7
8
// 텍스트 컴포넌트의 프로퍼티
type TextBaseProps = {
typography?: string;
};

// T 타입을 추론할 수 있는 as 프로퍼티를 자동으로 포함하고
// T 타입으로 HTML 엘리먼트 속성까지 타이핑 해주는 OverridableProps!
type TextProps<T extends ElementType> = OverridableProps<T, TextBaseProps>;
  • OverridableProps 타입은 특정 컴포넌트가 as 프로퍼티를 사용하여 HTML 요소 이름을 받고 내부적으로 해당 요소의 속성 타입을 찾아 바인딩해주는 함수이다.

이렇게 필요한 부분을 추상화해두면 필자가 아닌 다른 개발자는 ComponentPropsWithoutRef을 사용해야한다던가 Combine 타입을 사용할 때 타입 변수 TElementType으로 제한해야한다던가 하는 귀찮은 부분을 생각하지 않고도 as 프로퍼티를 쉽고 빠르게 추가할 수 있을 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 전체 코드
export type Combine<T, K> = T & Omit<K, keyof T>;

export type CombineElementProps<T extends ElementType, K = unknown> = Combine<
K,
ComponentPropsWithoutRef<T>
>;

type OverridableProps<T extends ElementType, K = unknown> = {
as?: T;
} & CombineElementProps<T, K>;

type TextBaseProps = {
typography?: string;
};

type TextProps<T extends ElementType> = OverridableProps<T, TextBaseProps>;

function Text<T extends ElementType = "span">(
{ typography = "content", as, ...props }: TextProps<T>,
ref: Ref<any>
) {
const target = as ?? "span";
const Component = target;

return (
<Component
ref={ref}
// 대충 타이포그래피 클래스 렌더하는 로직
{...props}
/>
);
}

export default forwardRef(Text) as typeof Text;

참조

Evans Library Blog - “타입스크립트와 함께 컴포넌트 단계별로 추상화하기”

댓글 공유

sticky 잘 사용하기

카테고리 Daily

sticky 잘 알고 사용하기

position:stickyposition:static 처럼 일반적인 흐름을 따르지만, 일정 스크롤을 내렸을 때 해당 임계점에 도달하였을 때, position:fixed 처럼 구현하기 위한 속성이다.

1
2
3
4
5
6
.sticky {
position: -webkit-sticky; /* 사파리 브라우저 지원 */
position: sticky;
top: 4px;
background: red;
}
  • sticky 속성을 설정하면 top, bottom, right, left 속성 중 하나는 반드시 설정해줘야한다.

또한 sticky가 기준으로 하는 임계점은 overflow:auto, overflow:scroll 속성을 가진 가장 가까운 조상 컨테이너 박스이다.

예시

1
2
3
4
5
6
7
8
9
10
11
<div class="scroll">
<div class="parent">
<b class="static">.static</b>
<b class="sticky">.sticky</b>
<b class="fixed">.fixed</b>
</div>
<div class="parent">
<b class="static">.static</b>
<b class="sticky">.sticky</b>
</div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.scroll {
display: inline-block;
width: 200px;
height: 400px;
overflow: auto;
padding: 0 4px;
vertical-align: middle;
border: 4px solid #000;
}
.parent {
height: 300px;
margin: 4px 0 200px;
border: 4px solid #666;
}
.static {
position: static;
background: gray;
}
  • 위 코드에서 sticky가 fixed로 고정되는 임계기준점은 scroll 클래스를 가진 div 박스이다.
  • scroll 클래스가 있는 박스는 스크롤이 할 수 있을 만큼 길이가 길어서 스크롤 하여 해당 임계점에 도달하는 순간 fixed로 변하게 된다.

처음에 헷갈린 점은 overflow:auto 속성을 parent 클래스를 가진 박스로 옮겼는데 아무런 일도 일어나지 않아서 당황했다. 그 이유는 parent 클래스를 가진 박스 내부가 스크롤이 되었을 때 해당 박스를 기준으로 고정되는 것인데, parent 클래스를 가진 박스는 스크롤이 될만한 높이를 가지고 있지 않아서 아무런 일도 발생하지 않았던 것이다.

댓글 공유

이번에 프로젝트를 진행하다가 아이콘을 사용하기 위해 svg 파일을 css background:url()로 불러와서 사용한 적이 있다.

그런데 로컬환경에서는 제대로 불러와지던 svg 파일들이 build를 하고 난 후, 확인해보니 불러와지지 않는 것을 확인하였다.

그래서 찾아보니 Vite에서는 다음 asset을 가져오기 위해 2가지 방법을 사용한다고 한다.

Vite에서 assets 가져오기

1. URL을 통해 가져오기

1
2
import imgUrl from "./img.png";
document.getElementById("hero-img").src = imgUrl;
  • Webpack의 file-loader와 비슷한데, 차이점이 있다면 Vite는 절대경로와 상대 경로 둘 다 사용가능하다.
  • 일반적인 이미지, 미디어, 폰트 파일 타입은 자동으로 asset 목록에 포함된다.

2. public 디렉터리에서 가져오기

  • robots.txt 같이 소스 코드에서 참조하지 않는 asset
  • 해싱 없이 항상 같은 이름을 갖는 asset
  • URL을 얻기 위해 굳이 import 할 필요가 없는 asset

위 3가지의 경우에는 public 디렉터리에 asset을 위치시킨다. 이곳에 위치한 asset은 개발 시 / 경로에, 배포시에는 dist 디렉터리에 위치한다.

1
2
3
.arrowDown {
background-image: url("/assets/img/Arrow.svg");
}
  • public 디렉터리에 있는 asset을 가져올 경우에는 항상 루트 기준으로 하는 절대경로에서 가져와야한다. (public/Arrow.svg asset은 소스 코드에서는 /Arrow.svg으로 접근 가능하다.)
  • public 디렉터리에 있는 asset은 JavaScript로 가져올 수 없다.

가급적 import를 사용하자.

React Router를 사용하여 중첩 라우팅을 구현하였다.

/mypage/history 라우팅에서 Header 컴포넌트를 불러왔다.

Header 컴포넌트에는 public 폴더에서 불러온 logo가 있다.

하지만 이 때의 경로는 /img/logo.svg 였기 때문에 /mypage/history 라우팅에서는 해당 경로의 파일을 찾을 수 없다.

해당 경로의 파일을 찾기 위해서는 /mypage/history/img/logo.svg 로 경로를 바꿔줘야 한다.

즉, public 폴더에서 이미지를 가져올 경우, 라우팅이 바뀔 때마다 경로를 생각해줘야한다.

그러므로 import 구문을 사용하여 src 폴더 안에 이미지를 가져오도록 하자.

댓글 공유

loco9939

author.bio


author.job