[TIL 21] - [React] 라이브러리 없이 SPA 구현 리팩토링

찐새·2023년 7월 8일
0

TIL

목록 보기
20/20
post-thumbnail

프리온보딩 챌린지 FE 7월 과제로 라이브러리 없이 SPA 라우터를 구현했다. 잘 동작하길래 만족했더니 함정이 숨어 있었다. 과제에서 제시한 대로만 구현하면 불필요한 마운트가 생긴다.

구현하면서 현재 Root에 있다면 렌더링하지 않은 About 라우트는 나타나지 않기를 기대했다. 숨겨진 진실(?)을 확인하니 많이 당황스러웠다.

피드백 내용과 제공해준 블로그 내용을 바탕으로 코드를 수정했다.

1. Route 내에서 렌더링 로직 실행

현재 url의 pathname과 요청한 path가 같으면 컴포넌트가 렌더링된다. 이 로직은 Route에서 실행되었다.

const Route = ({ path, component }: RouteProps) => {
  const pathname = useRouterState();
  const setPathname = useRouterDispatch();
  const [isCurrentPath, setIsCurrentPath] = useState(false);

  useEffect(() => {
    setIsCurrentPath(path === pathname ? true : false);

    const handlePopstate = () => {
      setPathname(window.location.pathname);
      return;
    };

    window.addEventListener("popstate", handlePopstate);

    return () => window.removeEventListener("popstate", handlePopstate);
  }, [pathname]);

  return isCurrentPath ? component : null;
};

얼핏 보면 현재 경로와 같을 때 component를 반환하고, 아닐 경우 null을 반환하니 잘 동작할 것 같다. 그러나 이는 Route의 하위 컴포넌트를 결정하는 것이고, 어쨌든 null도 객체이니 Route는 존재하는 컴포넌트로 마운트된다.

게다가 useEffect까지 사용하여 렌더링되라고 주문을 외웠으니 남지 않기를 바라는 게 욕심이었다.

내가 바라는 결과는 현재 경로의 Route를 제외한 나머지는 마운트하지 않는 것이다. 일단 로직부터 제거했다.

interface RouteProps {
  path: string;
  component: ReactElement;
}

const Route = (props: RouteProps) => {
  return <>{props.component}</>;
};

이제 Route는 렌더링 페이지 컴포넌트를 담는 깡통이 되었다.

2. 제거한 로직 위치 변경

경로 처리 로직은 필요하니 어딘가에 넣어야 하는데 처음에는 routeContextRoutes에서 처리하려고 했다. 실제로 코드를 작성해 실행해 봐도 아무런 탈 없이 잘 굴러갔다. 그러나 pathname의 상태관리를 위해 작성한 context에서 모든 로직을 처리해도 되나? 하는 의문점이 생겼다. 최근 관심사 분리에 대해 들었기 때문이었다. 선행한 분의 블로그에도 그런 이유로 처리 로직을 분리했다고 한다.

여기서 RouterRoute로 구현하라고 했었구나, 하는 작은 깨달음을 얻었다. 나는 그게 그거려니 생각해 대충 RoutesRoute로 나눴었다. 그러니 다시 중간 매개체를 고려해야 하는 문제가 발생했다.

routeContextRouter로 수정하고, 로직을 담당하는 Routes를 새로 생성했다.

처리 방안을 모색했던 컴포넌트와 path를 검증하는 로직을 Routes에서 처리했다.

const Routes = ({ children }: RoutesProps) => {
  const pathname = useRouterState();
  const setPathname = useRouterDispatch();

  /* 추가 */
  const isCurrentPathComponent = (
    component: ReactElement<{ path: string }>
  ) => {
    return pathname === component.props.path;
  };
  /* **** */
  useEffect(() => {
    const handlePopstate = () => {
      setPathname(window.location.pathname);
      return;
    };

    window.addEventListener("popstate", handlePopstate);

    return () => window.removeEventListener("popstate", handlePopstate);
  }, [pathname]);

  return <>{children.find(isCurrentPathComponent) ?? <NotFound />}</>;
};

children에서 url pathname과 컴포넌트의 path가 일치하는지 검증하는 함수 isCurrentPathComponent를 추가했다. 반환하는 컴포넌트는 children을 순회하면서 true인 값인 하위 Route만 렌더링한다. 추가로 잘못된 페이지 접근도 처리했다.

3. Route가 하나인 경우 처리

그러나 한 가지 미스가 또 있었다. 항상 Route가 여러 개 들어올 거라는 확신이었다. children의 타입을 ReactElemental[]로 선언했는데, 모종의 이유로 Route를 하나만 입력하면 에러가 발생한다. children이 배열인지 확인하고, 아닐 경우 배열로 바꿔주는 코드를 추가했다.

const childrenArray = Array.isArray(children) ? children : [children];

이제 불필요한 Route를 마운트하지 않으면서 원하는 대로 동작한다.

4. isValidElement ?

React에서 제공해주는 함수 중 isValidElement는 값이 React 엘리먼트인지 확인하여 boolean을 반환한다.

isValidElement
isValidElement checks whether a value is a React element.
isValidElement는 값이 React 엘리먼트인지 확인합니다.

선행하신 분의 글에 나와 있어서 이걸 왜 사용했나 고민했다. 없어도 잘 굴러가던데……. React 엘리먼트가 아닌 것이 들어오는 경우도 있나?

나의 경우 타입 선언 자체를 ReactElement로 선언하여 아무런 문제가 없는데, 만약 ReactNode로 선언한 경우 검증이 필요할 수 있다.

const isCurrentPathComponent = (component: ReactNode) => {
  if (!isValidElement(component)) {
    return false;
  }
  return pathname === component.props.path;
};

ReactNode는 다양한 속성으로 이루어져 있다.

type ReactNode =
  | ReactElement
  | string
  | number
  | Iterable<ReactNode>
  | ReactPortal
  | boolean
  | null
  | undefined;

만약 isValidElement를 통과하지 못하면 저 중 어떠한 속성인지 TypeScript는 알지 못한다. 통과하면 저 속성 중 ReactElement이므로 props에 접근할 수 있다.

지금처럼 간단하고 확실한 경우에는 직접 타입과 제네릭을 선언해도 되지만, 더 복잡한 경우에는 아주 유용한 함수일 것 같다.


참고
React Router를 직접 만들어보는 과정

profile
프론트엔드 개발자가 되고 싶다

0개의 댓글