Vanilla 로 구현해보는 라우터
2022. 07. 03.
#자바스크립트
라우터
바닐라 JS 에서 라우터 기능을 만들면서 경험한 것들을 공유하고자 한다.
간단 라우터
// 라우터 기능 const CHNAGE_ROUTE_EVENT = 'locationChange'; const changeRoute = (url) => { history.pushState(null, null, url); window.dispatchEvent(new CustomEvent(CHNAGE_ROUTE_EVENT)); } export { changeRoute }
- 라우터 기능을 모듈한 것으로
history.pushState
API 를 이용하여 요청받은 API 로 URL 을 변경한다.
CustomEvent 를 통해 이벤트를 발생하여 해당 이벤트를 등록한 기능을 동작하도록 한다.
// 라우터 사용(등록 - 라우터에서 등록한 CustomEvent 를 처리하는 부분) const CHNAGE_ROUTE_EVENT = 'locationChange'; function App() { this.init = () => { this.route(); }; // route 기능 this.route = () => { const { pathname } = location; if (pathname === "/") { new ProductListPage($(".App")).init(); return; } if (pathname === "/cart") { new CartPage($(".App")).init(); return; } if (pathname.indexOf("/products/") === 0) { // /products/1 // ['', 'product', 1] const [, , productId] = pathname.split("/"); new ProductDetail($(".App"), productId).init(); return; } // NotFoundPage console.log("NotFoundPage", pathname); }; this.route(); // 이 부분은 App 생성자에서 등록해도 될듯 window.addEventListener(CHNAGE_ROUTE_EVENT, this.route); window.addEventListener("popstate", this.route); // 뒤로 가기 이벤트 등록 } const app = new App(); app.init();
- 라우트 기능을 정의하고
changeRoute
를 호출하면 커스텀 이벤트가 호출되기 때문에 해당 이벤트를 처리할 수 있도록 이벤트를 등록한다.
- 이때 해당 이벤트를 처리하기 위해 route 기능을 정의하고 custom 이벤트가 발생할 때 마다 route 기능을 가능하도록 한다.
// 사용 방법 import { changeRoute } from "../libs/router.js"; function CartComponent($target, initialState) { this.init = () => { $target.innerHTML = ""; this.state = initialState; this.render(); this.registerEvent(); }; this.render = () => { const totalPrice = this.state.reduce((acc, cur) => (acc += cur.price * cur.quantity), 0); $target.innerHTML = ` <div class="CartPage"> <h1>장바구니</h1> <div class="Cart"> <ul> ${this.state .map(({ productName, optionName, price, imageUrl, quantity }) => { return ` <li class="Cart__item"> <img src="${imageUrl}" /> <div class="Cart__itemDesription"> <div> ${productName} ${optionName} ${quantity}개 </div> <div>${price}원</div> </div> </li> `; }) .join("")} </ul> <div class="Cart__totalPrice"> 총 상품가격 ${totalPrice}원 </div> <button class="OrderButton"> 주문하기 </button> </div> </div> `; }; // 사용하는 방법 this.registerEvent = () => { $(".OrderButton").addEventListener("click", () => { alert("주문이 완료되었습니다."); clearItem("products_cart"); changeRoute("/"); }); }; } export default CartComponent;
- 사용하는 곳에서 changeRoute 를 호출하여 사용할 수 있다
(상세) 라우터 기능 모듈화
전체 코드
const CHANGE_ROUTE_EVENT = 'urlChange'; interface RouterImpl { setNotFound(component: Component): Router; addRoute(path: string, component: Component): Router; changeRoute(url: string): void; route(): void; } type RouterType = { testRegExp: RegExp; component: (location?: any) => void; params: string[]; }; export default class Router implements RouterImpl { router: RouterType[]; private lastURL = ''; constructor() { this.router = []; this.notFoundComponent = () => {}; window.addEventListener(CHANGE_ROUTE_EVENT, this.route); window.addEventListener('popstate', this.route); // 뒤로 가기 이벤트 등록 this.addLinkEvent(); // 등록된 링크를 처리하기 위한 이벤트 등록 } private addLinkEvent = () => { ($('body') as HTMLBodyElement).addEventListener('click', (event: any) => { const { target } = event; if (target.matches('*[data-navigate]')) { event.preventDefault(); const { navigate } = target?.dataset; this.changeRoute(navigate); } }); }; private notFoundComponent: () => void; private getUrlQuery = () => { const searchQuery = new URLSearchParams(window.location.search); const query: Obj = {}; for (const [key, value] of searchQuery) { query[key] = value; } return query; }; private getUrlParams = (currentRouter: RouterType, pathname: string) => { const params: Obj = {}; if (currentRouter.params.length !== 0) { const matches = pathname.match(currentRouter.testRegExp); // ❓ THINK : 테스트 커버리지를 검사할 때 matches?.shift 쓰면 안되어서 if문 안으로 검사함. // 이럴 경우 어떻게 처리하는게 좋을까? if (matches) { matches.shift(); matches.forEach((paramValue, index) => { const paramName = currentRouter.params[index]; params[paramName] = paramValue; }); } } return params; }; setNotFound = (component: () => void): Router => { this.notFoundComponent = component; return this; }; addRoute = (path: string, component: Component): Router => { // params 제공 const params: string[] = []; // parse params const parsedParam = path .replace(/:(\w+)/g, (_, param) => { params.push(param); // param 추가하고 return '([^\\/]+)'; // 정규표현식으로 처리하기 위해 param 을 특정 패턴으로 변경 }) .replace(/\//g, '\\/'); // '/' 를 '\/' 패턴으로 변경 (정규표현식으로 인식하기 위함) this.router.push({ testRegExp: new RegExp(`^${parsedParam}$`), component, params, }); return this; }; changeRoute = (url: string): void => { history.pushState(null, '', url); window.dispatchEvent(new CustomEvent(CHANGE_ROUTE_EVENT)); }; route = (): void => { const { pathname, search } = window.location; const URL = `${pathname}${search}`; if (this.lastURL === URL) { return; } this.lastURL = URL; // parse query const query = this.getUrlQuery(); const currentRouter = this.router.find((route) => route.testRegExp.test(pathname)); if (!currentRouter) { this.notFoundComponent(); return; } // parse params const params = this.getUrlParams(currentRouter, pathname); const location = { params, query, }; currentRouter.component(location); }; }
router 생성자
router: RouterType[]; private lastURL = ''; constructor() { this.router = []; this.notFoundComponent = () => {}; window.addEventListener(CHANGE_ROUTE_EVENT, this.route); window.addEventListener('popstate', this.route); // 뒤로 가기 이벤트 등록 this.addLinkEvent(); // 등록된 링크를 처리하기 위한 이벤트 등록 }
- 해당 코드를 보면
router
와 notFoundComponent
로 속성으로 구성되어 있으며 router
에 등록된 라우트들을 관리하는 속성이다.notFoundComponent
속성은 404 인 경우, 렌더링할 컴포넌트를 관리하는 속성이다.→ 즉, addRoute 로
“/”, HomeComponent
"/users", UsersComponent
를 등록하면 router 에 등록되어 관리가 되어진다.notFoundComponent
는 router 에 등록되지 않은 경우 렌더링할 컴포넌트이다.- router 의 기능을 이용하여 url (라우터)이 변경될 경우, CustomEvent 로 이벤트를 dispatch 하기 때문에 해당 이벤트를 처리하기 위한 이벤트 및 뒤로가기 이벤트를 처리하기 위해 popstate 이벤트를 등록하였다.
- addLinkEvent 는 url 을 변경하는 기능을 제공하기 위해 등록된 기능이다.
⇒ 모두 생성자에 등록한 이유는 최초 한 번만 수행되면 되기 때문이다.
Link 기능을 처리하기 위한 기능
private addLinkEvent = () => { ($('body') as HTMLBodyElement).addEventListener('click', (event: any) => { const { target } = event; if (target.matches('*[data-navigate]')) { event.preventDefault(); const { navigate } = target?.dataset; this.changeRoute(navigate); } }); };
<!-- 사용 방법 --> <ul> <li><button data-navigate="/">home (/)</button></li> <!-- <li><a data-navigate="/users"> users (/users)</a></li> --> </ul>
- 범용성을 지원하기 위해 속성에
[data-navigate]
을 통해 라우터 변경 기능을 제공하고자 했다.
data-navigate 의 값으로 switching 할 url 을 입력하고자 했다.
- addLinkEvent 함수에서 body 이벤트를 등록하고
*[data-navigate]
data-navigate 가 지정된 모든 요소를 찾아 해당 값으로 route 를 변경할 수 있도록 했다.
setNotFound
setNotFound = (component: () => void): Router => { this.notFoundComponent = component; return this; };
- setNotFound 함수는 router 에 등록되지 않은 라우터가 요청될 경우 notFoundComponent 를 렌더링하는데 이때 렌더링할 컴포넌트를 할당하는 기능이다.
addRoute
addRoute = (path: string, component: Component): Router => { // params 제공 const params: string[] = []; // parse params const parsedParam = path .replace(/:(\w+)/g, (_, param) => { params.push(param); // param 추가하고 return '([^\\/]+)'; // 정규표현식으로 처리하기 위해 param 을 특정 패턴으로 변경 }) .replace(/\//g, '\\/'); // '/' 를 '\/' 패턴으로 변경 (정규표현식으로 인식하기 위함) this.router.push({ testRegExp: new RegExp(`^${parsedParam}$`), component, params, }); return this; };
// 사용 방법 const router = new Router(); router // .addRoute('/', pages.home) .addRoute('/posts', pages.posts) .addRoute('/posts/:id', pages.post) .addRoute('/posts/:id/:nestedId', pages.nestedPost) .addRoute('/users', pages.users) .addRoute('/users/:id', pages.user) .setNotFound(pages.notFound) .route();
- addRoute 기능은 라우터를 등록하는 기능이다.
url 별 보여주고 싶은 컴포넌트를 지정하는 기능이다.
- 라우터를 등록할 때 렌더링할 주소와 매칭할 수 있는
testRegExp
와 렌더링할 해당 컴포넌트, 주어진 param 정보(:id
:nestedId
등) 를 등록하여 관리한다.
// parse params const parsedParam = path .replace(/:(\w+)/g, (_, param) => { params.push(param); // param 추가하고 return '([^\\/]+)'; // 정규표현식으로 처리하기 위해 param 을 특정 패턴으로 변경 }) .replace(/\//g, '\\/'); // '/' 를 '\/' 패턴으로 변경 (정규표현식으로 인식하기 위함)
/:(\w+)/
는 param 의 패턴이:
으로 시작해서:
키워드를 포함하는 여러 글을 파싱함.
param 키워드를
:id
처럼 온 경우 :id
값을 params 배열에 추가하고 ([^\\/]+)
패턴으로 변경하여 정규표현식으로 파싱가능하게 하였다.또한, param 부분을 정규표현식으로 이해하게 만들기 위해 전체 path 를 정규표현식 패턴으로 만들기 위해
/
글자를 \\/
으로 변경.- 정규표현식에서
/
글자를 파싱하기 위해서는\/
로 인식하게 해야함
/posts/1
→ /posts/([^\/]+)
→ \/posts\/([^\\/]+)
/posts/1/test
→ /posts/([^\/]+)/([^\/]+)
→ \/posts\/([^\\/]+)\/([^\\/]+)
changeRoute
changeRoute = (url: string): void => { history.pushState(null, '', url); window.dispatchEvent(new CustomEvent(CHANGE_ROUTE_EVENT)); };
private addLinkEvent = () => { ($('body') as HTMLBodyElement).addEventListener('click', (event: any) => { const { target } = event; if (target.matches('*[data-navigate]')) { event.preventDefault(); const { navigate } = target?.dataset; this.changeRoute(navigate); } }); };
- changeRoute 함수는 url 을 변경하고자 할 때 사용하여 history 와 CustomEvent 를 호출하여 SPA 환경에서 URL 을 변경하는 기능을 수행하였다.
- 앞에서 설명한 addLinkEvent 는 리액트의 Link 및 a 태그 클릭시 url 이 변경되는 기능이다.
이때 해당 navigete 값(url) 을 가져와 해당 url 로 변경할 때 chageRoute 기능을 사용할 수 있다.
route
// query private getUrlQuery = () => { const searchQuery = new URLSearchParams(window.location.search); const query: Obj = {}; for (const [key, value] of searchQuery) { query[key] = value; } return query; }; // params private getUrlParams = (currentRouter: RouterType, pathname: string) => { const params: Obj = {}; if (currentRouter.params.length !== 0) { const matches = pathname.match(currentRouter.testRegExp); // ❓ THINK : 테스트 커버리지를 검사할 때 matches?.shift 쓰면 안되어서 if문 안으로 검사함. // 이럴 경우 어떻게 처리하는게 좋을까? if (matches) { matches.shift(); matches.forEach((paramValue, index) => { const paramName = currentRouter.params[index]; params[paramName] = paramValue; }); } } return params; }; route = (): void => { const { pathname, search } = window.location; const URL = `${pathname}${search}`; if (this.lastURL === URL) { return; } this.lastURL = URL; // parse query const query = this.getUrlQuery(); const currentRouter = this.router.find((route) => route.testRegExp.test(pathname)); if (!currentRouter) { this.notFoundComponent(); return; } // parse params const params = this.getUrlParams(currentRouter, pathname); const location = { params, query, }; currentRouter.component(location); };
- route 기능은 url 이 변경될 때 호출되는 기능으로 params 정보와 query 정보를 파싱하여 해당 컴포넌트를 렌더링할 때 params 정보와 query 정보를 전달하여 해당 값을 이용하여 처리할 수 있도록 하였다.
route 기능은 url 변경시 해당 컴포넌트를 render 하는 기능이다.
lastUrl
값을 따로 두어 이전과 같은 url 인 경우 재 렌더링 기능을 방지하였다.
getUrlQuery
메서드를 이용하여 해당 url 의 쿼리를 parse 하고getUrlParams
메서드를 이용하여 url 의 쿼리를 parse 하고 해당 정보를 component 에 전달하여 해당 컴포넌트에서 params 와 query 정보를 사용할 수 있게 하였다.
- 또한, route 에 등록되지 않은 요청인 경우,
notFoundComponent
메서드를 호출하여 404 컴포넌트를 렌더링하였다.
해당 내용을 자세히 알고 싶다면 해당 기능을 구현한 github 저장소 및 커밋 내용을 보면 좋을 거 같다!
이와 관련된 테스트 코드도 작성하여 도움이 되었으면 좋겠다.