react-router-dom 라이브러리 파헤쳐보기

Dahee Kim·2022년 5월 23일

오늘은 공식문서와 react-router의 github repo를 참고하여 react-router-dom의 동작원리를 이해해보는 시간을 가지도록 하겠습니다. 오픈소스 파헤치기!

오류가 있다면 댓글로 알려주세요. 언제든지 환영입니다.

도입

react-router-dom 을 사용하다가 문득 내부가 어떻게 짜여있는지 궁금해졌다. 공식문서와 github repo로 가서 코드를 직접 까보고 이해하여 정리해보았다.

Location

history 객체

react-router의 동작을 위해서는 브라우저 history stack의 변경사항을 구독할 수 있어야한다. 하지만 브라우저는 뒤로가기/앞으로가기 버튼을 클릭할 때에만 변경사항을 수신할 수 있는 메소드만을 제공한다. 즉 뒤로가기/앞으로가기를 제외한 다른 변경사항이 발생했을 때에는 변경사항을 브라우저만을 통해 알 수 없다.

그래서 react-router의 history 객체는 브라우저의 history stack의 변경사항을 모두 구독하기 위해 URL 수신 방법을 제공한다.

import { createBrowserHistory } from "history";

export function BrowserRouter({
  basename,
  children,
  window,
}: BrowserRouterProps) {
  let historyRef = React.useRef<BrowserHistory>();
  if (historyRef.current == null) {
    historyRef.current = createBrowserHistory({ window });
  }

URL 수신은 react-router에서 직접 구현하지는 않았고, history라는 라이브러리를 이용한다.(확인해보니 react-router와 history는 둘다 remix-run이라는 곳에서 만들어졌다.) JS가 실행되는 모든 곳에서 세션의 history를 쉽게 관리할 수 있는 기능을 제공하는 라이브러리다.

오픈소스 history

그렇다면 history 라이브러리의 createBrowserHistory는 어떤 방식으로 동작할까? 여기에서도 직접 확인할 수 있다. 코드가 상당히 긴데, 동작 방식을 정리하면 다음과 같다.

우선 window.location에서 pathname, search, hash를 받아오고 거기에 window.history.idx와 와 state와 key라는 추가 값을 붙인다.

let [index, location] = getIndexAndLocation();

function getIndexAndLocation(): [number, Location] {
    let { pathname, search, hash } = window.location;
    let state = globalHistory.state || {};
    return [
      state.idx,
      readOnly<Location>({
        pathname,
        search,
        hash,
        state: state.usr || null,
        key: state.key || "default",
      }),
    ];
  }

그 후 사용할 일련의 액션들을 정의한다. 여기서는 push만 살펴볼 것이다.
주석을 함께 읽으면 더욱더 쉽게 이해할 수 있을 것이다.

function push(to: To, state?: any) {
    let nextAction = Action.Push; // nextAction에 Action에 정의해놓은 Push 넣어주기
    let nextLocation = getNextLocation(to, state); // getNextLocation(to,state)함수는 {pathname, hash, search, state, key}를 반환
    function retry() {
      push(to, state);
    }

    if (allowTx(nextAction, nextLocation, retry)) {
      // getHistoryStateAndUrl()은 [nextLocation 정보{state, key, idx}, nextLocation의 url]을 반환
      let [historyState, url] = getHistoryStateAndUrl(nextLocation, index + 1);

      
      try {
        // history를 가져온 전역변수 globalHistory에 pushState(state, title, [,url])
        globalHistory.pushState(historyState, "", url);
      } catch (error) {
        
        window.location.assign(url);
      }
		// 만들어놓은 이벤트리스너에 액션이 발생했음을 알림
      applyTx(nextAction);
    }
  }

그리고 이것들을 하나로 묶어 return한다. 이외 액션들에 대한 설명은 생략한다. 궁금하다면 이곳을 직접 참고해보자.

let history: BrowserHistory = {
    get action() {
      return action;
    },
    get location() {
      return location;
    },
    createHref,
    push,
    replace,
    go,
    back() {
      go(-1);
    },
    forward() {
      go(1);
    },
    listen(listener) {
      return listeners.push(listener);
    },
    block(blocker) {
      let unblock = blockers.push(blocker);

      if (blockers.length === 1) {
        window.addEventListener(BeforeUnloadEventType, promptBeforeUnload);
      }

      return function () {
        unblock();

        // Remove the beforeunload listener so the document may
        // still be salvageable in the pagehide event.
        // See https://html.spec.whatwg.org/#unloading-documents
        if (!blockers.length) {
          window.removeEventListener(BeforeUnloadEventType, promptBeforeUnload);
        }
      };
    },
  };

  return history;

우리가 사용할 location 객체(window.location의 pathname, search, hash와 새로 추가한 state, key가 들어감)가 만들어졌고, PUSH, POP, REPLACE등 액션도 만들어졌으며, 이벤트를 수신하는 것도 가능해졌다.

최상위 BrowserRouter

react-router를 사용할 때 route 컴포넌트들을 <BrowserRouter>컴포넌트로 감싸야한다.
자 이제 <BrowserRouter>가 어떤 구조로 되어있는지 살펴보자.

export function BrowserRouter({
  basename,
  children,
  window,
}: BrowserRouterProps) {
  let historyRef = React.useRef<BrowserHistory>();
  if (historyRef.current == null) {
    // 위에서 살펴봤던 createBrowserHistory가 등장한다.
    historyRef.current = createBrowserHistory({ window });
  }
	// history 라이브러리에서 만든 history 객체가 저장된다.
  let history = historyRef.current;
  // history에서 새롭게 정의한 action과 location을 state로 사용한다.
  let [state, setState] = React.useState({
    action: history.action,
    location: history.location,
  });

  // 브라우저가 화면에 DOM을 그리기 전에 수행되는 훅이다.
  React.useLayoutEffect(() => history.listen(setState), [history]);

  return (
    // 모든 라우터 컴포넌트가 공유하는 인터페이스이다. 앱의 나머지 부분에 라우팅 정보를 리액트의 contextAPI를 이용하여 전달한다.
    <Router
      basename={basename}
      children={children}
      location={state.location}
      navigationType={state.action}
      navigator={history}
    />
  );
}

이제 <BrowserRouter>가 반환하는 <Router> 컴포넌트의 구조를 살펴보자.

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.`
  );
// pathname을 적절히 가공한다.
  let basename = normalizePathname(basenameProp);
  // navigationContext의 value를 만든다.
  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;
// LocationContext의 value인 location을 만든다.
  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>
  );
}

props을 통해 NavigationLocationContext를 제공하는 역할이라는 것을 알 수 있다.

Matching

이제 매칭 과정을 살펴보자.

우리가 react-router를 사용할 때, 보통 다음과 같이 작성한다.

import {
  BrowserRouter,
  Routes,
  Route,
} from "react-router-dom";

root.render(
  <BrowserRouter>
    <Routes>
      <Route path="/" element={<App />}>
        <Route index element={<Home />} />
        <Route path="teams" element={<Teams />}>
          <Route path=":teamId" element={<Team />} />
          <Route path="new" element={<NewTeamForm />} />
          <Route index element={<LeagueStandings />} />
        </Route>
      </Route>
    </Routes>
  </BrowserRouter>
);

이때 <Routes> 컴포넌트는 props.children을 통해 재귀적으로 동작하여 아래와 같은 객체를 만든다.

let routes = [
  {
    element: <App />,
    path: "/",
    children: [
      {
        index: true,
        element: <Home />,
      },
      {
        path: "teams",
        element: <Teams />,
        children: [
          {
            index: true,
            element: <LeagueStandings />,
          },
          {
            path: ":teamId",
            element: <Team />,
          },
          {
            path: ":teamId/edit",
            element: <EditTeam />,
          },
          {
            path: "new",
            element: <NewTeamForm />,
          },
        ],
      },
    ],
  },
  {
    element: <PageLayout />,
    children: [
      {
        element: <Privacy />,
        path: "/privacy",
      },
      {
        element: <Tos />,
        path: "/tos",
      },
    ],
  },
  {
    element: <Contact />,
    path: "/contact-us",
  },
];

이제 path가 들어오면, 이 객체를 이용하여 매칭작업이 이루어진다. 아래는 매칭작업과 관련된 코드이다.

export function _renderMatches(
  matches: RouteMatch[] | null,
  parentMatches: RouteMatch[] = []
): React.ReactElement | null {
  if (matches == null) return null;

  return matches.reduceRight((outlet, match, index) => {
    return (
      <RouteContext.Provider
        children={
          match.route.element !== undefined ? match.route.element : outlet
        }
        value={{
          outlet,
          matches: parentMatches.concat(matches.slice(0, index + 1)),
        }}
      />
    );
  }, null as React.ReactElement | null);
}

URL이 변경되면 Navigating 작업이 이루어진다. react-router에서 Navigating하는 방법은 <Link>, navigate가 있다. 여기서는 navigate의 동작 방식을 살펴보겠다.

navigate는 react-router에서 정의한 useNavigate()함수의 리턴값이다. 아래는 useNavigate의 코드이다. 위에서 살펴본 NavigationContext, RouteContext, LocationContext를 이용하여 URL에 따라 적절한 화면 전환이 가능하도록 해준다.

export function useNavigate(): NavigateFunction {
  invariant(
    useInRouterContext(),

  );

  // NavigationContext와 관련된 hook이다.
  let { basename, navigator } = React.useContext(NavigationContext);
  // RouteContext와 관련된 hook이다.
  let { matches } = React.useContext(RouteContext);
  // LocationContext와 관련된 hook이다.
  let { pathname: locationPathname } = useLocation();

  let routePathnamesJson = JSON.stringify(
    matches.map((match) => match.pathnameBase)
  );

  let activeRef = React.useRef(false);
  React.useEffect(() => {
    activeRef.current = true;
  });

  let navigate: NavigateFunction = React.useCallback(
    (to: To | number, options: NavigateOptions = {}) => {
      warning(
        activeRef.current,
        `You should call navigate() in a React.useEffect(), not when ` +
          `your component is first rendered.`
      );

      if (!activeRef.current) return;

      if (typeof to === "number") {
        navigator.go(to);
        return;
      }

      let path = resolveTo(
        to,
        JSON.parse(routePathnamesJson),
        locationPathname
      );

      if (basename !== "/") {
        path.pathname = joinPaths([basename, path.pathname]);
      }

      (!!options.replace ? navigator.replace : navigator.push)(
        path,
        options.state
      );
    },
    [basename, navigator, routePathnamesJson, locationPathname]
  );

  return navigate;
}

마무리

지금까지 react-router의 공식문서, github를 보면서 동작 원리를 이해해보았다.

잘 알려져있고, 현업에서도 많이 쓰이는 오픈소스를 직접 까보니 얻는 게 참 많은 것 같다. 나중에 필요하다면 이런 오픈소스들을 커스텀해서 사용할 수도 있지 않을까?

많은 사람들에 의해 수정되고 관리되어온 코드를 보니 어떻게 더 좋은 코드를 쓸 수 있을까하는 고민에 대한 해답도 어느정도 발견한 것 같다. 다음엔 오픈소스를 살펴보면서 good-first-issue를 발견하면 한 번 컨트리뷰팅에 도전해봐야지.

출처

https://reactrouter.com/docs/en/v6
https://github.com/remix-run/react-router
https://github.com/remix-run/history

profile
하루가 너무 짧아~

0개의 댓글