React Router 동작원리

Chex·2024년 11월 17일
0
post-thumbnail

서론

History APISPA에서 페이지를 새로 고침하지 않고 URL을 변경하며, 네비게이션 기록을 관리할 수 있도록 하는 기능입니다.
React RouterHistory 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 APIpushStatereplaceState 호출을 구독하여,
URL이 변경되면 이를 라우터에 알리고, URL 경로와 일치하는 컴포넌트를 찾아 렌더링합니다.

참고

profile
Fake It till you make It!

0개의 댓글