Tech/SW Development

React-router-dom 뜯어보기

slow_life 2024. 1. 10. 21:21

Next13의 app router 방식을 사용하며 폴더 구조 그 자체가 라우터 기능을 대신해주는 게 신기했다. Next의 기반이 되는 React의 React-router-dom의 동작 방식부터 어떤 방식으로 App router를 가능하게 해주는지 알아보려한다.

 

라우팅이란?

사용자의 요청에 따라 적절한 페이지를 보여주는 프로세스이다.

React-router-dom에서 router와 Link의 역할

router는 라우터의 핵심 역할을 한다. React에서 라우팅을 관리하고 URL의 변화에 따라 적절한 컴포넌트를 렌더링한다.  router의 동작 방식으로 두가지 유형이 존재하는데, 'BrowserRouter'와 'HashRouter' 이다. 각각 History API와, URL의 해시 부분을 이용하여 라우팅을 처리한다고 한다.

 

Link는 다른 경로로 이동하는 링크를 생성한다. "herf"를 사용해 라우팅을 하는 a 태그 와 다르게 "to"를 사용하며 페이지 전환 시 페이지 전체가 리로딩되지 않고, 가상DOM을 사용해 필요한 부분만 재렌더링을 한다.

 

먼저 아래 링크에서 React-router-dom의 Link태그를 찾아보았다.

 

GitHub - remix-run/react-router: Declarative routing for React

Declarative routing for React. Contribute to remix-run/react-router development by creating an account on GitHub.

github.com

<Link />는 react-routerpackages / react-router-dom / index.tsx에서 찾을 수 있었다. window.location.href, forwardRef , Context API가 눈에 띄었다.

 

Context API

context API는 왜 쓰인걸까? 

 

라우터의 context를 생성해서 이 context를 통해 라우팅과 관련된 정보를 하위 컴포넌트에 전달할 수 있게끔 하는 것 같다. 이를 통해 'Link' 컴포넌트에서 현재 라우터의 정보를 확인해서 현재 경로와 'to'에 지정한 prop을 비교하여 렌더링을 할 수 있지 않을까?

 

forwardRef

ref prop은 HTML 엘리먼트에 직접 접근하기 위해 사용한다. 루프를 돌며 동일한 컴포넌트를 반복적으로 렌더링할 때 사용하는 key prop 처럼 특수한 용도로 사용된다고 한다. forwardRef는 HTML 엘리먼트가 아닌 React 컴포넌트에서 ref prop 대신 사용하는 것이다.

 

Ref 전달하기 – React

A JavaScript library for building user interfaces

ko.legacy.reactjs.org

알아두기

두 번째 ref 인자는 React.forwardRef와 같이 호출된 컴포넌트를 정의했을 때에만 생성됩니다. 일반 함수나 클래스 컴포넌트는 ref 인자를 받지도 않고 props에서 사용할 수도 없습니다.

Ref 전달은 DOM 컴포넌트에만 한정적이지 않습니다. 클래스 컴포넌트 인스턴스에도 전달할 수 있습니다.

 

react-router-dom에서는 forwardRef를 이용해 특정 이벤트가 발생했을 때 'Link'가 동작하도록 하고 싶을 때, 부모컴포넌트에서 Link에 ref를 전달해 클릭 이벤트를 호출할 수 있는 역할을 한다고 한다.

onClick()

    function handleClick(
      event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
    ) {
      if (onClick) onClick(event);
      if (!event.defaultPrevented) {
        internalOnClick(event);
      }
    }
    
    return (
      // eslint-disable-next-line jsx-a11y/anchor-has-content
      <a
        {...rest}
        href={absoluteHref || href}
        onClick={isExternal || reloadDocument ? onClick : handleClick}
        ref={ref}
        target={target}
      />
    );
    
    .....

실제로 라우팅 동작을 하게 해주는건 onClick 이벤트일 것이다. Link 함수의 좀 더 아래 부분에 handleClick()이 있었다. reloadDocument의 값에 따라 onClick 또는 handleClick을 실행시킨다.

 

reloadDocument는 optional한 값이며 boolean 값이다. 공식 문서를 통해 추가적인 정보를 알 수 있었는데, 

