logo
menu

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(); // 등록된 링크를 처리하기 위한 이벤트 등록 }
  • 해당 코드를 보면
    • routernotFoundComponent 로 속성으로 구성되어 있으며
      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 저장소커밋 내용을 보면 좋을 거 같다!
이와 관련된 테스트 코드도 작성하여 도움이 되었으면 좋겠다.