react-router를 살짝 맛보자 - Router 편

바질·2023년 9월 14일
0
post-custom-banner

시작하면서...

원티드 프리온보딩으로 react-router를 가볍게 구현하고 난 후, 실제 react router는 어떻게 구현되어 있을지, 깃헙에 들어가서 살펴보자.

react-router github

react-router

Router

간단하게 Router를 구성하는 코드만 가져와보았다. 타입이나 별도의 코드는 빼고 핵심 코드로 보이는 것만 첨부하겠다.

Router code

export function Router({
  basename: basenameProp = "/",
  children = null,
  location: locationProp,
  navigationType = NavigationType.Pop,
  navigator,
  static: staticProp = false,
}: RouterProps): React.ReactElement | null {
  invariant(
    !useInRouterContext(),
    `You cannot render a <Router> inside another <Router>.` +
      ` You should never have more than one in your app.`
  );

  // Preserve trailing slashes on basename, so we can let the user control
  // the enforcement of trailing slashes throughout the app
  let basename = basenameProp.replace(/^\/*/, "/");
  let navigationContext = React.useMemo(
    () => ({ basename, navigator, static: staticProp }),
    [basename, navigator, staticProp]
  );

  if (typeof locationProp === "string") {
    locationProp = parsePath(locationProp);
  }

  let {
    pathname = "/",
    search = "",
    hash = "",
    state = null,
    key = "default",
  } = locationProp;

  let location = React.useMemo(() => {
    let trailingPathname = stripBasename(pathname, basename);

    if (trailingPathname == null) {
      return null;
    }

    return {
      pathname: trailingPathname,
      search,
      hash,
      state,
      key,
    };
  }, [basename, pathname, search, hash, state, key]);

  warning(
    location != null,
    `<Router basename="${basename}"> is not able to match the URL ` +
      `"${pathname}${search}${hash}" because it does not start with the ` +
      `basename, so the <Router> won't render anything.`
  );

  if (location == null) {
    return null;
  }

  return (
    <NavigationContext.Provider value={navigationContext}>
      <LocationContext.Provider
        children={children}
        value={{ location, navigationType }}
      />
    </NavigationContext.Provider>
  );
}

Router를 구성하는 코드이다.
이것을 보기 쉽게 쪼개어 살펴보겠다.

 invariant(
    !useInRouterContext(),
    `You cannot render a <Router> inside another <Router>.` +
      ` You should never have more than one in your app.`
  );

제일 먼저, invariant 라는 것이 보이는데, @remix-run/router 에서 import 하고 있다. 그리고 router 에서는 .untils에서 import를 하고 있다. 그러면 대체 invariant라는 녀석은 무엇일까?

간단하게 말해서, 에러 메세지를 띄우는 함수인 거 같다. 훅이나 컴포넌트를 잘못 사용했을 때, 브라우저 화면에 검은색 에러 창이 뜨면서 block 되는 현상을 한번쯤은 겪어봤을 것이다. 그런 에러 메세지를 띄우는 용도로 사용하는 함수라고 추측된다.

위의 에러 메세지는 Router 내부에 Router를 중복해서 사용할 수 없다는 내용인 듯 하다.

다음 코드를 봐보자.

basename 걸러내기


let basename = basenameProp.replace(/^\/*/, "/");
  let navigationContext = React.useMemo(
    () => ({ basename, navigator, static: staticProp }),
    [basename, navigator, staticProp]
  );

  if (typeof locationProp === "string") {
    locationProp = parsePath(locationProp);
  }

  let {
    pathname = "/",
    search = "",
    hash = "",
    state = null,
    key = "default",
  } = locationProp;

basename이라는 변수에 basenamePropreplace 했다. 단순하게 basename 이 될 url 앞에 / 를 붙이는 것이다. 이러한 이유는 절대 경로를 만들어 예기치 못한 오류를 피하기 위함인 것 같다.

basename = http://localhost:3000
// ex). /http://localhost:3000
let navigationContext = React.useMemo(
    () => ({ basename, navigator, static: staticProp }),
    [basename, navigator, staticProp]
  );

그 후, navigationContext 객체를 생성하게 되는데, useMemo라는 훅을 사용해 첫번째 인자로 쓴 함수를 재사용하게 된다.

  • basename: 기본 경로
  • navigator: 브라우저 히스토리 관리
  • static: 정적 렌더링 여부

그리고 위의 세 가지 중 하나라도 변경된다면, useMemo 가 새로운 값을 반환하게 된다.
(변경되지 않으면 기존 값을 참조함)

 if (typeof locationProp === "string") {
    locationProp = parsePath(locationProp);
  }

locationProp 의 값이 string인지 확인한다. 문자열 타입이 맞다면 parsePath를 호출해 인자로 넣게 되는데, parsePath가 하는 일은 locationProp# 가 붙어있는지, ? 가 붙어있는지 확인하는 일이다. #,? 둘 중 무엇도 붙어있지 않다면, 다시 locationProp을 반환한다.

let {
    pathname = "/",
    search = "",
    hash = "",
    state = null,
    key = "default",
  } = locationProp;

구조 분해 할당을 통해, 문자열을 객체로 바꿔준다.

let location = React.useMemo(() => {
    let trailingPathname = stripBasename(pathname, basename);

    if (trailingPathname == null) {
      return null;
    }

    return {
      pathname: trailingPathname,
      search,
      hash,
      state,
      key,
    };
  }, [basename, pathname, search, hash, state, key]);

다음 코드를 보면 stripBasename 함수를 호출하는데, stripBasename 가 하는 일은 pathname 에서 basename을 제거하는 일이다. 다음을 보자.

function stripBasename(
  pathname: string,
  basename: string
): string | null {
  if (basename === "/") return pathname;

  if (!pathname.toLowerCase().startsWith(basename.toLowerCase())) {
    return null;
  }

  // We want to leave trailing slash behavior in the user's control, so if they
  // specify a basename with a trailing slash, we should support it
  let startIndex = basename.endsWith("/")
    ? basename.length - 1
    : basename.length;
  let nextChar = pathname.charAt(startIndex);
  if (nextChar && nextChar !== "/") {
    // pathname does not start with basename/
    return null;
  }

  return pathname.slice(startIndex) || "/";
}

basenamepathname이 같다면, / 반환한다. pathnamebasename이 포함되어 있다면, basename만 제거하고 pathname을 반환한다. 만약, 전혀 다른 basename,과 pathname이라면 null을 반환하고 종료한다.

let location = React.useMemo(() => {
    let trailingPathname = stripBasename(pathname, basename);

    if (trailingPathname == null) {
      return null;
    }

    return {
      pathname: trailingPathname,
      search,
      hash,
      state,
      key,
    };
  }, [basename, pathname, search, hash, state, key]);

다시 원래의 코드로 돌아와서 보자면, trailingPathname 에는 pathname이 들어있을 것이다. 이걸 객체의pathname 속성에 할당하고 return 시킨다. 또한, 객체 내부 속성 중 하나라도 변경된다면 새로운 값을 참조할 수 있게 useMemo를 사용하였다.

에러 메세지

warning(
    location != null,
    `<Router basename="${basename}"> is not able to match the URL ` +
      `"${pathname}${search}${hash}" because it does not start with the ` +
      `basename, so the <Router> won't render anything.`
  );

  if (location == null) {
    return null;
  }
 

warning 이라는 함수를 호출하는데, locationnull일 때만 발생하게 된다. 경로가 basename으로 시작되지 않을 때 발생하는 에러 메세지라고 이해하면 될 거 같다.


return (
    <NavigationContext.Provider value={navigationContext}>
      <LocationContext.Provider
        children={children}
        value={{ location, navigationType }}
      />
    </NavigationContext.Provider>
  );
  

그리고 context.provider에 값을 전달한다.

NavigationContext 로 전달되는 값에는 basename, navigator, staticProp 가 들어있다.

LocationContext 로 전달되는 값은 두 가지가 있는데, children의 경우, 값을 사용할 하위 컴포넌트를 전달한다.
(NavigationContext, LocationContext 사용)

value 로는 location 값과 navigationType이 들어가게 된다.

결론

Router 컴포넌트에서는
하위 컴포넌트에서 location, navigation 에 관련된 정보를 쉽게 가져올 수 있도록 처리하고 있다.

느낀 점

내가 구현했던 Router와 구현 방식이 180도 다른 것 같아 놀라웠다. 우선, useState를 사용하지 않고, 함수를 이용해 값을 return하였다는 것. url의 경우 변경되는 일이 적기 때문에 useMemo를 사용했을 거라는 점, 예외처리 등 여러 방면에서 다르다는 것을 확인할 수 있었다.

어라?

여기서 궁금한 점이 생겼다. 왜 useMemo hook은 사용하지만 useState를 사용하지 않은 걸까? 내가 놓친 부분이 있는지 다시 확인해봐야겠다.

어라?? 의 해답

useMemo를 사용하지만 useState를 사용하지 않는 것에 대한 해답을 생각해보았다. useState는 상태관리를 할 때 사용하는 hook이다. 그렇다면, Router에 상태관리가 필요한 값이 있을까?

없다.

나는 라우팅 링크를 useState로 관리했는데, 여기서 착안한 오해였다. 리액트의 Router에서는 props로 라우팅의 정보값을 받는다. 따라서 라우팅이 변경될 때마다 새로운 props를 내려받을 것이고 useState를 사용하여 상태관리를 할 필요가 없는 것이다.

끝!...

post-custom-banner

0개의 댓글