리펙토링부터 검색어 추천 기능 및 무한 스크롤 기능 구현 하기
2023. 5. 18. 17:07ㆍ프론트엔드/ReactJS
기업 과제를 진행하면서 배운 내용들을 과제에 적용해보았다.
![](https://t1.daumcdn.net/keditor/emoticon/friends1/large/047.gif)
깃허브 링크 : https://github.com/wanted-frontedend-team5/pre-onboarding-10th-4-5
리팩토링
1. Js → Ts로 migration
타입스크립트 도입 이유
- 타입을 정적으로 제한하여 더 안정적이고 예측 가능한 코드 작성이 가능
- 컴파일 시간에 타입 오류를 확인함으로써 버그를 사전에 찾아 생산성 향상 및 유지보수가 용이함
의존성 설치
- react/typescript 개발에 필요한 의존성 추가
"devDependencies": {
"@types/react": "^18.2.6",
"@types/react-dom": "^18.2.4",
"typescript": "^5.0.4"
}
tsconfig.json 추가
- 기존 jsconfig.json 파일을 삭제 후, ts에 맞는 config 파일을 추가 및 설정
{
"compilerOptions": {
"outDir": "./dist/",
"strictNullChecks": true,
"module": "es6",
"jsx": "react",
"target": "es5",
"allowJs": true
},
"include": ["./src/"]
}
기존 eslint 수정하기
(생략)
Type 선언
- 목적
- type을 선언함으로써 다음에 코드를 읽어야 하는 개발자를 위해 문서화하기 위해
- 컴파일러가 수행하는 알고리즘 분석을 일치시키기 위해
todo 타입 선언
export type TodoInputType = {
title: String;
};
export type TodoType = {
createdAt: Date;
id: string;
title: string;
updatedAt: Date;
};
export type TodoListType = TodoType[];
api 타입 선언
type TodoListResponseType = {
data: TodoListType;
message: string;
opcode: string;
};
type TodoItemResponseType = {
data: TodoType;
message: string;
opcode: string;
};
type TodoApiRequest = {
get: (
url: string,
request?: AxiosRequestConfig,
) => Promise<TodoListResponseType>;
delete: (
url: string,
request?: AxiosRequestConfig,
) => Promise<TodoItemResponseType>;
post: (url: string, data: TodoInputType) => Promise<TodoItemResponseType>;
};
api 구체화
- 요청 방식에 따라 구체화 진행
const apiRequest: TodoApiRequest = {
get: url => baseInstance.get(url),
delete: url => baseInstance.delete(url),
post: (url, data) => baseInstance.post(url, data),
};
2. 컴포넌트 역할(로직) 분리
Layout 패턴을 이용해서 Main page 추상화 및 분리
import React from 'react';
import Header from './Header';
export const MainLayout = ({ children }: { children: React.ReactNode }) => {
return (
<div className="container">
<div className="inner">
<Header />
{children}
</div>
</div>
);
};
컴포넌트에서 data를 가져오는 로직과 UI를 가져오는 로직 커스텀 훅을 이용해서 분리
useTodoList.ts
import { getTodoList } from 'api/todo';
import { useState, useEffect } from 'react';
import { TodoListType } from 'type/todo';
export const useTodoList = () => {
const [todoListData, setTodoListData] = useState<TodoListType>([]);
useEffect(() => {
(async () => {
const { data } = await getTodoList();
setTodoListData(data || []);
})();
}, []);
return { todoListData, setTodoListData };
};
import React from 'react';
import InputTodo from 'components/todo/InputTodo';
import TodoList from 'components/todo/TodoList';
import { useTodoList } from 'hooks/useTodoList';
import { MainLayout } from 'components/layout/MainLayout';
const Main: React.FC = () => {
const { todoListData, setTodoListData } = useTodoList();
return (
<MainLayout>
<InputTodo setTodos={setTodoListData} />
<TodoList todos={todoListData} setTodos={setTodoListData} />
</MainLayout>
);
};
export default Main;
컴포넌트 추상화 : TodoItem에 포함되어 있는 deleteTodo 로직을 분리
import React from 'react';
import { TodoListType } from 'type/todo';
import { TodoRemove } from './TodoRemove';
type TodoItemProps = {
id: string;
title: string;
setTodos: React.Dispatch<React.SetStateAction<TodoListType>>;
};
const TodoItem = ({ id, title, setTodos }: TodoItemProps) => {
return (
<li className="item">
<span>{title}</span>
<TodoRemove id={id} setTodos={setTodos} />
</li>
);
};
export default TodoItem;
프로젝트 구조 변경
📦src
┣ 📂api
┃ ┣ 📜index.ts
┃ ┗ 📜todo.ts
┣ 📂components
┃ ┣ 📂layout
┃ ┃ ┣ 📜Header.tsx
┃ ┃ ┗ 📜MainLayout.tsx
┃ ┗ 📂todo
┃ ┃ ┣ 📜InputTodo.tsx
┃ ┃ ┣ 📜RemoveTodo.tsx
┃ ┃ ┣ 📜TodoItem.tsx
┃ ┃ ┗ 📜TodoList.tsx
┣ 📂hooks
┃ ┣ 📜useFocus.tsx
┃ ┣ 📜useFocusInput.tsx
┃ ┗ 📜useTodoList.tsx
┣ 📂pages
┃ ┗ 📜Main.tsx
┣ 📂style
┃ ┗ 📜Header.style.ts
┣ 📂type
┃ ┗ 📜todo.ts
┣ 📜App.css
┣ 📜App.tsx
┗ 📜index.tsx
기능 구현
1. InputTodo 컴포넌트 추천검색어 기능
search api 호출을 통한 dropdown 창 생성
- search api type 정의
export interface SearchParamsType {
q: string;
page?: number;
limit?: number;
}
export interface SearchPayLoadType {
q: string;
page: number;
limit: number;
result: string[];
qty: number;
total: number;
}
export interface SearchResponseType extends BaseResponseType {
data: SearchPayLoadType;
}
export interface SearchApiRequest {
get: (
params: SearchParamsType,
request?: AxiosRequestConfig,
) => Promise<SearchResponseType>;
}
Input창에 debounce 적용
useDebounce.ts 커스텀 훅 정의
- 불필요한 api 호출 방지 및 사용자 성능 개선 효과
- debounce time = 500ms
import { useEffect, useState } from 'react';
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
// fetch API...
const { isEndPage, fetchLoading, recommandList, fetchNextRecommandList } =
useTodoFetch(debounceValue);
2. RecommandList 내 검색어 강조, 추가 및 무한스크롤 기능
RecommandList highlight 처리
- string을 html 노드로 파싱해주는 *dangerouslySetInnerHTML를 사용해서 처리
const HIGHLIGHT_TEXT = (str: string) =>
`<span style="color: #2BC9BA">${str}</span>`;
/.../
{recommandList.map((title, index) => {
let titleContent = title;
if (titleContent.includes(inputValue)) {
titleContent = titleContent.replaceAll(
inputValue,
HIGHLIGHT_TEXT(inputValue),
);
}
return (
/.../
<li dangerouslySetInnerHTML={{ __html: titleContent }} /
);
})}
Dropdown 에서 추천 검색어 선택 시, 해당 검색어가 Todo 리스트에 추가
InputTodo.tsx
const addTodosSubmitFunc = useCallback(
async (value: string) => {
// 생략
finally {
setInputText('');
setIsLoading(false);
}
},
[setInputText, setTodos],
);
const handleSubmit = useCallback(
async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
addTodosSubmitFunc(debounceValue);
},
[debounceValue, addTodosSubmitFunc],
);
...
<RecommandList
// 생략
addTodosSubmitFunc={addTodosSubmitFunc}
/>
- addTodosSubmitFunc 를 handleSubmit 분리 후 RecommandList 하위 아이템에 props drilling 처리로 구현
- addTodosSubmitFunc 에서 try/catch 구문에서 finaly 부분에 상태 초기화 진행
RecommandList 무한 스크롤 적용
- useTodoFetch.ts
export const useTodoFetch = (value: string) => {
// 생략
const fetchNextRecommandList = useCallback(async () => {
// 생략
const totalPage = Math.ceil(searchPayload.total / searchPayload.limit);
if (searchPayload.page <= totalPage) {
setFetchLoading(true);
const result = await getRecommandList({
q: value,
page: searchPayload.page + 1,
});
setFetchLoading(false);
setSearchPayload(result.data);
setRecommandList(prev => [...prev, ...result.data.result]);
} else {
setIsEndPage(true);
}
}, [value, searchPayload]);
useEffect(() => {
const fetchRecommand = async (inputText: string) => {
if (!inputText) return;
setFetchLoading(true);
const { data } = await getRecommandList({ q: inputText });
if (data.qty < data.limit) setIsEndPage(true);
setSearchPayload(data);
setRecommandList(data.result);
setFetchLoading(false);
};
setIsEndPage(false);
fetchRecommand(value);
return () => {
setRecommandList([]);
setIsEndPage(true);
};
}, [value]);
return { isEndPage, fetchLoading, recommandList, fetchNextRecommandList };
};
- fetchLoading : fetch중 로딩 여부
- isEndPage : 끝 페이지 여부
- recommandList : dropdown 추천 목록
- fetchNextRecommandList : 다음 추천 목록을 불러옴
무한 스크롤 & 로딩 처리
useIntersect.ts
import { useRef, useCallback, useEffect } from 'react';
type IntersectHandler = (
entry: IntersectionObserverEntry,
observer: IntersectionObserver,
) => void;
const defaultOption: IntersectionObserverInit = {
root: null,
rootMargin: '0px',
threshold: 0.5,
};
export const useIntersect = (
onIntersect: IntersectHandler,
options = defaultOption,
) => {
const ref = useRef<HTMLDivElement>(null);
const callback = useCallback(
(entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
entries.forEach(entry => {
if (entry.isIntersecting) onIntersect(entry, observer);
});
},
[onIntersect],
);
useEffect(() => {
if (!ref.current) return;
const observer = new IntersectionObserver(callback, options);
observer.observe(ref.current);
return () => observer.disconnect();
}, [ref, options, callback]);
return ref;
};
// height : 1px
<div className="target-item" ref={tagetRef}></div>
- IntersectionObserver API를 이용한 교차상태를 감지하기 위한 커스텀 훅
- onIntersect 교차 상태 감지시에 취할 행동을 정의한 콜백함수
- options IntersectionObserverInit : option
- 타겟 ref 를 RecommandList 맨아래 삽입하고, options에 따라 감지되는 경우 fetchNextRecommandList 를 호출하여 List를 더 늘린다.
- isEndPage 를 통해 불러올 페이지가 남아 있다면 ... 블럭 표시
'프론트엔드 > ReactJS' 카테고리의 다른 글
Redux vs context api 비교 및 Recoil과의 첫만남! (0) | 2023.07.05 |
---|---|
컴포넌트 작성 피드백 및 리액트에서 렌더링 및 UseEffect 파해치기 (0) | 2023.05.18 |
react router v 6.4 체험기 (2) | 2023.02.27 |
상속(Inheritance), 합성(composition) 과 리액트 (0) | 2023.02.25 |