ABOUT ME

-

오늘
-
어제
-
-
  • Javascript로 라우터를 만들어 간단한 SPA를 구현해보기
    Front-end/Javascript 2020. 10. 1. 02:43

    라우터 ?

    라우터는 페이지들을 연결해주며 이를 이동시키는 역할을 해줍니다. 쉽게 말해 중계를 해준다고 표현하면 조금 더 이해하기 쉽겠네요!

     

    최근 SPA가 주로 활용되면서 중요성이 커졌는데요, 이전에는 대체적으로 MPA 방식으로 개발이 되었었습니다.

    그 이유는 외국에 비해 우리나라는 인터넷이 발달해서 약 10년 전 까지만 하더라도 MPA 방식으로 개발을 하더라도 사용하는데 큰 문제가 없었기 때문이죠.. 하지만 현재는 속도를 떠나 사용자의 사용성의 관점에서 본다면 SPA는 꼭 필요한 방식으로 자리잡았습니다.

     

    그래서 우리는 라우터를 순수 자바스크립트로 직접 구현하면서 공부해보는 시간을 가져보겠습니다.

     

    ❗️이 게시글은 개인적으로 공부하면서 얻은 자료들을 바탕으로 구현한 프로젝트입니다. 혹여 잘못된 정보가 있을 경우에는 댓글로 말씀부탁드립니다.

     

    전체 코드 보기

     

    프로젝트 구조

     

    먼저 모듈단위로 개발하기 위해 각각의 파일을 분리하였으며, browserify를 통해 간단한 번들링으로 구현하였습니다.

     

    프로젝트 세팅

    먼저 node 프로젝트를 생성하여 browserify를 설치해줍니다. (설치와 빌드 과정은 여기서 중요하지 않으므로 생략하겠습니다.)

     

    package.json

    {
      "name": "router",
      "version": "1.0.0",
      "description": "",
      "main": "index.js",
      "scripts": {
        "build": "cp -f public/index.html build/index.html && browserify index.js -o build/bundle.js"
      },
      "author": "",
      "license": "ISC",
      "dependencies": {
        "browserify": "^16.5.2"
      }
    }
    

    특별한 것 없이 번들링 빌드 스크립트와 browserify만 추가하였습니다.

     

    페이지 세팅

    publc/index.html

    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>라우터 공부하기</title>
      </head>
      <body>
        <div id="app"></div>
    
        <script src="./bundle.js"></script>
      </body>
    </html>
    

    페이지에 그릴 공간을 app이라는 박스를 하나 생성한 후 번들링 할 파일명을 선언해줍니다.

     

    pages/MainPage.js

    class MainPage {
      constructor() {}
    
      render() {
        return `<span>메인 페이지</span>
                <button type="button" class="main-button">Other Page</button>
                `;
      }
    }
    
    module.exports = MainPage;
    

     

    pages/OtherPage.js

    class OtherPage {
      constructor() {}
    
      render() {
        return `<span>다른 페이지</span>
                <button type="button" class="other-button">Main Page</button>
                `;
      }
    }
    
    module.exports = OtherPage;
    

     

    아직 라우터에 관련된 코드들은 없으며, render 함수를 통해 해당 페이지의 HTML을 그렸습니다.

     

    라우터 구현하기

    먼저 라우터를 만들 때 고려한 사항이 몇가지 있습니다.

     

    1. 라우팅할 전체 페이지를 등록한다.
    2. 전역에서 라우터를 가져와 쉽게 라우팅 할 수 있게 한다.
    3. 페이지가 변경될 때를 감지하여 라우팅을 한다.

     

    위 세 가지를 고려한 이유는, 등록한 페이지를 라우터는 항상 알고있어야 하고, 어디서든 라우팅을 하기 편하게 하며, 사용자가 페이지를 이동하는 것을 캐치하기 위함입니다.

     

    Router.js

    class Router {
      constructor() {
        window.onhashchange = () => {
          console.log('변경되면 나온다.');
        };
      }
    }
    
    module.exports = Router;
    

     

    라우터 객체가 생성되면 항상 hash 변화를 인지하여 특정 페이지를 렌더링 해주어야 하는데요,

    이를 수행해주는 web API인 onhashchange 를 이용하여 변경될 때의 로직을 처리할 수 있습니다.

     

    그렇다면 변경이 된 이후에 해당 페이지를 가져와서 그려줘야하는 로직이 추가가 되어야겠죠?

    그 전에 hash가 변경되면 수행을 제대로 하는지 확인해보겠습니다.

     

    index.js

    const Router = require('./Router');
    
    const router = new Router();
    

    라우터 객체를 가져와 생성 해준 후 빌드한(번들링 된) html을 실행해줍니다.

     

     

     

    #를 선언하고 아무 주소를 입력하면 콘솔이 잘 찍히는 것을 확인했습니다!! 😀

     

    그러면 이제 본격적으로 변경시 해당 페이지를 렌더링해주는 로직을 작성해보겠습니다.

    코드로 다시 돌아가서..

     

    class Router {
      constructor() {
        window.onhashchange = () => {
          console.log('변경되면 나온다.');
        };
      }
    }
    
    module.exports = Router;
    

    이제 변경된 hash의 정보를 가져와서 그에 맞는 페이지를 찾아야하겠죠?

    이 때 사용되는 api가 바로 window.location.hash입니다. 변경된 hash를 감지하여 해당 정보를 #을 포함하여 가져오는 역할을 수행해 주는데요, 이 친구를 이용해서 현재 페이지의 path를 알아내보겠습니다.

     

    class Router {
      nowPage = '';
      
      constructor() {
        window.onhashchange = () => {
          this.nowPage = window.location.hash;
        };
      }
    }
    
    module.exports = Router;
    

    nowPage라는 변수를 추가하여 변경된 hash 주소를 담아 객체에 저장하였습니다.

    그렇다면 nowPage를 콘솔로 출력해보면 어떤 값이 담길까요?

     

    주소창에 #main으로 요청했을 때 #까지 포함이 되서 나오는 것을 확인할 수 있습니다.

    페이지의 path를 선언할 때 #까지 함께 선언하면 상관 없지만 보통은 페이지의 경로만 선언하기 때문에 이 #을 공백으로 만들어 순수 path값만 얻도록 하겠습니다.

     

    class Router {
      nowPage = '';
      
      constructor() {
        window.onhashchange = () => {
          this.nowPage = window.location.hash.replace('#', '');
        };
      }
    }
    
    module.exports = Router;
    

    replace를 통해 #을 공백으로 만들었습니다. 결과는..!?

     

    오오오.. 원하는대로 출력이 되었습니다!!! 👍

     

    자 이제 이 정보를 가지고 무엇을 해야할까요?

    .

    .

    .

     

    바로 이 요청한 path를 이용해 맞는 페이지를 찾아내야합니다.

    그렇기 위해선 현재 애플리케이션에 가지고 있는 페이지들을 라우터는 알아야하겠죠?

    이 부분이 첫 번째 고려사항입니다.

     

    class Router {
      nowPage = '';
      
      constructor({ pages }) {
        window.onhashchange = () => {
          this.pages = pages;
          this.nowPage = window.location.hash.replace('#', '');
        };
      }
    }
    
    module.exports = Router;
    

    라우터 객체를 생성할 때 가지고 있는 페이지들을 받아내 라우터 내에서 다룰 수 있게 해줍니다.

    이전에 만든 MainPage와 OtherPage 객체가 있었죠?

    그 객체들을 담아주겠습니다.

     

    index.js

    const MainPage = require('./pages/MainPage');
    const OtherPage = require('./pages/OtherPage');
    const Router = require('./Router');
    
    const pages = [
      { page: MainPage, path: 'main' },
      { page: OtherPage, path: 'other' },
    ];
    
    const router = new Router({ pages });

    페이지들을 가져온 후 pages라는 배열에 해당 페이지와 path를 객체로 라우터 파라미터에 넘겨주었습니다.

    이렇게 되면 라우터는 해당 페이지의 정보들을 배열 형태로 가지고 있을 것입니다.

     

    이제 이 페이지들을 가지고 원하는 페이지를 찾아보겠습니다.

     

    class Router {
      nowPage = '';
      
      constructor({ pages }) {
        window.onhashchange = () => {
          this.pages = pages;
          this.nowPage = window.location.hash.replace('#', '');
          
          const page = this.pages.find((page) => page.path === this.nowPage);
        };
      }
    }
    
    module.exports = Router;
    

    find를 사용해서 가져온 페이지의 path와 현재 hash를 담은 nowPage를 활용해 동일한 객체를 찾아냈습니다.

    이렇게 찾은 객체를 page만 가져와야겠죠? 그 이유는 현재 페이지를 찾은 것이 아닌 pages에 있는 객체를 찾은 것이기 때문입니다.

     

    class Router {
      nowPage = '';
      
      constructor({ pages }) {
        window.onhashchange = () => {
          this.pages = pages;
          this.nowPage = window.location.hash.replace('#', '');
          
          const page = this.pages.find((page) => page.path === this.nowPage);
          const Page = page.page;
          const currentPage = new Page();
        };
      }
    }
    
    module.exports = Router;
    

    이렇게 해준다면 찾은 객체의 page를 Page에 담았고 해당 객체를 생성하면 우리가 원하는 페이지의 객체가 생성될 것입니다.

    한 번 확인해 볼까요?

     

    #main을 입력 했을 때 잘 가져오게 됩니다!! 😄

     

    자 이제 원하는 페이지 객체를 가져왔으니 우리가 만들었던 페이지의 render 함수를 통해 화면에 그려줄 일만 남았습니다.

    그렇다면 index.html에 있던 app의 div를 가져와야겠죠?

     

    class Router {
      nowPage = '';
      
      constructor({ pages }) {
        this.app = document.getElementById('app');
      
        window.onhashchange = () => {
          this.pages = pages;
          this.nowPage = window.location.hash.replace('#', '');
          
          const page = this.pages.find((page) => page.path === this.nowPage);
          const Page = page.page;
          const currentPage = new Page();
          
          this.app.innerHTML += currentPage.render();
        };
      }
    }
    
    module.exports = Router;
    

    최초 라우터 생성시 app에 접근하여 사용할 수 있게 가져왔습니다.

    그리고 페이지의 render를 담아주었죠 그리고 #main을 입력하여 실행해보면

     

    짜잔!!!

    드디어 화면에 메인 페이지가 보여졌습니다!!

     

    여기서 뭔가 우린 이상함을 느낄 것입니다...

    아니... 후... 저 버튼 어떡하지... 저거 누르면 다른 페이지로 가야하는데...

     

     

    그래서 우린 push라는 함수를 만들어 버튼을 클릭 했을 때 hash를 이동시키도록 해보겠습니다.

     

    class Router {
      nowPage = '';
      
      constructor({ pages }) {
        this.app = document.getElementById('app');
      
        window.onhashchange = () => {
          this.pages = pages;
          this.nowPage = window.location.hash.replace('#', '');
          
          const page = this.pages.find((page) => page.path === this.nowPage);
          const Page = page.page;
          const currentPage = new Page();
          
          this.app.innerHTML += currentPage.render();
        };
      }
      
      push(pageName) {
        window.location.hash = pageName;
      }
      
    }
    
    module.exports = Router;
    

    이렇게 되면 클릭 한 후 버튼을 누를 때 특정 hash를 선언해주면, 해당 정보를 받아 nowPage가 갱신이 되고 위와 동일한 로직을 수행할 것입니다.

     

    이제는 렌더링 된 버튼에 router의 push를 수행하는 이벤트를 추가해야겠죠?

     

    웹 브라우저의 라이프사이클을 생각해본다면, 우린 렌더링 된 이후 마운트하는 과정을 통해 이벤트들을 등록해야합니다.

    즉, render 이후 해당 DOM에 접근하여 이벤트를 선언해주면 될 것입니다.

     

    각각의 페이지에 이벤트를 선언하겠습니다.

     

    MainPage.js

    class MainPage {
      constructor({ router }) {
        this.router = router;
      }
    
      mount() {
        const button = document.querySelector('.main-button');
        button.addEventListener('click', () => {
          this.router.push('other');
        });
      }
    
      render() {
        return `<span>메인 페이지</span>
                <button type="button" class="main-button">Other Page</button>
                `;
      }
    }
    
    module.exports = MainPage;
    

    button을 가져와서 router의 push를 통해 다른 페이지의 path를 선언합니다.

     

    OtherPage.js

    class OtherPage {
      constructor({ router }) {
        this.router = router;
      }
    
      mount() {
        const button = document.querySelector('.other-button');
        button.addEventListener('click', () => {
          this.router.push('main');
        });
      }
    
      render() {
        return `<span>다른 페이지</span>
                <button type="button" class="other-button">Main Page</button>
                `;
      }
    }
    
    module.exports = OtherPage;
    

    마찬가지로 button을 가져와 메인 페이지의 path를 선언합니다.

     

    이렇게 mount 함수를 만들어 주었다면 렌더 이후에 수행되어야 하니 라우터에서는 해당 함수를 추가해주면 되겠죠?

     

    class Router {
      nowPage = '';
    
      constructor({ pages }) {
        this.app = document.getElementById('app');
    
        window.onhashchange = () => {
          this.pages = pages;
          this.nowPage = window.location.hash.replace('#', '');
    
          const page = this.pages.find((page) => page.path === this.nowPage);
          const Page = page.page;
          const currentPage = new Page({ router: this });
    
          this.app.innerHTML += currentPage.render();
          currentPage.mount();
        };
      }
    
      push(pageName) {
        window.location.hash = pageName;
      }
    }
    
    module.exports = Router;
    

    바뀐건 페이지를 생성할 때 router 객체를 넣어준 것과 렌더 이후 마운트를 해주었습니다. 

     

    이젠 버튼이 작동하겠죠?

    확인해보겠습니다.

     

    !?!?!?!!??

     

    우리는 SPA를 구현해보고 있는데 새로운 페이지로 옮기면서 기존에 있는 페이지를 비우지 않아서 이런 문제가 발생했네요!

     

    그렇다면 새로운 페이지를 push후 다시 그려질 때 한 번 비우는 과정이 필요할 것 같습니다.

     

    class Router {
      nowPage = '';
    
      constructor({ pages }) {
        this.app = document.getElementById('app');
    
        window.onhashchange = () => {
          this.pages = pages;
          this.nowPage = window.location.hash.replace('#', '');
    
          const page = this.pages.find((page) => page.path === this.nowPage);
          const Page = page.page;
          const currentPage = new Page({ router: this });
          this.app.innerHTML = '';
          this.app.innerHTML += currentPage.render();
          currentPage.mount();
        };
      }
    
      push(pageName) {
        window.location.hash = pageName;
      }
    }
    
    module.exports = Router;
    

    페이지를 렌더링 하기 전 싹 비워준 후에 그려주면 이상없을 것 같네요

    과연 잘 작동 할까요?

    .

    .

    .

    .

    .

     

    이상없이 잘 작동 하네요!

     

    보면 버튼을 누르면 hash가 바뀌면서 app 이 전체적으로 엎어졌다가 다시 그려지는 것을 확인할 수 있습니다.👍

     

     

    이렇게 간단한 라우터를 만들어보면서 hash를 사용한 SPA가 어떤식으로 만들어지는지 공부해보았는데요! 사실 이러한 기능을 하는 History API가 존재합니다. push 뿐만 아니라 replace와 같은 기능을 제공하고 있는데요 최근에는 이 API를 활용하여 SPA를 구현하기도 합니다.

    댓글