본문 바로가기
JS&TS

Typescript만으로 SPA 라우터 만들기 (라이브러리 x)

by Luke K 2022. 9. 30.

이 글에서는 다른 라이브러리 없이 typescript를 이용하여 SPA 라우터를 만드는 것을 다룹니다.

시작 전에

SPA가 무엇인가?

SPA는 하나의 페이지를 동적으로 바꾸어가는 어플리케이션이다.
하지만 같은 url이면 사용자들끼리 링크를 공유했을 때 모두가 다른 화면을 볼 것이다.
그래서 url은 달라지지만 다시 get요청을 받는 것이 아닌 같은 index.html에 DOM을 동적으로 바꿔주는 라우터가 필요하다.

그래서 SPA에서 라우터는 무엇을 해결하는가?

  • SPA에서는 페이지 이동 시 Dom을 다시 그려주어야한다.
    • MPA: 새로운 html
    • SPA: 같은 html 내부를 갈아끼우기
  • 페이지 바뀜을 의도한 이벤트를 인식해야한다.

그러면 라우터의 역할은 무엇인가?

라우터의 역할은 정하기 나름일 것 같지만
내가 생각하는 라우터의 역할은 요약하면

  • path의 바뀜을 인식한다.
  • path에 따른 적당한 화면(컴포넌트들의 집합)을 브라우저에 그린다.

 

직접 만들어보자

간략화된 구조와 자세한 설명을 나눠놓았습니다.

간략한 구조를 보시며 흐름을 이해하시고 자세한 설명을 보시며 코드를 이해해보시는 것을 추천드립니다.

간략화된 구조 및 동작 순서

  1. index.html이 읽어질 때 내부 script가 읽힌다. (index.js -> app.js)
  2. script 내부에서는 싱글톤 라우터의 인스턴스를 생성한다.
import Router from './Router';

function App() {
    Router.getInstance();
}

export default App;
  1. 싱글톤 라우터의 생성자함수는 Map 자료구조를 가진 routes를 만든다. (경로->페이지(컴포넌트))
// typescript는 생성자를 private으로 만들 수 있습니다.
private constructor() {
    this.routes = new Map([
        ['/', () => Main()],
        ['/login', () => Login()],
        ['/signup', () => Signup()],
    ]) as Map<string, () => string>;
}
  1. 전역객체인 window의 popstate 이벤트에 render함수를 걸어놓는다. (뒤로가기, 앞으로가기 감지)
window.addEventListener('popstate', () => {
    this.render(location.pathname);
});
  1. render함수를 호출한다. (처음 시작할 때도 render를 해야한다.)
    this.render(location.pathname);

자세한 버전

Router.ts 전체 코드

import Main from './pages/Main';
import Login from './pages/Login';
import Signup from './pages/Signup';
import { BASE_URL_DEV } from './config/config';
import { initRender } from '@/core/renderer';

export default class Router {
    private static instance: Router;
    private routes: Map<string, () => string>;
    private $app: HTMLElement;

    private constructor() {    
        this.routes = new Map([
            ['/', () => Main()],
            ['/login', () => Login()],
            ['/signup', () => Signup()],
        ]) as Map<string, () => string>;

        this.$app = document.getElementById('app') as HTMLElement;

        window.addEventListener('popstate', () => {
            this.render(location.pathname);
        });

        this.render(location.pathname);
    }


    public static getInstance() {
        if (!Router.instance) {
        Router.instance = new Router();
        }    

        return Router.instance;
    }


    render(path: string) {
        if (this.routes.has(path)) {
            history.pushState({}, '', window.location.origin + path);
            const routeToPath = this.routes.get(path) as () => string;
            this.$app.innerHTML = routeToPath();
            initRender(this.$app, routeToPath);
        } 
        else {
            history.pushState({}, '', window.location.origin + '/');
            const routeToMain = this.routes.get('/') as () => string;
            initRender(this.$app, routeToMain);
        }
        this.addWholeAnchorEvent();
    }


    addWholeAnchorEvent() {
        const aTags = document.querySelectorAll('a');
        aTags.forEach((tag) =>
            tag.addEventListener('click', (e) => {
            e.preventDefault();
            const tagRef = tag.href.replace(BASE_URL_DEV, '');
            this.render(tagRef);
            })
        );
    }

}

필자가 만든 라우터는 싱글톤으로 라우터들의 인스턴스들이 같은 라우터임을 보장하도록 만들었다.

왜 싱글톤인가.

  • 라우터가 메모리에 여러개 올라갈 필요가 없다.
    • 어차피 같은 routes를 공유할 것이다.
      • prototype으로 메모리를 공유하지만 매핑 하는 것조차 굳이?
  • 전역(Window)에 거는 이벤트가 있다.
    • 라우터를 새로 만들면 전역에 새로운 이벤트가 걸린다.

이런 이유로 싱글톤으로 구현하였다.

constructor를 private으로 만들었기 때문에(ts만 가능) new로 인스턴스를 만들 시 오류가 난다.
따라서 getInstance 메서드를 이용하여 호출해야한다.

public static getInstance() {
    if (!Router.instance) {
        Router.instance = new Router();
    }
    return Router.instance;
}

