React Router 동작원리

Chex·2024년 11월 17일
post-thumbnail

서론

History API는 SPA에서 페이지를 새로 고침하지 않고 URL을 변경하며, 네비게이션 기록을 관리할 수 있도록 하는 기능입니다.
React Router는 History API를 추상화한 라이브러리로, 라우팅 기능을 제공합니다.
History API를 학습하며 React Router의 동작 원리를 이해하기 위해 코드를 분석해 보았습니다.

React router 코드 살펴보기

// react-router/packages/react-router-dom/index.tsx

/**
 * A `<Router>` for use in web browsers. Provides the cleanest URLs.
 */
export function BrowserRouter({
  basename,
  children,
  future,
  window,
}: BrowserRouterProps) {
  let historyRef = React.useRef<BrowserHistory>();
  
  if (historyRef.current == null) { // history 객체 초기화
    historyRef.current = createBrowserHistory({ window, v5Compat: true });
  }

  let history = historyRef.current;
  let [state, setStateImpl] = React.useState({
    action: history.action, // 현재 히스토리 액션(POP, PUSH, REPLACE)
    location: history.location, // 현재 URL
  });

	let { v7_startTransition } = future || {};
  let setState = React.useCallback(
    (newState: { action: NavigationType; location: Location }) => {
      v7_startTransition && startTransitionImpl
        ? startTransitionImpl(() => setStateImpl(newState))
        : setStateImpl(newState);
    },
    [setStateImpl, v7_startTransition]
  );

	// 컴포넌트가 렌더링 되기 전 실행
	// history.listen을 사용해 URL 변경 이벤트(popstate)를 구독
	// URL이 변경될 때 setState를 호출하여 컴포넌트의 상태를 업데이트
  React.useLayoutEffect(() => history.listen(setState), [history, setState]);
	
  return (
    <Router
      basename={basename}
      children={children}
      location={state.location}
      navigationType={state.action}
      navigator={history}
      future={future}
    />
  );
}
// react-router/packages/router/history.ts

/**
 * Browser history stores the location in regular URLs. This is the standard for
 * most web apps, but it requires some configuration on the server to ensure you
 * serve the same app at multiple URLs.
 *
 * @see https://github.com/remix-run/history/tree/main/docs/api-reference.md#createbrowserhistory
 */
export function createBrowserHistory(
  options: BrowserHistoryOptions = {}
): BrowserHistory {
  function createBrowserLocation(
    window: Window,
    globalHistory: Window["history"]
  ) {
    let { pathname, search, hash } = window.location;
    return createLocation(
      "",
      { pathname, search, hash },
      // state defaults to `null` because `window.history.state` does
      (globalHistory.state && globalHistory.state.usr) || null,
      (globalHistory.state && globalHistory.state.key) || "default"
    );
  }

  function createBrowserHref(window: Window, to: To) {
    return typeof to === "string" ? to : createPath(to);
  }

  return getUrlBasedHistory(
    createBrowserLocation,
    createBrowserHref,
    null,
    options
  );
}
// react-router/packages/router/history.ts

function getUrlBasedHistory(
  getLocation: (window: Window, globalHistory: Window["history"]) => Location,
  createHref: (window: Window, to: To) => string,
  validateLocation: ((location: Location, to: To) => void) | null,
  options: UrlHistoryOptions = {}
): UrlHistory {
  let { window = document.defaultView!, v5Compat = false } = options;
  let globalHistory = window.history;
  let action = Action.Pop;
  let listener: Listener | null = null; // URL 변경 시 실행

  let index = getIndex()!;

  if (index == null) {
    index = 0;
    globalHistory.replaceState({ ...globalHistory.state, idx: index }, "");
  }

  function getIndex(): number {
    let state = globalHistory.state || { idx: null };
    return state.idx;
  }

  function handlePop() { // popstate 이벤트 발생 시 실행되는 이벤트 핸들러
    action = Action.Pop;
    let nextIndex = getIndex();
    let delta = nextIndex == null ? null : nextIndex - index;
    index = nextIndex;
    if (listener) { // listener함수 호출
      listener({ action, location: history.location, delta });
    }
  }

  function push(to: To, state?: any) {
    action = Action.Push;
    let location = createLocation(history.location, to, state);
    if (validateLocation) validateLocation(location, to);

    index = getIndex() + 1; // 현재 히스토리 위치 + 1
    let historyState = getHistoryState(location, index); // 새로운 히스토리 상태 객체 생성
    let url = history.createHref(location);

    try {
      globalHistory.pushState(historyState, "", url); // 브라우저 히스토리 스택에 추가하며 URL 변경
    } catch (error) {
	    // ...
    }

    if (v5Compat && listener) { 
	    // URL 변경 후 등록된 listener 호출
      listener({ action, location: history.location, delta: 1 });
    }
  }
  
	function replace(to: To, state?: any) {
	    action = Action.Replace;
	    let location = createLocation(history.location, to, state);
	    if (validateLocation) validateLocation(location, to);
	
	    index = getIndex();
	    let historyState = getHistoryState(location, index);
	    let url = history.createHref(location);
	    globalHistory.replaceState(historyState, "", url);
	
	    if (v5Compat && listener) {
		    // URL 변경 후 등록된 listener 호출
	      listener({ action, location: history.location, delta: 0 });
	    }
	}
	
	// ...
	
	let history: History = {
		get action() {
      return action;
    },

    get location() {
      return getLocation(window, globalHistory);
    },
	    
    listen(fn: Listener) { // 브라우저의 popstate 이벤트를 구독 관리하는 함수
      if (listener) { // popstate이벤트가 중복 처리되는 문제 방지
        throw new Error("A history only accepts one active listener");
      }
      window.addEventListener(PopStateEventType, handlePop); // 리스너 등록
      listener = fn; // 사용자가 전달한 콜백함수를 저장하여 URL 변경 시 호출

      return () => { // 구독 해지
        window.removeEventListener(PopStateEventType, handlePop);
        listener = null;
      };
    },
    
    createHref(to) {
      return createHref(window, to);
    },
    
    createURL,
    
    encodeLocation(to) {
      // Encode a Location the same way window.location would
      let url = createURL(to);
      return {
        pathname: url.pathname,
        search: url.search,
        hash: url.hash,
      };
    },
    
    push,
    replace,
    go(n) {
      return globalHistory.go(n);
    },
  };

  return history;
}
/**
 * A container for a nested tree of `<Route>` elements that renders the branch
 * that best matches the current location.
 *
 * @see https://reactrouter.com/components/routes
 */
