react-router 살짝 맛보기 - Routes 편

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

시작하면서...

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

react-router github

react-router

Routes

Routes 를 파악해보자.

export function Routes({
  children,
  location,
}: RoutesProps): React.ReactElement | null {
  let dataRouterContext = React.useContext(DataRouterContext);
  // When in a DataRouterContext _without_ children, we use the router routes
  // directly.  If we have children, then we're in a descendant tree and we
  // need to use child routes.
  let routes =
    dataRouterContext && !children
      ? (dataRouterContext.router.routes as DataRouteObject[])
      : createRoutesFromChildren(children);
  return useRoutes(routes, location);
}

먼저, DataRouterContext에서 값을 가져온다. 해당 값은 router, navigator, static: false, basename 이다.

그 후, 조건문에 의하여, 자식 컴포넌트가 존재하지 않고, dataRouterContext에 값이 존재할 때 routes에 배열 값을 할당하게 된다.

dataRouterContext.router.routes as DataRouteObject[]

의 값이 배열이라는 것을 알 수 있는 방법은 as 뒤의 코드를 보면 된다. DataRouteObject[] 이라고 타입 정의가 되어 있는데 as 를 사용했다는 것은 배열임을 간주한다는 의미다.

자식 컴포넌트는 없지만 dataRouterContext 안에 정보는 있으니 router.routes 를 사용하겠다는 것으로 해석할 수 있을 거 같다.

자식 컴포넌트가 존재한다면, createRoutesFromChildren 함수를 호출하여 children을 인자로 넘겨준다.

좀 더 자세하게 createRoutesFromChildren 함수 내부로 들어가보겠다.

createRoutesFromChildren

export function createRoutesFromChildren(
  children: React.ReactNode,
  parentPath: number[] = []
): RouteObject[] {
  let routes: RouteObject[] = [];

  React.Children.forEach(children, (element, index) => {
    if (!React.isValidElement(element)) {
      // Ignore non-elements. This allows people to more easily inline
      // conditionals in their route config.
      return;
    }

React.Children 이라는 코드가 제일 먼저 눈에 들어온다. 본 적 없는 코드여서 공식문서를 살펴보니 리액트 컴포넌트로 넘어오는 자식 트리라고 한다.

즉, 앞서 조건문에 의해 자식 요소가 존재했을 때 함수가 호출되니, forEach를 통해 자식 요소를 순차적으로 훑는다.

만약, 여기서 리액트 요소가 아니라면 함수를 종료하게 된다.
React.isValidElement (리액트 요소인지 확인)

if (element.type === React.Fragment) {
      // Transparently support React.Fragment and its children.
      routes.push.apply(
        routes,
        createRoutesFromChildren(element.props.children, parentPath)
      );
      return;
    }

그리고 element의 type이 Fragment인지 확인한다.
Fragment란 빈 태그를 말한다. <Fragment></Fragment>, <></>

빈 태그를 이용해서 묶어둔 자식 요소가 있다면, 기존의 routes 배열에 createRoutesFromChildren 함수를 호출해서 나온 결과를 합치는 코드이다.

에러 메세지

invariant(
      element.type === Route,
      `[${
        typeof element.type === "string" ? element.type : element.type.name
      }] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>`
    );

에러 처리 함수를 통해 React Routes 에 들어갈 컴포넌트는 <Route> 혹은 <React.Fragment> 만 가능하다는 에러 메세지를 던진다.

invariant(
      !element.props.index || !element.props.children,
      "An index route cannot have child routes."
    );

index route는 자식 요소를 가질 수 없다는 에러 메세지이다.

let treePath = [...parentPath, index];
    let route: RouteObject = {
      id: element.props.id || treePath.join("-"),
      caseSensitive: element.props.caseSensitive,
      element: element.props.element,
      index: element.props.index,
      path: element.props.path,
      loader: element.props.loader,
      action: element.props.action,
      errorElement: element.props.errorElement,
      hasErrorBoundary: element.props.errorElement != null,
      shouldRevalidate: element.props.shouldRevalidate,
      handle: element.props.handle,
    };

treePathparentPath를 풀어넣고, index를 마지막에 추가시킨다.

route라는 오브젝트 안에, 새롭게 element를 할당하는 걸 볼 수 있다. 여러 속성이 있는데, 지금 단계에서는 그렇구나 하고 넘어가겠다...

if (element.props.children) {
      route.children = createRoutesFromChildren(
        element.props.children,
        treePath
      );
    }

    routes.push(route);
  });

  return routes;
}

