그동안 route 세팅과 관련 로직 작성을 주로 담당하기도 했고, 이것저것 구현해 볼 때 꼭 필요한 기능이라는 생각이 들어서, 중첩 라우팅이 가능한 페이지 라우터를 직접 구현해봤다!
순수 자바스크립트만으로 구현하려다, 타입스크립트 친구를 데려오고 말았다. 객체를 많이 활용하는 코드를 작성할 때 타입스크립트가 상당히 많은 도움을 주기 때문이다.타며들었다...
단순히 라우팅 기능
을 개발하는 것이 아니라, 미래의 내가 써먹을 수 있는 라우터
자체를 구현하기!
이 코드만 떼어다가 다른 프로젝트에서도 써먹을 수 있어야 한다.
아래는 다시 쓸 수 없는 라우팅 기능의 예시이다.
이전에 Notion 클로닝 프로젝트를 진행했던 적이 있는데, App 함수 내에서 notion 이라는 도메인과 강력하게 엮여있는 코드를 볼 수 있었다.
다시 읽는 입장에서는 어떤 기능을 하는 라우터인지 해석하기도 어려웠다.
// App.js
this.route = () => {
const currentUrl = router.getUrl();
navPage.setState();
if (currentUrl === '/') {
editPage.setState({ id: null });
navPage.setState({ selected: null });
} else {
const { id } = router.getQuery();
editPage.setState({ id });
}
};
this.route();
router.observe(this.route);
게다가 이것은 App 함수 안에 써져 있는 코드일 뿐 .. 어딘가로 분리하는 것도 할 수 없다.
따라서, 분리 가능 + 도메인 연관 X
인 라우터를 구현하기로 했다.
익숙한 코드 인터페이스, 기본적인 기능은 장착해야 한다. 보편적으로 !
그동안 편리한 프로젝트를 위해서 공통 커스텀 훅과 공통 컴포넌트를 구현하고 써먹으면서 깨달은 바가 있다. 그 장치를 활용할 때 드는 러닝 리소스를 최소화해야 한다는 것이다. 엄청난 기능이 들어있지 않은 이상, 인터페이스가 과하게 특이하거나 사용할 때의 제약이 많으면 결국 사용하지 않게 되는 것 같다.
또한, 기본적인 기능은 있어야 하기에 기존에 구현해보지 않았던 ‘중첩 라우팅’ 을 챌린지로 삼았다.
이런 요구사항들을 만족하는 react-router-dom
의 createBrowserRouter
코드 인터페이스를 가져와서 써보기로 했다.
// router 생성
const router = createBrowserRouter([
{
path: "/",
element: <Home />,
children: [
{
path: "about",
element: <About />,
},
{
path: "detail",
element: <Detail />,
},
],
},
]);
// outlet
const Home = () => {
return (
<div>
<h1>Home</h1>
<Outlet />
</div>
)
}
createBrowserRouter 메소드로 라우터를 생성해주면, Outlet 컴포넌트에서 children 에 넣어준 친구들이 보여지는 방식이다.
어느 정도의 렌더링 성능 고려하기
/list
이면, list 페이지를 렌더링한다.
/list/item
이면, list 페이지와 그 안의 item 페이지를 렌더링한다.
/list/item1
⇒ /list/item2
경로 변경이 발생하면?
item1, item2 가 list 페이지 내의 중첩된 라우트라고 가정할 때, 변경이 일어난 Outlet
부분만 다시 실행되어야 한다. 왜냐하면 list 페이지 자체는 변하지 않기 때문이다!
url 이 바뀔 때 전체 페이지를 다시 로드하는 것이 아니라, 바뀐 route 부분만 리렌더링하도록 구현하고 싶었다.
일단 코드 인터페이스를 적어보고 뒤에서 어떤 일들이 일어나야 하는지 생각했다.
createRouter([
{
path: "/",
element: <Home />,
children: [
{
path: "/about",
element: <About />,
},
{
path: "/detail",
element: <Detail />,
},
],
},
]);
이렇게 createBrowser 를 실행하면, Router 인스턴스가 생성되는 싱글톤 패턴임을 추측할 수 있었다.
createRouter 는 constructor 에 들어가는 매개변수를 전달하여 생성된 Router 인스턴스를 반환해주는 친구인 것이다.
생성된 Router 는 url 과 매칭된 페이지를 반환해주는 등의 라우팅 기능을 실행할 것이다.
class Router {
routes;
constructor(routes: Route[]) {
this.routes = routes;
}
}
const createRouter = (routes: Route[]) => new Router(routes);
export default createRouter;
Route 객체의 타입은 아래와 같다.
export interface Route {
path: string;
component: (page: HTMLElement) => void;
children?: Route[];
}
path
: 경로component
: 렌더링 함수children
: 자식 Route 객체 배열결국 Route 배열은 트리구조를 가진다. 재귀 함수를 많이 작성하게 될 거라는 생각이 들었다. 하하
그렇다면 Router 클래스 안에서는 어떤 일들을 해주어야 할까?
기본적으로는 아래와 같은 기능을 수행해야 한다.
여기서 주의해야 할 점은, url 이 수정되는 케이스가 많다는 것이다.
브라우저 내에서 뒤로가기/앞으로가기 를 수행할 수도 있고, 사용자가 직접 페이지 navigate 를 할 수도 있다.
따라서 url 이 수정되는 것을 하나의 이벤트로 분리하고, 해당되는 액션이 발생할 때 마다 이벤트를 발행해야 한다!
navigate
커스텀 이벤트 붙이기window.addEventListener('navigate', (event) => {
/*
1. 수정된 url 가져오기
2. 렌더링해주기
3. history 에 추가
*/
});
navigate
이벤트 발행하기const navigateEvent = new CustomEvent('navigate', {
detail: { path: '새로운 url' },
});
window.dispatchEvent(navigateEvent);
class Router {
// ...
constructor(routes: Route[]) {
this.routes = routes;
this.init();
const worker = routeWorker(this.routes);
this.render = worker.render;
}
init() {
// 최초 url 렌더링
this.render(window.location.pathname);
// navigate 이벤트 붙이기
window.addEventListener('navigate', (event) => {
const { path } = (event as CustomEvent).detail;
this.render(path, window.location.pathname);
window.history.pushState({}, '', path);
});
}
}
// navigate 이벤트를 발행하는 함수
export const navigate = (path: string) => {
const navigateEvent = new CustomEvent('navigate', {
detail: { path },
});
window.dispatchEvent(navigateEvent);
};
가장 기본적이고 필수적인 기능이다!
url 에 맞는 페이지를 보여주려면, Route 배열로부터 적절한 객체를 찾아서 페이지 컴포넌트를 실행시켜야 한다.
만약 페이지 주소가 /list/item1
이라면,
찾아야 하는 Route 의 객체는 /
안의 /list
안의 /item1
객체일 것이다.
재귀적으로 탐색을 진행 해야겠다는 아이디어를 얻을 수 있었다.
const findRoute = (
routes: Route[],
url: string,
): Route => {
for (const route of routes) {
if ('url에 해당되는 route객체') {
return route;
}
if (route.children) {
const childRoute = findRoute(route.children, url);
if (childRoute) {
return childRoute;
}
}
}
return null;
};
Route 배열로부터 적합한 Route 객체를 탐색하는 방법은 두 가지가 있다.
첫번째 방법의 경우, ‘어떤 기준으로 자르는가’ 의 기준이 애매하다.
아래와 같은 케이스를 처리하기 곤란하기 때문이다.
// 중첩된 페이지로 렌더링 하고 싶지는 않지만,
// 중첩된 라우트는 가지고 싶을 때 이와 같이 쓰곤 한다.
[
{ path : '/list' },
{ path : '/list/item'}
]
url segment 를 나누는 /
문자를 기준으로 자르면, /list/item
을 한 덩어리로 보지 못하게 된다. 결국 /list
의 children
을 찾아 떠날 것이다.
따라서 두번째 방법으로 구현할 수 있었다.
초기값인 ‘/’ 인 currentPath 가 url 과 동일해 질 때까지 route 배열 탐색
const findRoute = (
url: string,
routes: Route[],
currentPath: string
): RouteWithParams | null => {
// routes 배열을 순회하면서 검사
for (const route of routes) {
// currentPath 에서 route.path 더하기
const path = (currentPath + route.path).replace('//', '/');
// 더한 경로가 url 과 부합하는지 검사
const { isMatched, params } = matchPathToUrl(path, url);
// 부합한다면 Route 객체 반환
if (isMatched) {
return { ...route, params };
}
// 아니라면 children 배열에서 다시 검사
if (route.children) {
const childRoute = findRoute(url, route.children, path);
if (childRoute) {
return childRoute;
}
}
}
return null;
};
// findRoute 반환값
// url : /post/123
{
path: '/post/:id',
component: (page) => { /* 렌더링 */ },
children: [],
params: { id : '123' }
}
위에서 matchPathToUrl 은 만들어진 path 가 url 과 맞는지 검사하는 함수이다.
/post/:id
와 같이 파라미터를 포함한 path 를 고려하여 구현하였다.
/
문자를 기준으로 각 segment 가 동일한지 검사하고, 파라미터를 포함하는 segment 일 경우 해당 파라미터를 반환한다.
const matchPathToUrl = (path: string, url: string) => {
const params: Params = {};
// 파라미터 없이 아예 똑같은 경로라면 바로 반환
if (path === url) return { isMatched: true, params };
const pathSegment = path.split('/');
const urlSegment = url.split('/');
// 같은 경로인지를 나타내는 Boolean 값
const isMatched =
pathSegment.length === urlSegment.length &&
pathSegment.every((seg, i) => {
if (seg === urlSegment[i]) return true;
else if (seg.startsWith(':')) {
// 파라미터 segment 일 경우
params[seg.slice(1)] = urlSegment[i];
return true;
}
return false;
});
return { isMatched, params };
};
path = /post/:id
, url = /post/123
일 경우 아래와 같은 객체를 반환한다.
{
isMatched: true,
params: { id : '123' }
}
url 에 맞는 route 객체를 찾았다면, 이제 그에 맞게 페이지를 보여줘야 한다.
현재 주소가 /list/item
이라면, findRoute 함수는 아래와 같은 객체를 반환할 것이다.
// url = /list/item
{
path: '/item'
component: (page) => { /* 렌더링 */ }
}
여기 있는 component 함수를 그대로 실행시켜주면 렌더링이 끝나는걸까?
현재 url 을 보면 /
> /list
> /item
이런 형태의 중첩 라우트라는 것을 알 수 있다.
/
와 /list
에 해당되는 컴포넌트도 실행되어야 한다.
따라서, 적합한 route 를 재귀적으로 탐색하는 과정에서 부모 route 들도 어딘가에 저장해야 한다.
이를 위해서 거쳐온 route 객체들도 함께 반환하도록 findMathcingRoutes
함수를 작성했다.
export const findMatchingRoutes = (routes: Route[], url: string) => {
// 거쳐온 Route 들을 저장하는 배열
const routeFromRoot: Route[] = [];
const findRoute = (
routes: Route[],
currentPath: string
): RouteWithParams | null => {
for (const route of routes) {
// 생략
if (route.children) {
const childRoute = findRoute(route.children, path);
if (childRoute) { // 자식 route 가 매칭될 시, 부모 route 를 배열에 추가
routeFromRoot.push(route);
return childRoute;
}
}
}
return null;
};
// findRoute 실행
const matchedRoute = findRoute(routes, '/');
// 최종 종착지를 routeFromRoot 에 저장
if (matchedRoute) routeFromRoot.unshift(matchedRoute);
return { match: matchedRoute, routes: routeFromRoot };
};
이제 findRoute
함수는 Route 배열을 탐색하면서 최종 Route 를 찾을 뿐만 아니라, 거쳐온 부모 Route 를 하나씩 저장해 나간다.
findMatchingRoutes
는 내부에서 findRoute
를 실행한 후, 최종 Route 객체와 거쳐온 Route 들의 배열을 반환한다.
// url = /list/item
{
match: { path: '/item' ... },
routes: [
{ path: '/item', component: ... },
{ path: '/list', component: ... },
{ path: '/', component: ... }
]
}
반환된 routes 배열을 뒤에서 부터 pop 하면서 component 를 실행하면 된다!
url 에 맞게 page component 가 순서대로 실행된다. 그렇다면 어디에 페이지가 보여져야 하는걸까?
react-router-dom
의 Outlet 컴포넌트에서 힌트를 얻을 수 있었다.
// '/list' 에 해당하는 페이지 컴포넌트
<div>
<h1>List</h1>
<Outlet/> // '/item' 이 보여지는 부분
</div>
outlet 컴포넌트 내에서 children 에 해당하는 페이지들이 보여진다. outlet 컴포넌트를 넣어주지 않으면 자식 페이지들은 렌더링되지 않는다.
Route 의 depth 만큼 outlet 이 중첩되고, 그 안에 페이지가 보여져야 한다.
일단 element 의 id 값으로 outlet 을 설정하도록 하자!
/list/item
페이지의 경우, DOM 이 아래와 같이 완성되는 것이다.
<div id='app'>
Home Page
<div id='outlet'>
List Page
<div id='outlet'>
Item Page
</div>
</div>
</div>
route 배열에서 몇 번째로 pop 되었는 지를 통해 depth 를 알 수 있다.
[
{ path: '/item' ... }, // depth : 2 => '#outlet #outlet'
{ path: '/list' ... }, // depth : 1 => '#outlet'
{ path: '/' ... }, // depth : 0 => '#app'
]
이 depth 를 사용하여 outlet element 를 찾는 querySelector 를 실행한다.
const getOutletElement = (depth: number) => {
const selector = Array(depth).fill('#outlet').join(' ') || '#app';
return document.querySelector<HTMLElement>(selector);
};
이제 getOutletElement 에서 반환된 outlet 안을 채워주면 된다.
그렇기 때문에, route.component 에 들어가야 하는 함수는 outlet element 를 받아서 그 안에 template 을 채워넣는 친구인 것이다.
component: (page: HTMLElement) => void;
여기까지 했다면 route 배열을 pop 하면서 전체 component 를 실행해줘도 문제 없이 동작한다.
하지만 처음에 세운 목표대로, url 이 바뀔 때 전체 페이지를 다시 로드하는 것이 아니라 바뀐 route 부분만 리렌더링하도록 구현해보았다.
/list/item1
⇒ /list/item2
경로 변경이 발생하면, item1 depth 에 해당하는 outlet 만 다시 채워넣는 것이다.
route 배열이 달라지는 순간부터 component 함수가 실행되어야 한다.
url 이 달라지는 케이스는 총 3가지가 있다.
최초 로드 : 최상위 페이지 부터 차례대로 칠해 져야 한다.
상위 route 로 이동 : 하위 페이지가 지워져야 한다.
/list/item1
⇒ /list
: item1 지우기그 외 : 변경된 route 의 페이지부터 칠해져야 한다. 이미 칠해진 부모 페이지는 건드리지 않는다.
/post/123
⇒ /list
: list 부터 새로 칠하기/list/item1
⇒ /list/item2
: item 부터 새로 칠하기outlet 은 새로 칠해질 수도, 지워질 수도 있다.
getOutletElement
에서 반환된 outlet 을 수정하는 함수들을 작성했다.
// depth 에 해당하는 outlet 지우기
export const removeOutletElement = (depth: number) => {
const $outlet = getOutletElement(depth);
if ($outlet) {
$outlet.innerHTML = '';
}
};
// depth 에 해당하는 outlet 채우기
export const paintOutletElement = (
depth: number,
paint: (outlet: HTMLElement) => void
) => {
const $outlet = getOutletElement(depth);
if ($outlet) {
paint($outlet);
}
};
마지막으로, url 변경 방식에 따라 페이지를 렌더링하는 함수 renderRoute
를 구현했다.
findMatchingRoutes
를 통해 이전 주소와 변경된 주소의 route 배열을 얻는다.
nextRoutes 배열이 비워질 때 까지, 해당되는 outlet 에 페이지를 채워 넣는다.
paintOutletElement
를 실행한다. (renderStart = true) prevRoutes 가 남아있다면,
/list/item1
⇒ next : /list
)removeOutletElement
실행!export const renderRoute = (
routes: Route[],
nextPath: string, // 변경된 주소
prevPath?: string // 이전의 주소
) => {
// 이전 주소의 route 배열
const { routes: prevRoutes } = findMatchingRoutes(
routes,
prevPath ?? '/'
);
// 변경된 주소의 route 배열
const { routes: nextRoutes } = findMatchingRoutes(routes, nextPath);
// 렌더링을 시작할 지 알려주는 트리거
// prevPath 가 없다면 최초 렌더링으로 간주하여 처음부터 true
let renderStart = !prevPath;
let depth = 0;
// nextRoutes 배열이 비워질 때 까지, 해당되는 outlet 에 페이지를 채워 넣는다.
while (nextRoutes.length) {
const nextRoute = nextRoutes.pop();
const prevRoute = prevRoutes.pop();
if (
(renderStart || nextRoute?.path !== prevRoute?.path) &&
nextRoute
) {
renderStart = true; // 두 path 가 달라지는 시점부터 render start!
paintOutletElement(depth, nextRoute.component);
}
depth += 1;
}
// prevRoutes 가 남아있다면, 해당되는 outlet 들은 지워준다.
while (prevRoutes.length) {
prevRoutes.pop();
removeOutletElement(depth);
depth += 1;
}
};
페이지 라우팅이 이루어지는 과정은 다음과 같다.
createRouter
를 호출하여 페이지 라우터 설정
Router 클래스에서 navigation 이벤트 연결
navigate 함수에서 navigation 이벤트 발행
이전 url 과 변경된 url 를 비교하여 바뀐 부분만 렌더링
findMatchingRoutes
에서 findRoute
를 재귀적으로 호출하여 거쳐온 route 배열 반환
위에서 반환된 배열을 비교하면서,
getOutletElement
함수가 depth 를 받아 outlet element 를 반환
여기에 paintOutletElement
또는 removeOutletElement
실행
각종 함수들은 관심사에 따라 다른 파일에 분류해 두었다.
Router.ts
Router
routeUtils.ts
matchPathToUrl
findMatchingRoutes
domUtils.ts
getOutletElement
removeOutletElement
paintOutletElement
renderRoute.ts
renderRoute
routeWorker.ts
renderRoute
, findMatchingRoute
함수를 반환하는 친구이다.// Router constructor
const worker = routeWorker(this.routes);
this.render = worker.render;
의도한대로 잘 작동한다 🙂
// main.ts
createRouter([
{
path: '/',
component: homePage.render,
children: [
{
path: '/about',
component: aboutPage.render,
},
{
path: '/list',
component: listPage.render,
children: [
{
path: '/item1',
component: (page) => {
page.innerHTML = `<p>item1 페이지 입니다</p>`;
},
},
{
path: '/item2',
component: (page) => {
page.innerHTML = `<p>item2 페이지 입니다</p>`;
},
},
],
},
],
},
]);
navigation 이 가능한 기본적인 페이지 라우터를 구현했지만, 아직 남은 과제가 몇 가지 있다. 차근차근 개선할 예정이다!
/
안붙여도 작동 되게 하기/*
) 라우팅 구현, 등등..!
재귀적인 경로 탐색 알고리즘에 렌더링 효율성까지 많은 것을 고민하셨군요..
라이브러리를 사용하지 않는 상황에서 라우팅을 어떻게 처리 해야 할지 고민이었는데, 좋은 글 잘 보고가요!! 👍👍