리펙토링부터 검색어 추천 기능 및 무한 스크롤 기능 구현 하기

2023. 5. 18. 17:07프론트엔드/ReactJS

기업 과제를 진행하면서 배운 내용들을 과제에 적용해보았다. 

 

깃허브 링크 : 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 를 통해 불러올 페이지가 남아 있다면 ... 블럭 표시

최종 결과물