오늘은 공식문서와 react-router의 github repo를 참고하여 react-router-dom의 동작원리를 이해해보는 시간을 가지도록 하겠습니다. 오픈소스 파헤치기!
오류가 있다면 댓글로 알려주세요. 언제든지 환영입니다.

react-router-dom 을 사용하다가 문득 내부가 어떻게 짜여있는지 궁금해졌다. 공식문서와 github repo로 가서 코드를 직접 까보고 이해하여 정리해보았다.
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 라이브러리의 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등 액션도 만들어졌으며, 이벤트를 수신하는 것도 가능해졌다.
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을 통해 Navigation과 Location의 Context를 제공하는 역할이라는 것을 알 수 있다.
이제 매칭 과정을 살펴보자.
우리가 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