
이번 아티클에서는 주소창의 주소를 직접 변경하거나, history.push와 같은 메서드를 사용해 브라우저의 주소를 변경하였을때 주소와 매치되는 컴포넌트가 렌더링되는 과정을 살펴보겠습니다. 분석에 사용할 코드는 아래와 같습니다.
import { BrowserRouter, Routes, Route } from "react-router";
const App = () => {
return (
<BrowserRouter>
<Routes>
<Route index element={<Home />} />
<Route path="about" element={<About />} />
{/* multi level path */}
<Route path="dashboard" element={<Dashboard />}>
<Route index element={<Summary />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
</BrowserRouter>
);
};
BroserRouter
가장 처음 실행하게 되는 컴포넌트는 BroserRouter
입니다. 이 컴포넌트는 이름에서 알 수 있듯이 브라우저를 위한 설정을 한뒤 Router
컴포넌트를 반환하는 컴포넌트입니다.
export function BrowserRouter({
basename,
children,
window,
}: BrowserRouterProps) {
let historyRef = React.useRef<BrowserHistory>();
if (historyRef.current == null) {
// createBrowserHistory를 이용해 history 생성
historyRef.current = createBrowserHistory({ window, v5Compat: true });
}
let history = historyRef.current;
let [state, setStateImpl] = React.useState({
action: history.action,
location: history.location,
});
// React.startTransition으로 setState함수를 한번 래핑
let setState = React.useCallback(
(newState: { action: NavigationType; location: Location }) => {
React.startTransition(() => setStateImpl(newState));
},
[setStateImpl]
);
// history.listen 함수를 호출해 history 구독
React.useLayoutEffect(() => history.listen(setState), [history, setState]);
return (
<Router
basename={basename}
children={children}
location={state.location}
navigationType={state.action}
navigator={history}
/>
);
}
코드를 개략적으로 살펴보면 createBrowserHistory
함수를 이용해 history
객체를 생성한뒤 setState
를 콜백함수로 넣어 구독하고 있습니다. 따라서history
객체가 변경되면 컴포넌트가 리렌더링되어 변경된 history
가 하위 컴포넌트에 반영되므로 우리가 history에서 꺼내어 사용하는 주소에 관한 정보들(pathname, hash, search등)은 항상 최신값을 유지할수 있게 됩니다.
그렇다면 어떤 행위가 history
객체를 변경하는 행위이고 어떤 과정을 거쳐 객체 변경이 컴포넌트에 통지될까요? 이를 알아보기 위해 history
객체를 생성하는 createBrowserHistory
함수를 살펴보겠습니다.
setState
를 사용할때,useState
에서 바로 꺼내어 사용하지 않고,startTransition
으로 한번 래핑하여 사용하고 있습니다. 이유는 간단한데, 유저의 입력이벤트를 막으면서 까지 이동한 페이지의 렌더링을 하게되면 유저경험을 해칠수 있기 때문입니다. 따라서startTransition
를 사용하여 이동한 페이지의 렌더링 우선순위를 낮춰주고 있습니다. 자세한 내용은 링크를 참고해보세요.
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
);
}
여기서 핵심은 history
객체를 반환하는 getUrlBasedHistory
함수입니다. 하지만 이 함수 내부를 분석하기전에 createBrowserLocation
함수와 createBrowserHref
함수를 간단히 살펴보겠습니다.
createBrowserLocation
함수명 그대로 브라우저용 location 객체를 만드는것입니다.
function createBrowserLocation(
window: Window,
globalHistory: Window["history"]
) {
let { pathname, search, hash } = window.location;
return createLocation(
"",
{ pathname, search, hash },
(globalHistory.state && globalHistory.state.usr) || null,
(globalHistory.state && globalHistory.state.key) || "default"
);
}
export function createLocation(
current: string | Location,
to: To,
state: any = null,
key?: string
): Readonly<Location> {
let location: Readonly<Location> = {
pathname: typeof current === "string" ? current : current.pathname,
search: "",
hash: "",
...(typeof to === "string" ? parsePath(to) : to),
state,
key: (to && (to as Location).key) || key || createKey(),
};
return location;
}
pathname
, search
, hash
등을 가진 location
객체를 생성합니다. 이때 to
파라미터로 넘어온 객체는 실제 window.location
에서 꺼내온 pathname
, search
, hash
입니다.
createBrowserHref
함수명 그대로 브라우저용 href를 만드는것입니다. pathname
, search
, hash
가 합쳐진 값을 반환합니다.
function createBrowserHref(window: Window, to: To) {
return typeof to === "string" ? to : createPath(to);
}
export function createPath({
pathname = "/",
search = "",
hash = "",
}: Partial<Path>) {
if (search && search !== "?")
pathname += search.charAt(0) === "?" ? search : "?" + search;
if (hash && hash !== "#")
pathname += hash.charAt(0) === "#" ? hash : "#" + hash;
return pathname;
}
pathname
뒤에 search
와 hash
를 순서대로 붙여서 반환하게됩니다.
getUrlBasedHistory
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;
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() {
action = Action.Pop;
let nextIndex = getIndex();
let delta = nextIndex == null ? null : nextIndex - index;
index = nextIndex;
if (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;
let historyState = getHistoryState(location, index);
let url = history.createHref(location);
globalHistory.pushState(historyState, "", url);
if (v5Compat && 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) {
listener({ action, location: history.location, delta: 0 });
}
}
function createURL(to: To): URL {
let base =
window.location.origin !== "null"
? window.location.origin
: window.location.href;
let href = typeof to === "string" ? to : createPath(to);
href = href.replace(/ $/, "%20");
invariant(
base,
`No window.location.(origin|href) available to create URL for href: ${href}`
);
return new URL(href, base);
}
let history: History = {
get action() {
return action;
},
get location() {
return getLocation(window, globalHistory);
},
listen(fn: Listener) {
if (listener) {
throw new Error("A history only accepts one active listener");
}
window.addEventListener(PopStateEventType, handlePop);
listener = fn;
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;
}
이 함수는 앞서 말씀드린대로 history객체를 반환하는 함수입니다. 반환하는 객체를 살펴보면 listen
이라는 history객체를 구독할수 있도록 해주는 함수도 있고, 우리가 주소를 변경할때 사용하는 push
, replace
함수도 있습니다.
listen과 handlePop
function listen(fn: Listener) {
// history의 리스너는 하나만 등록 가능함.
if (listener) {
throw new Error("A history only accepts one active listener");
}
window.addEventListener(PopStateEventType, handlePop);
listener = fn;
return () => {
window.removeEventListener(PopStateEventType, handlePop);
listener = null;
};
}
먼저 listen
함수를 살펴봅시다. 인자로 넣은 listener
를 등록하는 역할 이외에도 popState
이벤트를 구독하는 역할도 하고있습니다. popState
이벤트는 브라우저의 뒤로가기 버튼을 누를때 트리거 되므로, 이를 통해 뒤로가기로 인한 주소 변경을 감지할수 있게 됩니다.
주소 변경
뒤로가기 버튼을 눌러 실행되는 handlePop
함수나, 주소를 변경하기위해 사용하는 push
,replace
함수를 살펴보면, 동작이 거의 유사함을 알 수 있습니다.
function handlePop() {
action = Action.Pop;
let nextIndex = getIndex();
let delta = nextIndex == null ? null : nextIndex - index;
index = nextIndex;
if (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;
let historyState = getHistoryState(location, index);
let url = history.createHref(location);
globalHistory.pushState(historyState, "", url);
if (v5Compat && 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) {
listener({ action, location: history.location, delta: 0 });
}
}
위 세함수는 공통적으로 다음 동작을 수행합니다.
- 액션 변경
- push 또는 replace는 pushState또는 replaceState 호출하여 주소 변경
- index 변경
- action, location, delta 정보를 포함하여 listener함수 실행
이러한 과정을 통해 주소가 변경되었을때, React 컴포넌트가 주소에 관련된 정보와 함께 history객체의 변경사실을 통지받을수 있음을 알수 있습니다. 따라서 이후 살펴볼 컴포넌트나, 내부적으로 사용자가 작성한 컴포넌트에서 사용하는 history객체는 항상 최신임을 보장받을수 있게 됩니다.
Router
앞서 생성한 location 및 navigator의 Provider 역할을 하는 컴포넌트 입니다.
export function Router({
basename: basenameProp = "/",
children = null,
location: locationProp,
navigationType = NavigationType.Pop,
navigator,
static: staticProp = false,
}: RouterProps): React.ReactElement | null {
// Router 안에서 또다른 Router 사용 불가.
invariant(
!useInRouterContext(),
`You cannot render a <Router> inside another <Router>.` +
` You should never have more than one in your app.`
);
let basename = basenameProp.replace(/^\/*/, "/");
let navigationContext = React.useMemo(
() => ({
basename,
navigator,
static: staticProp,
future: {},
}),
[basename, navigator, staticProp]
);
if (typeof locationProp === "string") {
locationProp = parsePath(locationProp);
}
let {
pathname = "/",
search = "",
hash = "",
state = null,
key = "default",
} = locationProp;
let locationContext = React.useMemo(() => {
let trailingPathname = stripBasename(pathname, basename);
if (trailingPathname == null) {
return null;
}
return {
location: {
pathname: trailingPathname,
search,
hash,
state,
key,
},
navigationType,
};
}, [basename, pathname, search, hash, state, key, navigationType]);
warning(
locationContext != 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.`
);
// pathname이 잘못된 경우 null
if (locationContext == null) {
return null;
}
return (
<NavigationContext.Provider value={navigationContext}>
<LocationContext.Provider children={children} value={locationContext} />
</NavigationContext.Provider>
);
}
꽤 많은 코드들이 있지만, useMemo
를 통해서 navigation
과 location
객체를 생성한뒤 이를 Provider
컴포넌트를 통해 제공하는 역할을 하는것이 전부입니다. 따라서 자식 컴포넌트에서 useNavigation
과 useLocation
을 이용해 navigation
과 location
를 사용할수 있게 됩니다.
다음으로 LocationContext Provider에서 children을 반환하였으므로, 자식 컴포넌트인 Routes로 이동해보겠습니다.
Routes
export function Routes({
children,
location,
}: RoutesProps): React.ReactElement | null {
return useRoutes(createRoutesFromChildren(children), location);
}
단순히 useRoutes
를 호출하고, 이를 반환하는 컴포넌트입니다. createRoutesFromChildren
의 실행결과와 useRoutes
의 실행과정 두가지를 살펴보겠습니다.
createRoutesFromChildren
export function createRoutesFromChildren(
children: React.ReactNode,
parentPath: number[] = []
): RouteObject[] {
let routes: RouteObject[] = [];
React.Children.forEach(children, (element, index) => {
// React Element가 아니면 무시합니다. 이 코드로 인해 isActive && <Route></Route> 와같은 코드가 가능합니다.
if (!React.isValidElement(element)) {
return;
}
// 현재 Route의 경로
let treePath = [...parentPath, index];
// <></> <React.Fragment></React.Fragment>인 경우 자식요소를 사용
if (element.type === React.Fragment) {
// Transparently support React.Fragment and its children.
routes.push.apply(
routes,
createRoutesFromChildren(element.props.children, treePath)
);
return;
}
// Route 컴포넌트가 아니면 에러 발행
invariant(
element.type === Route,
`[${
typeof element.type === "string" ? element.type : element.type.name
}] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>`
);
// index Route에 자식요소 포함하면 에러 발행
invariant(
!element.props.index || !element.props.children,
"An index route cannot have child routes."
);
let route: RouteObject = {
// 자식요소인경우 id는 3-1-0 과 같이 결정된다.
id: element.props.id || treePath.join("-"),
caseSensitive: element.props.caseSensitive,
element: element.props.element,
Component: element.props.Component,
index: element.props.index,
path: element.props.path,
loader: element.props.loader,
action: element.props.action,
hydrateFallbackElement: element.props.hydrateFallbackElement,
HydrateFallback: element.props.HydrateFallback,
errorElement: element.props.errorElement,
ErrorBoundary: element.props.ErrorBoundary,
hasErrorBoundary:
element.props.hasErrorBoundary === true ||
element.props.ErrorBoundary != null ||
element.props.errorElement != null,
shouldRevalidate: element.props.shouldRevalidate,
handle: element.props.handle,
lazy: element.props.lazy,
};
// 자식요소가 있을경우 재귀적으로 createRoutesFromChildren함수 실행
if (element.props.children) {
route.children = createRoutesFromChildren(
element.props.children,
treePath
);
}
routes.push(route);
});
return routes;
}
createRoutesFromChildren
함수 이름으로부터 추측할수 있듯이, Routes하위의 Route
컴포넌트들을 배열형태의 객체로 변경하는 함수입니다. 이때 Route컴포넌트가 부모 자식 관계로 중첩된 경우 children 프로퍼티에 createRoutesFromChildren
함수를 한번더 실행하여 결과를 담습니다.
한가지 더 알아두면 좋을 부분은 id를 구성하는 방식입니다. 기본적으로 우리가 담은 순서대로 구성되지만, 자식요소일 경우 다음레벨에서 순서가 다시 시작됩니다. 예를들어 세번째 요소의 첫번째 자식요소라면 2-0과 같은 번호가 부여되는것입니다.
결과적으로 생성되는 routes는 다음과 같습니다. 이 결과를 잘 기억하면서 useRoute
로 이동해봅시다.
[
{
"id": "0",
"path": null,
"element": "<Home/>"
},
{
"id": "1",
"path": "about",
"element": "<About/>"
},
{
"id": "2",
"path": "dashboard",
"element": "<Dashboard/>",
"children": [
{
"id": "2-0",
"element": "<Summary/>",
"path": null
},
{
"id": "2-1",
"element": "<Settings/>",
"path": "settings"
}
]
}
]
useRoutes
export function useRoutesImpl(
routes: RouteObject[],
locationArg?: Partial<Location> | string,
dataRouterState?: DataRouter["state"],
future?: DataRouter["future"]
): React.ReactElement | null {
// useRoutes는 Router 컴포넌트 내에서만 써야함
invariant(
useInRouterContext(),
`useRoutes() may be used only in the context of a <Router> component.`
);
let { navigator, static: isStatic } = 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();
let location = locationFromContext;
let pathname = location.pathname || "/";
let remainingPathname = pathname;
// matchRoute찾기
let matches =
!isStatic &&
dataRouterState &&
dataRouterState.matches &&
dataRouterState.matches.length > 0
? (dataRouterState.matches as AgnosticRouteMatch<string, RouteObject>[])
: 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
);
return renderedMatches;
}
이 함수에서 핵심적으로 확인해야할 부분은 matches를 만드는 부분과 matches를 렌더링하는것입니다. 하나씩 살펴봅시다.
matches만들기
let matches =
!isStatic &&
dataRouterState &&
dataRouterState.matches &&
dataRouterState.matches.length > 0
? (dataRouterState.matches as AgnosticRouteMatch<string, RouteObject>[])
: matchRoutes(routes, { pathname: remainingPathname });
이전에 dataSource
를 채운적은 없으므로 matchRoutes
가 실행됩니다. routes
는 앞서 만들었던 값이며, pathname
에는 현재 location
의 pathname
이 담기게됩니다.
export function matchRoutes<
RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject
>(
routes: RouteObjectType[],
locationArg: Partial<Location> | string,
basename = "/"
): AgnosticRouteMatch<string, RouteObjectType>[] | null {
return matchRoutesImpl(routes, locationArg, basename, false);
}
export function matchRoutesImpl<
RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject
>(
routes: RouteObjectType[],
locationArg: Partial<Location> | string,
basename: string,
allowPartial: boolean
): AgnosticRouteMatch<string, RouteObjectType>[] | null {
// 로케이션 파싱
let location =
typeof locationArg === "string" ? parsePath(locationArg) : locationArg;
// basename이 "/" 이므로 그대로 pathname
let pathname = stripBasename(location.pathname || "/", basename);
if (pathname == null) {
return null;
}
// routes에 자식요소가 있을경우 이를 플랫하게 펴주는 로직
let branches = flattenRoutes(routes);
// 브랜치의 랭크를 이용해 정렬함
rankRouteBranches(branches);
let matches = null;
for (let i = 0; matches == null && i < branches.length; ++i) {
let decoded = decodePath(pathname);
matches = matchRouteBranch<string, RouteObjectType>(
branches[i],
decoded,
allowPartial
);
}
return matches;
}
가장 먼저 routes를 자식 요소를 같은 레벨 요소로 만드는 flattenRoutes함수의 실행을 분석해보겠습니다.
function flattenRoutes<
RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject
>(
routes: RouteObjectType[],
branches: RouteBranch<RouteObjectType>[] = [],
parentsMeta: RouteMeta<RouteObjectType>[] = [],
parentPath = ""
): RouteBranch<RouteObjectType>[] {
let flattenRoute = (
route: RouteObjectType,
index: number,
relativePath?: string
) => {
let meta: RouteMeta<RouteObjectType> = {
relativePath:
relativePath === undefined ? route.path || "" : relativePath,
caseSensitive: route.caseSensitive === true,
childrenIndex: index,
route,
};
if (meta.relativePath.startsWith("/")) {
invariant(
meta.relativePath.startsWith(parentPath),
`Absolute route path "${meta.relativePath}" nested under path ` +
`"${parentPath}" is not valid. An absolute child route path ` +
`must start with the combined path of all its parent routes.`
);
meta.relativePath = meta.relativePath.slice(parentPath.length);
}
let path = joinPaths([parentPath, meta.relativePath]);
let routesMeta = parentsMeta.concat(meta);
// 자식요소의 경우 재귀적으로 flattenRoutes 실행
if (route.children && route.children.length > 0) {
invariant(
// Our types know better, but runtime JS may not!
// @ts-expect-error
route.index !== true,
`Index routes must not have child routes. Please remove ` +
`all child routes from route path "${path}".`
);
flattenRoutes(route.children, branches, routesMeta, path);
}
if (route.path == null && !route.index) {
return;
}
branches.push({
path,
score: computeScore(path, route.index),
routesMeta,
});
};
// routes 순회
routes.forEach((route, index) => {
if (route.path === "" || !route.path?.includes("?")) {
flattenRoute(route, index);
} else {
for (let exploded of explodeOptionalSegments(route.path)) {
flattenRoute(route, index, exploded);
}
}
});
return branches;
}
routes
요소에 대해 순회하면서 branches
에 path
, score
, routesMeta
로 구성된 branch
를 넣습니다. 이때 자식요소는 재귀적으로 돌면서 branches
에 넣어주고있음을 확인할수 있습니다. 여기서 한가지 더 보면 좋을것은 branch
의 점수를 결정하는 computeScore
입니다.
const paramRe = /^:[\w-]+$/;
const dynamicSegmentValue = 3;
const indexRouteValue = 2;
const emptySegmentValue = 1;
const staticSegmentValue = 10;
const splatPenalty = -2;
const isSplat = (s: string) => s === "*";
function computeScore(path: string, index: boolean | undefined): number {
let segments = path.split("/");
// 1. 길이에 따른 점수
let initialScore = segments.length;
// 2. "*"유무에 따른 점수
if (segments.some(isSplat)) {
initialScore += splatPenalty;
}
// 3. index 라우트 유무에 따른 점수
if (index) {
initialScore += indexRouteValue;
}
// 4. 세그먼트를 순회하면서 점수 적용
return segments
.filter((s) => !isSplat(s))
.reduce(
(score, segment) =>
score +
(paramRe.test(segment)
? dynamicSegmentValue
: segment === ""
? emptySegmentValue
: staticSegmentValue),
initialScore
);
}
-
initialScore
를 살펴보면,/
로 분리되는 세그먼트 사이즈를 더합니다. 즉 길이가 길수록 점수가 높습니다. -
path에
*
를 포함하면 점수를 -2점 빼줍니다. 참고로*
는 모든 문자에 매치되는것을 의미합니다. -
index 라우트 인경우 2점을 더해줍니다.
-
세그먼트를 순회하면서 점수를 적용하는데, 이미 적용한
*
는 점수에서 제외합니다. 그리고 동적 경로(:path
)는 3점을 더하고, 빈 경로(""
)는 1점을 더하고, 기본 경로는 10점을 더하게 됩니다.
이제까지 살펴본 과정을 일반화 해보면, 점수가 낮을수록 일반적이고, 점수가 높을수록 구체적인 path입니다. 예를들어 /dashboard/home
의 경우 점수를 더해보면 22점이고, /dashboard/:path
의 경우 점수를 더해보면 15점입니다. 전자의 경우 보다 후자의 경우 동적 경로 때문에 매치할수있는 라우트가 훨씬 많습니다.
따라서 점수가 높은 라우트와 먼저 비교해보고, 이와 일치한다면 하위 점수의 라우트와는 비교하지 않고 그대로 해당 컴포넌트를 반환하게됩니다. 아래 함수는 이를위해 점수가 높은순으로 브랜치를 재배치하는 함수입니다.
function rankRouteBranches(branches: RouteBranch[]): void {
branches.sort((a, b) =>
a.score !== b.score
? b.score - a.score // Higher score first
: compareIndexes(
a.routesMeta.map((meta) => meta.childrenIndex),
b.routesMeta.map((meta) => meta.childrenIndex)
)
);
}
다음으로는 matchRoutesImpl함수의 마지막 부분을 살펴보겠습니다.
let matches = null;
for (let i = 0; matches == null && i < branches.length; ++i) {
// Incoming pathnames are generally encoded from either window.location
// or from router.navigate, but we want to match against the unencoded
// paths in the route definitions. Memory router locations won't be
// encoded here but there also shouldn't be anything to decode so this
// should be a safe operation. This avoids needing matchRoutes to be
// history-aware.
let decoded = decodePath(pathname);
matches = matchRouteBranch<string, RouteObjectType>(
branches[i],
decoded,
allowPartial
);
}
function matchRouteBranch<
ParamKey extends string = string,
RouteObjectType extends AgnosticRouteObject = AgnosticRouteObject
>(
branch: RouteBranch<RouteObjectType>,
pathname: string,
allowPartial = false
): AgnosticRouteMatch<ParamKey, RouteObjectType>[] | null {
let { routesMeta } = branch;
let matchedParams = {};
let matchedPathname = "/";
let matches: AgnosticRouteMatch<ParamKey, RouteObjectType>[] = [];
for (let i = 0; i < routesMeta.length; ++i) {
let meta = routesMeta[i];
let end = i === routesMeta.length - 1;
let remainingPathname =
matchedPathname === "/"
? pathname
: pathname.slice(matchedPathname.length) || "/";
let match = matchPath(
{ path: meta.relativePath, caseSensitive: meta.caseSensitive, end },
remainingPathname
);
let route = meta.route;
if (
!match &&
end &&
allowPartial &&
!routesMeta[routesMeta.length - 1].route.index
) {
match = matchPath(
{
path: meta.relativePath,
caseSensitive: meta.caseSensitive,
end: false,
},
remainingPathname
);
}
if (!match) {
return null;
}
Object.assign(matchedParams, match.params);
matches.push({
params: matchedParams as Params<ParamKey>,
pathname: joinPaths([matchedPathname, match.pathname]),
pathnameBase: normalizePathname(
joinPaths([matchedPathname, match.pathnameBase])
),
route,
});
if (match.pathnameBase !== "/") {
matchedPathname = joinPaths([matchedPathname, match.pathnameBase]);
}
}
return matches;
}
여기서는 모든 브랜치를 순회하면서 pathname과 비교하여 일치하는 대상을 찾게됩니다. 일치하는지 여부는 matchPath
함수를 실행하여 알게됩니다.
이때 branch 내부에 있는 routesMeta를 순회하게되는데 이를 순회하는 이유는 중첩된 라우팅 때문입니다. 예를들어 "/about"인 경우 자식요소가 없기 때문에 순회가 의미 없지만, "dashboard/setting"인 경우 부모요소와 자식요소가 있기 때문에 두 요소의 정보가 모두 필요합니다. 구체적으로 정보를 어떻게 사용하는지는 아래 코드를 살펴보면 이해할수 있습니다.
matches렌더링
export function _renderMatches(
matches: RouteMatch[] | null,
parentMatches: RouteMatch[] = [],
dataRouterState: DataRouter["state"] | null = null,
future: DataRouter["future"] | null = null
): React.ReactElement | null {
let renderedMatches = matches;
let errors = dataRouterState?.errors;
return renderedMatches.reduceRight((outlet, match, index) => {
// Only data routers handle errors/fallbacks
let error: any;
let shouldRenderHydrateFallback = false;
let errorElement: React.ReactNode | null = null;
let hydrateFallbackElement: React.ReactNode | null = null;
if (dataRouterState) {
error = errors && match.route.id ? errors[match.route.id] : undefined;
errorElement = match.route.errorElement || defaultErrorElement;
if (renderFallback) {
if (fallbackIndex < 0 && index === 0) {
warningOnce(
"route-fallback",
false,
"No `HydrateFallback` element provided to render during initial hydration"
);
shouldRenderHydrateFallback = true;
hydrateFallbackElement = null;
} else if (fallbackIndex === index) {
shouldRenderHydrateFallback = true;
hydrateFallbackElement = match.route.hydrateFallbackElement || null;
}
}
}
let matches = parentMatches.concat(renderedMatches.slice(0, index + 1));
let getChildren = () => {
let children: React.ReactNode;
if (error) {
children = errorElement;
} else if (shouldRenderHydrateFallback) {
children = hydrateFallbackElement;
} else if (match.route.Component) {
children = <match.route.Component />;
} else if (match.route.element) {
// route의 element 렌더링
children = match.route.element;
} else {
children = outlet;
}
return (
<RenderedRoute
match={match}
routeContext={{
outlet,
matches,
isDataRoute: dataRouterState != null,
}}
children={children}
/>
);
};
// Only wrap in an error boundary within data router usages when we have an
// ErrorBoundary/errorElement on this route. Otherwise let it bubble up to
// an ancestor ErrorBoundary/errorElement
return dataRouterState &&
(match.route.ErrorBoundary || match.route.errorElement || index === 0) ? (
<RenderErrorBoundary
location={dataRouterState.location}
revalidation={dataRouterState.revalidation}
component={errorElement}
error={error}
children={getChildren()}
routeContext={{ outlet: null, matches, isDataRoute: true }}
/>
) : (
getChildren()
);
}, null as React.ReactElement | null);
}
중요한 코드만 남겨두었습니다. 핵심은 renderedMatches.reduceRight
함수입니다. 왼쪽이 아닌, 오른쪽 부터 쌓아나가는 방식인데, /about과 같이 자식요소가 없는 경우, renderedMatches가 하나이기 때문에, children = match.route.element
이후 RenderedRoute
컴포넌트를 반환하여 우리가 Routes에 넣은 element를 렌더링하게됩니다.
한편 "/dashboard/settings" 와 같이 부모자식 요소가 존재하는 경우 RenderedRoute
컴포넌트에 쌓아나가는 방식으로 진행하게 됩니다. 이때 routeContext
의 outlet
에 이전 컴포넌트가 포함되기 때문에, 가장 오른쪽에 위치한 자식요소가 부모요소의 Outlet
컴포넌트를 통해 렌더링되게 됩니다.
마치며
간단하게 React-router가 주소에 맞는 컴포넌트를 렌더링 하는 방식을 살펴보았습니다. history 객체를 이용하는 부분과, score를 이용해 브랜치를 정렬해 우선순위를 조정하는 방식의 아이디어가 인상 깊었던것 같습니다.
감사합니다.