
이번 아티클에서는 앞선 분석에 이어서 브라우저의 주소를 변경하였을때 주소와 매치되는 컴포넌트가 렌더링되는 과정을 살펴보겠습니다.
지난 아티클과 마찬가지로 분석에 사용할 코드는 아래와 같습니다.
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>
)
}
BrowserRouter
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}
/>
)
}
앞서 살펴보았던 BrowserRouter
컴포넌트입니다. history
객체와 관련된 내용은 앞선 아티클에서 살펴보았으니, 이번 아티클에서는 해당 컴포넌트가 반환하는 Router
컴포넌트부터 살펴보겠습니다.
Router
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는 다음과 같습니다. 이 결과를 잘 기억하면서 useRoutes
로 이동해봅시다.
[
{
"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
함수입니다. 일반적인 reduce함수와 달리 마지막 요소가 시작점이 됩니다. /about
과 같이 자식요소가 없는 경우, renderedMatches
가 하나이기 때문에, children = match.route.element
이후 RenderedRoute
컴포넌트를 반환하여 우리가 Routes
에 넣은 element
를 렌더링하게됩니다.
반면 /dashboard/settings
와 같이 부모 요소가 존재하는 경우 RenderedRoute
컴포넌트를 통해 route
를 중첩해 렌더링하게됩니다. 이때 routeContext
의 outlet
에 이전 컴포넌트가 포함되기 때문에, 자식요소가 부모요소의 Outlet
컴포넌트를 통해 렌더링되게 됩니다.
만약 중첩 라우트에서
Outlet
컴포넌트의 사용이 생소하시다면 공식문서의 Nested Routes 부분을 확인해주세요
마치며
이번 아티클에서는 React-router가 주소에 맞는 컴포넌트를 렌더링 하는 방식을 살펴보았습니다. 추가적으로 다른 모드들에서 라우트가 렌더링되는 방식도 살펴보시면 라이브러리의 동작을 이해하는데 많은 도움이 될것입니다.