react-router를 구현해보자

date
Oct 7, 2022
thumbnail
slug
react-router를 구현해보자
author
status
Published
tags
React
summary
원티드 프리온보딩 챌린지 첫번째 과제 React와 History API 사용하여 SPA Router 기능 구현하기를 해결해봅시다. 이미 글 쓰는 시점에서는 전부 개발하고 제출했지만 고민했던 점을 정리해보려고 합니다.
type
Post
updatedAt
Jun 17, 2023 01:26 PM
react-router-dom을 보면 컴포넌트 구조가 아래처럼 Routes 내부에 Route 이 들어있는 구조인데 이것을 그대로 재연하고 싶었다.
const Layout = () => {
  return (
    <>
      <Navigation />
      <Routes>
        <Route path={'/'} element={<App />} />
        <Route path={'/1'} element={<Route1 />} />
        <Route path={'/2'} element={<Route2 />} />
      </Routes>
    </>
  );
};
평소에도 HOC는 많이 개발해봤지만 내부에서 여러개의 children이 들어간 것은 개발해본 적이 없어서 냅다 콘솔에 찍어봤는데 ReactNode[] 형태로 들어오는 것을 확인했다.
type RoutesProps = {
  children: React.ReactNode | React.ReactNode[];
};

const Routes = ({ children }: RoutesProps) => {
  const { path } = useContext(RouteContext);
  const isArray = Array.isArray(children);

  const isChild = (child: React.ReactNode) => {
    const { props } = child as React.ReactElement;
    return props.path === path.pathname;
  };

  const childElement = (child: React.ReactNode) => {
    const { props } = child as React.ReactElement;
    return props.element;
  };

  const routeChildren = () => {
    if (isArray) {
      return childElement(children.find(isChild));
    } else {
      return childElement(children);
    }
  };
  const result = routeChildren();

  return <>{result}</>;
};
그래서 위 코드처럼 children 타입을 정했고 각각 분기처리로 return해주었다.
type RouteProps = {
  path: string;
  element: React.ReactNode;
};

const Route = ({ path, element }: RouteProps) => {
  return null;
};
Route는 따로 설정할 기능이 없어서 Props type만 지정해줬다.
이제부터가 본격적인 기능 개발인데 React에서 routing을 해본 사람은 알겠지만 Provider를 씌워준다.
저 Provider가 무슨 기능을 할까.. 생각해봤는데 디자인시스템 만들 때와 비슷하게 location 정보들을 담고 상태를 관리해주는 역할을 해주면 된다고 생각했다.
const noop = () => {};

type RouteContextType = {
  path: PathType;
  setPath: (location: PathType) => void;
};

export const RouteContext = createContext<RouteContextType>({
  path: {
    pathname: location.pathname,
    search: location.search,
    hash: location.hash,
    location: location,
  },
  setPath: noop,
});
그래서 간단하게 Context를 정의하고 Provider를 만들어줬다.
type RouteProviderProps = {
  children: React.ReactNode;
};

const RouteProvider = ({ children }: RouteProviderProps) => {
  const contextValue = useLocation();
  return (
    <RouteContext.Provider value={contextValue}>
      {children}
    </RouteContext.Provider>
  );
};

export default RouteProvider;
context에 값을 넣어줘야하니 hook도 만들어서 값을 넣어줬다.
export const useLocation = () => {
  const [path, setPath] = React.useState({
    pathname: window.location.pathname,
    search: window.location.search,
    hash: window.location.hash,
    location: window.location,
  });

  return { path, setPath };
};
여기까지만 하면 문제점이 브라우저의 뒤로가기 앞으로가기를 했을 때 화면이 다시 그려지지 않는다는 것이다.
여기서는 popState를 활용하면 되는데 페이지가 이동되면 실행되는 이벤트이다.
useLocation hook에 추가해줌으로서 브라우저 history이벤트가 발생했을 때 상태를 변경할 수 있도록 했다.
  React.useEffect(() => {
    window.addEventListener('popstate', () => {
      setPath((prevState) => ({
        ...prevState,
        location: window.location,
      }));
    });
    return () => {
      window.removeEventListener('popstate', () => {
        setPath((prevState) => ({
          ...prevState,
          location: window.location,
        }));
      });
    };
  }, []);
여기까지만 하면 심심하니 navigate함수도 만들어보자.
react-router-dom에서 navigate함수를 만들려면 const navigate = useNavigate()로 사용해야하는데 이것도 동일하게 커스텀 hook을 생성해봤다.
export const useNavigate = () => {
  let { path, setPath } = useContext(RouteContext);

  const navigate = useCallback(
    (inputPath: string) => {
      if (path.pathname === inputPath) return;
      history.pushState({}, '', inputPath);
      setPath({
        pathname: location.pathname,
        search: location.search,
        hash: location.hash,
        location: window.location,
      });
    },
    [path],
  );
  return navigate;
};
여기서는 Provider 내부에서 사용하는 상태이니 Context를 연결해주고 navigate 함수에 props로 경로를 받으면 해당 경로로 이동하는 pushState를 생성했다.
그리고 화면을 다시 그려줘야하니! setPath로 상태도 변경해주었다.

1. 고민했던 점

여기서 Context에 location하나만 할당하지 않고 여러 value를 넣은 이유가 있는데 location을 직접 context에 집어넣게 되면 context를 통해 상태를 변경했을 때 상태의 변경으로 처리하지 않는다는 점이었다.
생각해보면 location은 Object 즉 참조형 데이터이기 때문에 내부 value값이 바뀐다고 해서 상태의 변경으로 처리하지 않을 것 같다.이전 값, 이후 값을 비교 검증해본 결과 true로 나온다.
이 문제를 해결하려고 생각한 방식이 깊은 복사이다.
const newObj = Object.assign({}, location);이 코드로 객체를 복사한 후에 다시 재할당하는 방식으로 진행했다.
Object.assign이 깊은 복사는 아니지만 location 데이터 구조를 보면 위치 관련 데이터는 전부 일차원 데이터이기 때문에 문제가 없다고 판단했다.
export const useNavigate = () => {
  let { path, setPath } = useContext(RouteContext);

  const navigate = useCallback((inputPath: string) => {
    if (path.pathname === inputPath) return;
    history.pushState({}, '', inputPath);
    const newObj = Object.assign({}, location);
    setPath(newObj);
  }, []);
  return navigate;
위 코드로 변경함으로서 해결했다.
너무 재밌었다.