원티드 프리온보딩으로 react-router를 가볍게 구현하고 난 후, 실제 react router는 어떻게 구현되어 있을지, 깃헙에 들어가서 살펴보자.
Routes 를 파악해보자.
export function Routes({
children,
location,
}: RoutesProps): React.ReactElement | null {
let dataRouterContext = React.useContext(DataRouterContext);
// When in a DataRouterContext _without_ children, we use the router routes
// directly. If we have children, then we're in a descendant tree and we
// need to use child routes.
let routes =
dataRouterContext && !children
? (dataRouterContext.router.routes as DataRouteObject[])
: createRoutesFromChildren(children);
return useRoutes(routes, location);
}
먼저, DataRouterContext에서 값을 가져온다. 해당 값은 router, navigator, static: false, basename
이다.
그 후, 조건문에 의하여, 자식 컴포넌트가 존재하지 않고, dataRouterContext
에 값이 존재할 때 routes
에 배열 값을 할당하게 된다.
dataRouterContext.router.routes as DataRouteObject[]
의 값이 배열이라는 것을 알 수 있는 방법은 as
뒤의 코드를 보면 된다. DataRouteObject[]
이라고 타입 정의가 되어 있는데 as
를 사용했다는 것은 배열임을 간주한다는 의미다.
자식 컴포넌트는 없지만 dataRouterContext
안에 정보는 있으니 router.routes
를 사용하겠다는 것으로 해석할 수 있을 거 같다.
자식 컴포넌트가 존재한다면, createRoutesFromChildren
함수를 호출하여 children
을 인자로 넘겨준다.
좀 더 자세하게 createRoutesFromChildren
함수 내부로 들어가보겠다.
export function createRoutesFromChildren(
children: React.ReactNode,
parentPath: number[] = []
): RouteObject[] {
let routes: RouteObject[] = [];
React.Children.forEach(children, (element, index) => {
if (!React.isValidElement(element)) {
// Ignore non-elements. This allows people to more easily inline
// conditionals in their route config.
return;
}
React.Children
이라는 코드가 제일 먼저 눈에 들어온다. 본 적 없는 코드여서 공식문서를 살펴보니 리액트 컴포넌트로 넘어오는 자식 트리라고 한다.
즉, 앞서 조건문에 의해 자식 요소가 존재했을 때 함수가 호출되니, forEach를 통해 자식 요소를 순차적으로 훑는다.
만약, 여기서 리액트 요소가 아니라면 함수를 종료하게 된다.
React.isValidElement
(리액트 요소인지 확인)
if (element.type === React.Fragment) {
// Transparently support React.Fragment and its children.
routes.push.apply(
routes,
createRoutesFromChildren(element.props.children, parentPath)
);
return;
}
그리고 element의 type이 Fragment인지 확인한다.
Fragment란 빈 태그를 말한다. <Fragment></Fragment>
, <></>
빈 태그를 이용해서 묶어둔 자식 요소가 있다면, 기존의 routes
배열에 createRoutesFromChildren
함수를 호출해서 나온 결과를 합치는 코드이다.
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>`
);
에러 처리 함수를 통해 React Routes
에 들어갈 컴포넌트는 <Route>
혹은 <React.Fragment>
만 가능하다는 에러 메세지를 던진다.
invariant(
!element.props.index || !element.props.children,
"An index route cannot have child routes."
);
index route는 자식 요소를 가질 수 없다는 에러 메세지이다.
let treePath = [...parentPath, index];
let route: RouteObject = {
id: element.props.id || treePath.join("-"),
caseSensitive: element.props.caseSensitive,
element: element.props.element,
index: element.props.index,
path: element.props.path,
loader: element.props.loader,
action: element.props.action,
errorElement: element.props.errorElement,
hasErrorBoundary: element.props.errorElement != null,
shouldRevalidate: element.props.shouldRevalidate,
handle: element.props.handle,
};
treePath
에 parentPath
를 풀어넣고, index
를 마지막에 추가시킨다.
route
라는 오브젝트 안에, 새롭게 element
를 할당하는 걸 볼 수 있다. 여러 속성이 있는데, 지금 단계에서는 그렇구나 하고 넘어가겠다...
if (element.props.children) {
route.children = createRoutesFromChildren(
element.props.children,
treePath
);
}
routes.push(route);
});
return routes;
}
element
요소에 자식 요소가 존재하면 다시 함수를 호출하여 자식 요소와 treePath
를 인자로 넣어준다. (재귀함수)
함수 내부의 코드가 전부 실행되면 마지막으로 routes
를 반환하고 종료한다.
routes
배열에는 RouteObject
가 들어있을 것이다. RouteObject
에는 해당 요소의 정보와 경로 등이 담겨져있다.
그러면 다시 Routes
로 돌아와서~
아직 안 끝났다.
export function Routes({
children,
location,
}: RoutesProps): React.ReactElement | null {
...
let routes =
dataRouterContext && !children
? (dataRouterContext.router.routes as DataRouteObject[])
: createRoutesFromChildren(children);
return useRoutes(routes, location);
}
Routes
에서 마지막으로 useRoutes
훅을 호출하고 종료되는데, useRoutes
에 대해서 자세하게 들어가보자.
export function useRoutes(
routes: RouteObject[],
locationArg?: Partial<Location> | string
): React.ReactElement | null {
invariant(
useInRouterContext(),
// TODO: This error is probably because they somehow have 2 versions of the
// router loaded. We can help them understand how to avoid that.
`useRoutes() may be used only in the context of a <Router> component.`
);
let dataRouterStateContext = React.useContext(DataRouterStateContext);
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;
if (__DEV__) {
// ~~ 주석 내용 ~~
let parentPath = (parentRoute && parentRoute.path) || "";
warningOnce(
parentPathname,
!parentRoute || parentPath.endsWith("*"),
`You rendered descendant <Routes> (or called \`useRoutes()\`) at ` +
`"${parentPathname}" (under <Route path="${parentPath}">) but the ` +
`parent route path has no trailing "*". This means if you navigate ` +
`deeper, the parent won't match anymore and therefore the child ` +
`routes will never render.\n\n` +
`Please change the parent <Route path="${parentPath}"> to <Route ` +
`path="${parentPath === "/" ? "*" : `${parentPath}/*`}">.`
);
}
...
// <생략>
}
invariant(
useInRouterContext(),
// TODO: This error is probably because they somehow have 2 versions of the
// router loaded. We can help them understand how to avoid that.
`useRoutes() may be used only in the context of a <Router> component.`
);
useInRouterContext
란, 현재 컴포넌트가 Router
컴포넌트 안에(하위) 위치하는 지 검사하는 역할을 한다. 이유는 useRoutes()
가 react hook
이기 때문에 Router
내부에서만 사용 가능하기 때문이다.
따라서 해당 오류는 두 가지의 다른 버전의 Router
를 사용했을 때, 일어나도록 하는 것 같다. (주석 내용)
버전이 다르다면, 버전에 맞게 hook
을 사용해주어야 한다. 또한, 버전이 다른 Router
마다 각각의 context
를 가지기 때문에 충돌이 일어날 가능성이 있다.
비슷한 예시지만 다른 오류인 경우도 있다. 나는 이 에러가 react hook을 함수 컴포넌트 밖에서 사용했을 때와 닮아있다고 느꼈으나 위의 에러는 Router 내부에서 useRoutes() hook을 사용할 수 있다는 의미라서 약간 다르다.
let dataRouterStateContext = React.useContext(DataRouterStateContext);
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;
dataRouterStateContext
: context에서 Router의 정보를 가져온다.parentMatches
: 경로와 일치하는 라우트의 배열routeMatch
: 가장 최근 라우트 매칭 결과(즉, 제일 마지막에 추가된 데이터)parentParams
: 현재 매칭된 라우트의 param를 참조한다.parentPathname
: 현재 매칭된 라우트의 pathname을 참조한다.parentPathnameBase
: 현재 매칭된 라우트의 pathnameBase를 참조한다.parentRoute
: 현재 매칭된 라우트가 존재한다면, 해당 라우트를 참조한다.if (__DEV__) {
// You won't get a warning about 2 different <Routes> under a <Route>
// without a trailing *, but this is a best-effort warning anyway since we
// cannot even give the warning unless they land at the parent route.
//
// Example:
//
// <Routes>
// {/* This route path MUST end with /* because otherwise
// it will never match /blog/post/123 */}
// <Route path="blog" element={<Blog />} />
// <Route path="blog/feed" element={<BlogFeed />} />
// </Routes>
//
// function Blog() {
// return (
// <Routes>
// <Route path="post/:id" element={<Post />} />
// </Routes>
// );
// }
let parentPath = (parentRoute && parentRoute.path) || "";
warningOnce(
parentPathname,
!parentRoute || parentPath.endsWith("*"),
`You rendered descendant <Routes> (or called \`useRoutes()\`) at ` +
`"${parentPathname}" (under <Route path="${parentPath}">) but the ` +
`parent route path has no trailing "*". This means if you navigate ` +
`deeper, the parent won't match anymore and therefore the child ` +
`routes will never render.\n\n` +
`Please change the parent <Route path="${parentPath}"> to <Route ` +
`path="${parentPath === "/" ? "*" : `${parentPath}/*`}">.`
);
}
간단히 말해서, path 뒤에
*
을 붙이지 않으면, 발생하는 오류이다.
예를 들어,/blog
라는 컴포넌트 안에서/blog/post
라는 경로를 만들고자 할 때,/blog/*
라고 명시하지 않으면 하위 경로는 매칭되지 않는다는 것이다.
let locationFromContext = useLocation();
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;
}
제일 상단의 useLocation()
을 볼 수 있다. 이는 react hook 인데, 객체의 값을 반환한다.
pathname
, search
,hash
등의 속성이 있다.
그 다음 줄로 if
문을 통해 locationArg
의 타입이 문자열이라면, parsePath
함수를 호출하는데, 앞서 말했듯이 location
의 값은 객체타입으로 들어와야 한다.
따라서 문자열일 경우, url 주소 그대로인 것으로 판단하여 parsePath
를 통해 객체 타입으로 변환하여 반환한다.
반환하는 값에는 pathname
, search
,hash
등의 속성이 있다.
if
문을 통해 locationArg
가 있다면, parsedLocationArg
의 값이 location
에 할당된다. (객체로 변환한 값) 그 외의 상황에서는 context
로 가져온 객체 값이 할당된다.
let pathname = location.pathname || "/";
let remainingPathname =
parentPathnameBase === "/"
? pathname
: pathname.slice(parentPathnameBase.length) || "/";
let matches = matchRoutes(routes, { pathname: remainingPathname });
parentPathnameBase
는 현재 매칭된 라우트의 base
이다. 즉, parentPathnameBase
가 루트 경로라면, pathname
을 반환하고, 그게 아니라면 base
의 문자열 개수만큼 pathname
에서 삭제한다.
pathname 에서 base url만 제외시키는 것, 루트 경로라면 pathname 그대로 사용
matchRoutes
함수는 주어진 경로(remainingPathname
)에 대응하는 라우트(routes
)를 찾아내고 그 결과를 matches
에 저장한다.
if (__DEV__) {
warning(
parentRoute || matches != null,
`No routes matched location "${location.pathname}${location.search}${location.hash}" `
);
warning(
matches == null ||
matches[matches.length - 1].route.element !== undefined,
`Matched leaf route at location "${location.pathname}${location.search}${location.hash}" does not have an element. ` +
`This means it will render an <Outlet /> with a null value by default resulting in an "empty" page.`
);
}
그 다음 개발 모드(__DEV__
)에서만 동작하는 두 개의 에러 메세지가 있다.
location
에 매칭한 라우트가 존재하지 않을 시 띄우는 경고여기서 잠깐 헷갈렸다. 예를들어,
parentRoute || matches != null
라면, 값이 존재할 때true
가 되어warning
함수를 호출하는데 반대가 되어야 하는 게 아닌가 싶어졌기 때문이다. 그러나warning
함수는 첫번째 인자가false
일 때 함수를 호출하니 유의하자!
let renderedMatches = _renderMatches(
matches &&
matches.map((match) =>
Object.assign({}, match, {
params: Object.assign({}, parentParams, match.params),
pathname: joinPaths([parentPathnameBase, match.pathname]),
pathnameBase:
match.pathnameBase === "/"
? parentPathnameBase
: joinPaths([parentPathnameBase, match.pathnameBase]),
})
),
parentMatches,
dataRouterStateContext || undefined
);
_renderMatches
의 첫 번째 인자로 matches && matches.map(...)
이 보인다. 이는 matches
에 값이 존재할 때 실행하도록 만들었다. (값이 없다면 map을 돌릴 때 에러가 난다)
_renderMatches 의 첫 번째 인자
matches
에 map을 돌리며 Object.assign
을 통해 새로운 객체를 생성한다.
Object.assign
의 첫 번째 인자: 빈 객체 생성Object.assign
의 두 번째 인자: match의 속성을 복제Object.assign
의 세 번째 인자: 반환 값즉, 원래
match
객체에 추가적으로params
,pathname
,pathnameBase
를 추가한 객체이다.
parentPathnameBase
)에 현재 pathname
을 합친다.parentPathnameBase
경로를 사용하고 그게 아니라면 parentPathnameBase
에 현재 pathnameBase
경로를 합친다._renderMatches 의 두 번째 인자
parentMatches
: context
로 전달받은 match
들
_renderMatches 의 세 번째 인자
dataRouterStateContext
: dataRouterStateContext
에 값이 있다면 사용하고 없다면 undefined
를 사용한다.
따라서,
_renderMatches
함수는 주어진matchs
를 바탕으로 변형된 새로운matchs
를 반환한다. (map 사용으로)
// When a user passes in a `locationArg`, the associated routes need to
// be wrapped in a new `LocationContext.Provider` in order for `useLocation`
// to use the scoped location instead of the global location.
if (locationArg) {
return (
<LocationContext.Provider
value={{
location: {
pathname: "/",
search: "",
hash: "",
state: null,
key: "default",
...location,
},
navigationType: NavigationType.Pop,
}}
>
{renderedMatches}
</LocationContext.Provider>
);
}
return renderedMatches;
}
locationArg
가 있다면 지역 location
을 사용하게 된다. 따라서 LocationContext
에 새로운 location
정보를 전달한다. (덮어씌운다)
그리고 자식 요소로 앞에서 처리한 renderedMatches
가 들어가게 된다.
만약, locationArg
가 없다면, 단순히 renderedMatches
를 반환하게 되고 전역 location
을 사용한다.
이상으로 useRoutes 함수가 종료된다.
Router보다 Routes의 로직이 더 길고 복잡한 느낌을 받았다.
나는 단순하게 react-router를 구현할 때, Routes 컴포넌트로 들어오는 Route 중, 현재 location과 일치하는 Route만 렌더링하도록 했다.
즉, Routes 배열 중 find 메서드를 사용하여 일치하는 요소를 반환하고 렌더링했는데, 실제 react-router에서 사용하는 Routes 컴포넌트는 예외 처리도 많아 좀 더 복잡한 느낌이다.
그야 실제 사용하는 라이브러리니까
Routes 로직을 살펴보며, find 라는 날 것의 메서드보단 match라는 함수를 사용하여 location을 분리한 게 인상 깊었다.