export function Routes({
  children,
  location,
}: RoutesProps): React.ReactElement | null {
  return useRoutes(createRoutesFromChildren(children), location);
}
// packages/react-router/lib/hooks.tsx

/**
 * Returns the element of the route that matched the current location, prepared
 * with the correct context to render the remainder of the route tree. Route
 * elements in the tree must render an `<Outlet>` to render their child route's
 * element.
 *
 * @see https://reactrouter.com/hooks/use-routes
 */
export function useRoutes(
  routes: RouteObject[],
  locationArg?: Partial<Location> | string
): React.ReactElement | null {
  return useRoutesImpl(routes, locationArg);
}

// Internal implementation with accept optional param for RouterProvider usage
export function useRoutesImpl(
  routes: RouteObject[],
  locationArg?: Partial<Location> | string, // 현재 URL 정보
  dataRouterState?: RemixRouter["state"],
  future?: RemixRouter["future"]
): React.ReactElement | null {

	// ...

  let { navigator } = React.useContext(NavigationContext);
  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;

  // ...

  let locationFromContext = useLocation(); // 라우터 컨텍스트에서 제공하는 URL

  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;
  }

  let pathname = location.pathname || "/";

  let remainingPathname = pathname; // 부모 라우트의 경로를 제외한 나머지 경로
  if (parentPathnameBase !== "/") {
    let parentSegments = parentPathnameBase.replace(/^\//, "").split("/");
    let segments = pathname.replace(/^\//, "").split("/");
    remainingPathname = "/" + segments.slice(parentSegments.length).join("/");
  }
	
	// routes 배열 안에서 URL 경로와 일치하는 Route들을 탐색
  let matches = matchRoutes(routes, { pathname: remainingPathname }); 

	// ...

  let renderedMatches = _renderMatches(
    matches &&
      matches.map((match) =>
        Object.assign({}, match, {
          params: Object.assign({}, parentParams, match.params),
          pathname: joinPaths([
            parentPathnameBase,
            // Re-encode pathnames that were decoded inside matchRoutes
            navigator.encodeLocation
              ? navigator.encodeLocation(match.pathname).pathname
              : match.pathname,
          ]),
          pathnameBase:
            match.pathnameBase === "/"
              ? parentPathnameBase
              : joinPaths([
                  parentPathnameBase,
                  // Re-encode pathnames that were decoded inside matchRoutes
                  navigator.encodeLocation
                    ? navigator.encodeLocation(match.pathnameBase).pathname
                    : match.pathnameBase,
                ]),
        })
      ),
    parentMatches,
    dataRouterState,
    future
  );

  if (locationArg && renderedMatches) {
    return (
      <LocationContext.Provider
        value={{
          location: {
            pathname: "/",
            search: "",
            hash: "",
            state: null,
            key: "default",
            ...location,
          },
          navigationType: NavigationType.Pop,
        }}
      >
        {renderedMatches}
      </LocationContext.Provider>
    );
  }

  return renderedMatches;
}

결론

  • 라우터는 현재 URL 정보를 컴포넌트의 state로 관리합니다.
  • 브라우저에서 popstate 이벤트가 발생하면 이를 구독하여 핸들러(여기서는 setState)를 실행함으로써 라우터에 URL 변경을 알립니다.
  • 또한, globalHistory.pushState 실행하여 URL을 변경한 뒤에도 핸들러(setState)를 실행하여 라우터에 URL 변경을 알립니다.

요약하자면,
브라우저에서 뒤로 가기나 앞으로 가기 버튼을 클릭하여 발생하는 popstate 이벤트와
History API의 pushState 및 replaceState 호출을 구독하여,
URL이 변경되면 이를 라우터에 알리고, URL 경로와 일치하는 컴포넌트를 찾아 렌더링합니다.

참고

profile
Fake It till you make It!

0개의 댓글