You can use <Link reloadDocument>  to skip client side routing and let the browser handle the transition normally (as if it were an <a href>)

 

a태그와 동일하게 동작(페이지 전체가 랜더링되는) 할 수 있도록 처리하는 역할을 한다. 예외 상황에 대한 처리인 것 같다.

 

Link v6.21.1 | React Router

This is the web version of . For the React Native version, go here. Type declarationdeclare function Link(props: LinkProps): React.ReactElement; interface LinkProps extends Omit< React.AnchorHTMLAttributes , "href" > { to: To; preventScrollReset?: boolean;

reactrouter.com

handleClick() 의 역할

onClick과 !event.defaultPrevented값에 따라 각각 onClick(event)와 internalOnClick(event) 라는 것을 실행시킨다.

 

onClick은 forwardRef의 함수에서 인자로 받아오고 있지만 LinkProps 인터페이스에 onClick이 없다. LinkProps에 HTMLAnchorElement, 'href'를 보니 a태그에 저장된 링크를 의미하는 것 같다.

 

이후 event.defaultPrevented()를 통해 event.preventDefault()의 기본 동작을 판단해준다. 즉, event.preventDefault()가 false일 때 internalOnClick(event)를 실행시킨다. 

 

정리해보자면.. 항상 전체 새로고침이 발생하는 a태그에서, 새로고침이 발생하지 않을 때, 즉, event.preventDefatult()가 동작하지 않을 때, internalOnclick()함수를 통해 또 다른 동작을 실행시키는 것 같다.

internalOnClick(event) 의 역할

internalOnClick(event)는 useLinkClickHandler()의 반환값을 받고 있었다. useLinkClickHandler()는 다음과 같다.

export function useLinkClickHandler<E extends Element = HTMLAnchorElement>(
  to: To,
  {
    target,
    replace: replaceProp,
    state,
    preventScrollReset,
    relative,
    unstable_viewTransition,
  }: {
    target?: React.HTMLAttributeAnchorTarget;
    replace?: boolean;
    state?: any;
    preventScrollReset?: boolean;
    relative?: RelativeRoutingType;
    unstable_viewTransition?: boolean;
  } = {}
): (event: React.MouseEvent<E, MouseEvent>) => void {
  let navigate = useNavigate();
  let location = useLocation();
  let path = useResolvedPath(to, { relative });

  return React.useCallback(
    (event: React.MouseEvent<E, MouseEvent>) => {
      if (shouldProcessLinkClick(event, target)) {
        event.preventDefault();

        // If the URL hasn't changed, a regular <a> will do a replace instead of
        // a push, so do the same here unless the replace prop is explicitly set
        let replace =
          replaceProp !== undefined
            ? replaceProp
            : createPath(location) === createPath(path);

        navigate(to, {
          replace,
          state,
          preventScrollReset,
          relative,
          unstable_viewTransition,
        });
      }
    },
    [
      location,
      navigate,
      path,
      replaceProp,
      state,
      target,
      to,
      preventScrollReset,
      relative,
      unstable_viewTransition,
    ]
  );
}

이 코드를 통해 이 함수가 이벤트를 막고 특정 라우팅을 해주는 역할이라는 것을 유추해볼 수 있었다. 그 중에 navigate의 인자(replace, preventScrollReset, unstable_viewTransition)를 보고 실제 라우팅 과정을 동작하게 해준다는 것을 유추할 수 있었다.

 

  let navigator = React.useMemo((): Navigator => {
    return {
      createHref: router.createHref,
      encodeLocation: router.encodeLocation,
      go: (n) => router.navigate(n),
      push: (to, state, opts) =>
        router.navigate(to, {
          state,
          preventScrollReset: opts?.preventScrollReset,
        }),
      replace: (to, state, opts) =>
        router.navigate(to, {
          replace: true,
          state,
          preventScrollReset: opts?.preventScrollReset,
        }),
    };
  }, [router]);

이 부분이 실질직으로 router.navigate를 사용하여 새로운 경로로 이동하고 이동한 페이지를 기록하는 것 같다. state는 페이지의 상태이고, preventScrollReset은 스크롤 위치를 초기화 하지 않도록 도와주는 옵션이다.

 

*replace는 push와 달리 브라우저 기록을 남기지 않고 '대체'한다.

History.replaceState() 메서드는 현재 기록 항목을 수정하여 메서드 매개변수로 전달된 상태 객체 및 URL로 대체합니다. 
 

History: replaceState() method - Web APIs | MDN

The History.replaceState() method modifies the current history entry, replacing it with the state object and URL passed in the method parameters. This method is particularly useful when you want to update the state object or URL of the current history entr

developer.mozilla.org

router는 무슨 역할을 할까

  //   let router = createRouter(init).initialize();
  function initialize() {
    // If history informs us of a POP navigation, start the navigation but do not update
    // state.  We'll update our own state once the navigation completes
    unlistenHistory = init.history.listen(
      ({ action: historyAction, location, delta }) => {
        // Ignore this event if it was just us resetting the URL from a
        // blocked POP navigation
        if (ignoreNextHistoryUpdate) {
          ignoreNextHistoryUpdate = false;
          return;
        }

        warning(
          blockerFunctions.size === 0 || delta != null,
          "You are trying to use a blocker on a POP navigation to a location " +
            "that was not created by @remix-run/router. This will fail silently in " +
            "production. This can happen if you are navigating outside the router " +
            "via `window.history.pushState`/`window.location.hash` instead of using " +
            "router navigation APIs.  This can also happen if you are using " +
            "createHashRouter and the user manually changes the URL."
        );

        let blockerKey = shouldBlockNavigation({
          currentLocation: state.location,
          nextLocation: location,
          historyAction,
        });

        if (blockerKey && delta != null) {
          // Restore the URL to match the current UI, but don't update router state
          ignoreNextHistoryUpdate = true;
          init.history.go(delta * -1);

          // Put the blocker into a blocked state
          updateBlocker(blockerKey, {
            state: "blocked",
            location,
            proceed() {
              updateBlocker(blockerKey!, {
                state: "proceeding",
                proceed: undefined,
                reset: undefined,
                location,
              });
              // Re-do the same POP navigation we just blocked
              init.history.go(delta);
            },
            reset() {
              let blockers = new Map(state.blockers);
              blockers.set(blockerKey!, IDLE_BLOCKER);
              updateState({ blockers });
            },
          });
          return;
        }

        return startNavigation(historyAction, location);
      }
    );

router 는 createRouter()의 initialize()를 호출하고 startNavigation()을 호출한다는 것을 알 수 있었다.

 

startNavigation에서는 라우팅이 정상적으로 동작할 때 completeNavigation()을 호출해주는데, 

  function completeNavigation(
    location: Location,
    newState: Partial<Omit<RouterState, "action" | "location" | "navigation">>,
    { flushSync }: { flushSync?: boolean } = {}
  ): void {
  ...
  
      if (isUninterruptedRevalidation) {
      // If this was an uninterrupted revalidation then do not touch history
    } else if (pendingAction === HistoryAction.Pop) {
      // Do nothing for POP - URL has already been updated
    } else if (pendingAction === HistoryAction.Push) {
      init.history.push(location, location.state);
    } else if (pendingAction === HistoryAction.Replace) {
      init.history.replace(location, location.state);
    }
    
    ...

해당 부분에서 history.push, replace를 통해 라우팅 동작을 실행해주는 것을 알 수 있었다. 

 

 

정리해보면, react-router-dom은 path(state)를 Context를 통해 전역으로 저장하고 있다가 변경이 일어나면, pushState를 통해 이동 주소를 변경해주고 있음을 알 수 있었다.

 

*render는 화면의 변경점을 갱신해주는 행위이다. 여기서 화면은 곧 state이다. 리액트는 state가 변경될 때 re-render가 일어난다.

 

 

'Tech > SW Development' 카테고리의 다른 글

번들러에 대해 알아보기  (1) 2025.04.15
Errorboundary가 포착할 수 없는 에러  (3) 2024.11.26
디자인 패턴 도입기  (0) 2023.12.12
Next13에서 Sass 사용하기  (0) 2023.12.11
AWS amplify 도입기  (0) 2023.09.14