element 요소에 자식 요소가 존재하면 다시 함수를 호출하여 자식 요소와 treePath를 인자로 넣어준다. (재귀함수)

함수 내부의 코드가 전부 실행되면 마지막으로 routes를 반환하고 종료한다.

routes 배열에는 RouteObject가 들어있을 것이다. RouteObject 에는 해당 요소의 정보와 경로 등이 담겨져있다.

그러면 다시 Routes 로 돌아와서~
아직 안 끝났다.

export function Routes({
  children,
  location,
}: RoutesProps): React.ReactElement | null {
  
  ...
  
  let routes =
    dataRouterContext && !children
      ? (dataRouterContext.router.routes as DataRouteObject[])
      : createRoutesFromChildren(children);
  return useRoutes(routes, location);
}

Routes 에서 마지막으로 useRoutes 훅을 호출하고 종료되는데, useRoutes에 대해서 자세하게 들어가보자.

useRoutes

export function useRoutes(
  routes: RouteObject[],
  locationArg?: Partial<Location> | string
): React.ReactElement | null {
  invariant(
    useInRouterContext(),
    // TODO: This error is probably because they somehow have 2 versions of the
    // router loaded. We can help them understand how to avoid that.
    `useRoutes() may be used only in the context of a <Router> component.`
  );

  let dataRouterStateContext = React.useContext(DataRouterStateContext);
  let { matches: parentMatches } = React.useContext(RouteContext);
  let routeMatch = parentMatches[parentMatches.length - 1];
  let parentParams = routeMatch ? routeMatch.params : {};
  let parentPathname = routeMatch ? routeMatch.pathname : "/";
  let parentPathnameBase = routeMatch ? routeMatch.pathnameBase : "/";
  let parentRoute = routeMatch && routeMatch.route;

  if (__DEV__) {
  // ~~ 주석 내용 ~~
    let parentPath = (parentRoute && parentRoute.path) || "";
    warningOnce(
      parentPathname,
      !parentRoute || parentPath.endsWith("*"),
      `You rendered descendant <Routes> (or called \`useRoutes()\`) at ` +
        `"${parentPathname}" (under <Route path="${parentPath}">) but the ` +
        `parent route path has no trailing "*". This means if you navigate ` +
        `deeper, the parent won't match anymore and therefore the child ` +
        `routes will never render.\n\n` +
        `Please change the parent <Route path="${parentPath}"> to <Route ` +
        `path="${parentPath === "/" ? "*" : `${parentPath}/*`}">.`
    );
  }
  ...
  // <생략>
  
}

에러 메세지

invariant(
    useInRouterContext(),
    // TODO: This error is probably because they somehow have 2 versions of the
    // router loaded. We can help them understand how to avoid that.
    `useRoutes() may be used only in the context of a <Router> component.`
  );

useInRouterContext 란, 현재 컴포넌트가 Router 컴포넌트 안에(하위) 위치하는 지 검사하는 역할을 한다. 이유는 useRoutes()react hook 이기 때문에 Router 내부에서만 사용 가능하기 때문이다.

따라서 해당 오류는 두 가지의 다른 버전의 Router를 사용했을 때, 일어나도록 하는 것 같다. (주석 내용)

버전이 다르다면, 버전에 맞게 hook을 사용해주어야 한다. 또한, 버전이 다른 Router마다 각각의 context를 가지기 때문에 충돌이 일어날 가능성이 있다.

비슷한 예시지만 다른 오류인 경우도 있다. 나는 이 에러가 react hook을 함수 컴포넌트 밖에서 사용했을 때와 닮아있다고 느꼈으나 위의 에러는 Router 내부에서 useRoutes() hook을 사용할 수 있다는 의미라서 약간 다르다.

context와 변수들

  let dataRouterStateContext = React.useContext(DataRouterStateContext);
  let { matches: parentMatches } = React.useContext(RouteContext);
  let routeMatch = parentMatches[parentMatches.length - 1];
  let parentParams = routeMatch ? routeMatch.params : {};
  let parentPathname = routeMatch ? routeMatch.pathname : "/";
  let parentPathnameBase = routeMatch ? routeMatch.pathnameBase : "/";
  let parentRoute = routeMatch && routeMatch.route;
  • dataRouterStateContext: context에서 Router의 정보를 가져온다.
  • parentMatches: 경로와 일치하는 라우트의 배열
  • routeMatch: 가장 최근 라우트 매칭 결과(즉, 제일 마지막에 추가된 데이터)
  • parentParams: 현재 매칭된 라우트의 param를 참조한다.
  • parentPathname: 현재 매칭된 라우트의 pathname을 참조한다.
  • parentPathnameBase: 현재 매칭된 라우트의 pathnameBase를 참조한다.
  • parentRoute: 현재 매칭된 라우트가 존재한다면, 해당 라우트를 참조한다.

개발 모드 경고 메세지

if (__DEV__) {
    // You won't get a warning about 2 different <Routes> under a <Route>
    // without a trailing *, but this is a best-effort warning anyway since we
    // cannot even give the warning unless they land at the parent route.
    //
    // Example:
    //
    // <Routes>
    //   {/* This route path MUST end with /* because otherwise
    //       it will never match /blog/post/123 */}
    //   <Route path="blog" element={<Blog />} />
    //   <Route path="blog/feed" element={<BlogFeed />} />
    // </Routes>
    //
    // function Blog() {
    //   return (
    //     <Routes>
    //       <Route path="post/:id" element={<Post />} />
    //     </Routes>
    //   );
    // }
    let parentPath = (parentRoute && parentRoute.path) || "";
    warningOnce(
      parentPathname,
      !parentRoute || parentPath.endsWith("*"),
      `You rendered descendant <Routes> (or called \`useRoutes()\`) at ` +
        `"${parentPathname}" (under <Route path="${parentPath}">) but the ` +
        `parent route path has no trailing "*". This means if you navigate ` +
        `deeper, the parent won't match anymore and therefore the child ` +
        `routes will never render.\n\n` +
        `Please change the parent <Route path="${parentPath}"> to <Route ` +
        `path="${parentPath === "/" ? "*" : `${parentPath}/*`}">.`
    );
  }

간단히 말해서, path 뒤에 * 을 붙이지 않으면, 발생하는 오류이다.

예를 들어, /blog라는 컴포넌트 안에서 /blog/post 라는 경로를 만들고자 할 때, /blog/* 라고 명시하지 않으면 하위 경로는 매칭되지 않는다는 것이다.

location

let locationFromContext = useLocation();

  let location;
  if (locationArg) {
    let parsedLocationArg =
      typeof locationArg === "string" ? parsePath(locationArg) : locationArg;

    invariant(
      parentPathnameBase === "/" ||
        parsedLocationArg.pathname?.startsWith(parentPathnameBase),
      `When overriding the location using \`<Routes location>\` or \`useRoutes(routes, location)\`, ` +
        `the location pathname must begin with the portion of the URL pathname that was ` +
        `matched by all parent routes. The current pathname base is "${parentPathnameBase}" ` +
        `but pathname "${parsedLocationArg.pathname}" was given in the \`location\` prop.`
    );

    location = parsedLocationArg;
  } else {
    location = locationFromContext;
  }

제일 상단의 useLocation() 을 볼 수 있다. 이는 react hook 인데, 객체의 값을 반환한다.
pathname, search,hash 등의 속성이 있다.

그 다음 줄로 if 문을 통해 locationArg의 타입이 문자열이라면, parsePath 함수를 호출하는데, 앞서 말했듯이 location의 값은 객체타입으로 들어와야 한다.

따라서 문자열일 경우, url 주소 그대로인 것으로 판단하여 parsePath 를 통해 객체 타입으로 변환하여 반환한다.
반환하는 값에는 pathname, search,hash 등의 속성이 있다.

if문을 통해 locationArg가 있다면, parsedLocationArg의 값이 location 에 할당된다. (객체로 변환한 값) 그 외의 상황에서는 context로 가져온 객체 값이 할당된다.

pathname


let pathname = location.pathname || "/";
  let remainingPathname =
    parentPathnameBase === "/"
      ? pathname
      : pathname.slice(parentPathnameBase.length) || "/";

  let matches = matchRoutes(routes, { pathname: remainingPathname });

parentPathnameBase는 현재 매칭된 라우트의 base 이다. 즉, parentPathnameBase 가 루트 경로라면, pathname을 반환하고, 그게 아니라면 base의 문자열 개수만큼 pathname에서 삭제한다.

pathname 에서 base url만 제외시키는 것, 루트 경로라면 pathname 그대로 사용

matchRoutes 함수는 주어진 경로(remainingPathname)에 대응하는 라우트(routes)를 찾아내고 그 결과를 matches에 저장한다.


  if (__DEV__) {
    warning(
      parentRoute || matches != null,
      `No routes matched location "${location.pathname}${location.search}${location.hash}" `
    );

    warning(
      matches == null ||
        matches[matches.length - 1].route.element !== undefined,
      `Matched leaf route at location "${location.pathname}${location.search}${location.hash}" does not have an element. ` +
        `This means it will render an <Outlet /> with a null value by default resulting in an "empty" page.`
    );
  }

그 다음 개발 모드(__DEV__)에서만 동작하는 두 개의 에러 메세지가 있다.

  • 첫 번째 warning: 현재 location에 매칭한 라우트가 존재하지 않을 시 띄우는 경고
  • 두 번째 warning: 매칭된 라우트 중 마지막 요소의 element 속성에 값이 없을 시에 띄우는 경고
    (빈 페이지일 때)

여기서 잠깐 헷갈렸다. 예를들어, parentRoute || matches != null 라면, 값이 존재할 때 true가 되어 warning 함수를 호출하는데 반대가 되어야 하는 게 아닌가 싶어졌기 때문이다. 그러나 warning 함수는 첫번째 인자가 false일 때 함수를 호출하니 유의하자!


let renderedMatches = _renderMatches(
    matches &&
      matches.map((match) =>
        Object.assign({}, match, {
          params: Object.assign({}, parentParams, match.params),
          pathname: joinPaths([parentPathnameBase, match.pathname]),
          pathnameBase:
            match.pathnameBase === "/"
              ? parentPathnameBase
              : joinPaths([parentPathnameBase, match.pathnameBase]),
        })
      ),
    parentMatches,
    dataRouterStateContext || undefined
  );

_renderMatches의 첫 번째 인자로 matches && matches.map(...)이 보인다. 이는 matches에 값이 존재할 때 실행하도록 만들었다. (값이 없다면 map을 돌릴 때 에러가 난다)

_renderMatches 의 첫 번째 인자

matches에 map을 돌리며 Object.assign을 통해 새로운 객체를 생성한다.

  • Object.assign의 첫 번째 인자: 빈 객체 생성
  • Object.assign의 두 번째 인자: match의 속성을 복제
  • Object.assign의 세 번째 인자: 반환 값

즉, 원래 match 객체에 추가적으로 params, pathname, pathnameBase를 추가한 객체이다.

  • params: 빈 객체를 생성하며, parentParams의 속성을 갖는다. 거기에 현재 match의 params를 추가.
  • pathname: 부모 경로 (parentPathnameBase)에 현재 pathname을 합친다.
  • pathnameBase: 현재 매칭된 경로가 루트 경로라면, parentPathnameBase 경로를 사용하고 그게 아니라면 parentPathnameBase에 현재 pathnameBase 경로를 합친다.

_renderMatches 의 두 번째 인자
parentMatches: context로 전달받은 match

_renderMatches 의 세 번째 인자
dataRouterStateContext: dataRouterStateContext에 값이 있다면 사용하고 없다면 undefined를 사용한다.

따라서,
_renderMatches 함수는 주어진 matchs를 바탕으로 변형된 새로운 matchs를 반환한다. (map 사용으로)

// When a user passes in a `locationArg`, the associated routes need to
  // be wrapped in a new `LocationContext.Provider` in order for `useLocation`
  // to use the scoped location instead of the global location.
  if (locationArg) {
    return (
      <LocationContext.Provider
        value={{
          location: {
            pathname: "/",
            search: "",
            hash: "",
            state: null,
            key: "default",
            ...location,
          },
          navigationType: NavigationType.Pop,
        }}
      >
        {renderedMatches}
      </LocationContext.Provider>
    );
  }

  return renderedMatches;
}

locationArg가 있다면 지역 location을 사용하게 된다. 따라서 LocationContext에 새로운 location 정보를 전달한다. (덮어씌운다)

그리고 자식 요소로 앞에서 처리한 renderedMatches가 들어가게 된다.

만약, locationArg가 없다면, 단순히 renderedMatches를 반환하게 되고 전역 location을 사용한다.

이상으로 useRoutes 함수가 종료된다.

마지막으로

느낀 점

Router보다 Routes의 로직이 더 길고 복잡한 느낌을 받았다.
나는 단순하게 react-router를 구현할 때, Routes 컴포넌트로 들어오는 Route 중, 현재 location과 일치하는 Route만 렌더링하도록 했다.

즉, Routes 배열 중 find 메서드를 사용하여 일치하는 요소를 반환하고 렌더링했는데, 실제 react-router에서 사용하는 Routes 컴포넌트는 예외 처리도 많아 좀 더 복잡한 느낌이다.
그야 실제 사용하는 라이브러리니까

Routes 로직을 살펴보며, find 라는 날 것의 메서드보단 match라는 함수를 사용하여 location을 분리한 게 인상 깊었다.

post-custom-banner

0개의 댓글