이제 생성자 함수를 들여다보자.

    private constructor() {    
        this.routes = new Map([
            ['/', () => Main()],
            ['/login', () => Login()],
            ['/signup', () => Signup()],
        ]) as Map<string, () => string>;

        this.$app = document.getElementById('app') as HTMLElement;

        window.addEventListener('popstate', () => {
            this.render(location.pathname);
        });

        this.render(location.pathname);
    }

routes

routes는 경로와 페이지 컴포넌트를 이은 Map 자료구조이다.
그러면 의문이 든다. () => Main() 은 함순데..? 왜 페이지 컴포넌트라는 명사로 표현하지?
함수형 컴포넌트는 함수를 하나의 컴포넌트이자 값(일급)이자 명사로 볼 수 있다.
필자가 만든 페이지 컴포넌트는 HTML 템플릿을 반환하는 함수이기 때문에 그렇게 작성했다.
리액트를 참고했고 예시는 아래와 같다.

import Header from '@/components/MainHeader';

function Main() {
    return `
        ${Header()}
        <div>메인페이지</div>
    `;
}

export default Main;

popstate(history api)

window.addEventListener('popstate', () => {
    this.render(location.pathname);
});

윈도우 객체는 popstate이라는 이벤트를 가지고 있다.
브라우저는 history 객체를 제공한다.
이 객체 내부에 있는 history.state는 스택 자료구조로 만들어져 있다.
여기서 pop 즉 후입된 state가 state에서 빠져나간다는 얘기다. (pop pop 터지길 원해)
이 부분은 history api 자체에 관련된 글로 설명하는 게 좋을 것 같다.
다시 위에 있는 코드를 요약하자면
뒤로가기, 앞으로가기가 발생할 때 무엇을 할거냐이다.
사용자가 뒤로가기를 눌렀을 때 path가 바뀌고 그 path에 맞는 get요청을 브라우저는 보낼텐데
popstate 관련 설정을 안해주면 메인페이지가 아닌 페이지에는 404가 나온다.
그걸 가로채서 우리가 원하는 것을 render시켜주는것이다.

render

render(path: string) {
        if (this.routes.has(path)) {
            history.pushState({}, '', window.location.origin + path);
            const routeToPath = this.routes.get(path) as () => string;
            this.$app.innerHTML = routeToPath();
            initRender(this.$app, routeToPath);
        } 
        else {
            history.pushState({}, '', window.location.origin + '/');
            const routeToMain = this.routes.get('/') as () => string;
            initRender(this.$app, routeToMain);
        }
        this.addWholeAnchorEvent();
    }

render의 역할은 routes의 path에 따라 루트인 <main id="app"></main> 에 내용을 채워주는 것이다.
순서대로 살펴보자

  1. routes에 해당 경로가 있는지 확인한다.
  2. history.state에 새로운 path 정보를 넣어준다.
    코드를 보면 history.pushState라는 메서드가 있다.
    pushState는 인자 세개를 가지는데
    첫번째 인자는 저장할 데이터 객체이다.
    필자는 비워뒀지만 해당 페이지에서 써야하는 데이터가 있다면 넣으면 유용할 것이다.
    두번째 인자는 제목인데 빈 문자열로 두는 것이 대부분이다.
    딱히 쓸모가 없어 mozilla에서도 unused라고 한다.
    세번째 인자는 바꿀 주소이다.
    render함수는 path를 매개변수로 받음으로 routes에 path가 있다면 origin 즉 baseUrl(ex: localhost:3000) + path를 해준다.
  3. 루트인 <main id="app"></main> 에 해당 함수형 컴포넌트를 넣어준다.
  4. initRender(루트, 페이지 컴포넌트) 를 실행한다.
    해당 함수는 renderer라는 함수안에 있는 함수로 관리를 하고 있다.
    renderer함수를 살펴보자.
// Renderer.ts
function Renderer() {
    let root: any, component: any;
    const initRender = (initialRoot: HTMLElement, initialComp: () => string) => {
        root = initialRoot;
        component = initialComp;
        render();
    };
    const render = () => (root.innerHTML = component());
    return { initRender, render };
}


export const { initRender, render } = Renderer();

initRender(initialRoot, initialComp)는 리렌더를 위해 루트와 컴포넌트를 등록해놓는다.
이 함수에 root와 그 안에 무엇을 넣을 지 정해놓고
render() 함수로 상태가 바뀌었을 때 다시 그려주는 역할을 한다.

  1. a태그들에 이벤트를 건다.
    a태그들이 동작할 때 새로고침되는 것을 억제하기 위해 이벤트를 preventDefault()하고 페이지가 바뀌면 Router.render()를 실행한다.

결론

필자가 생각한 라우터의 역할을 하기 위해서는 해당 구조를 사용했다.
라우터를 직접 만들어서 나만의 spa를 만들어보는 것은 굉장히 의미있다.
엮어서 공부해볼만한 것들이 굉장히 많고 파다보면 같이 쓰면 궁합이 좋을 것들이 보일 수 있다.
필자의 경우에는 함수형 컴포넌트와 전역 상태를 만들어 사용하는 것과 궁합이 좋았다.
해당 글과 같이 읽을 때 이해도가 오를만한 글을 다음글로 포스팅해보려고 한다.

출처(참고문헌)

댓글