Vanilla javascript URL Router 만들기 (web components)

Web Component를 주로 이용하게 되는 이유는 Single Page Application을 만들기 위한 용도이다.

사용자는 Page가 변경한다고 생각하지만 Page는 변경되고 있지 않다.

그러다 보니 사용자들에게 익숙한 Browser Navigation Button을 활용하기 어렵다.

이 부분을 해결하기 위해서 windows.history 객체를 통해서 browser url search bar에 url router를 만들어 보고자 한다.

우리가 만들어볼 페이지는 아래와 같다.

최초 접근시에는 root path를 기본으로 하고 각 box를 클릭하면, 

/page/ 디렉토리 밑에 각 box별 numbering 0에서 부터 3번까지 page를 통해서 page 전환 효과를 나타낼 예정이다.

그리고 back버튼을 누르면 페이지가 뒤로 가도록 하는 것이다.

물론 forward 버튼을 누르면 페이지가 앞으로 갈것이다.

다음 url에서 최종 화면을 확인할 수 있다.

https://theyoung.github.io/VanillaUrlRouterSample/

참고하자.

 

Component 디자인하기

기본적으로 Component 디자인은

2021.08.16 - [Web] - 기존 web site를 components 로 다시 만들기 (No State management)

이를 통해서 이해하고 있다는 전제로 이야기를 풀어나가겠다.

4개의 box가 있고 click하면 box가 커지면서 image를 표현하는 html이다.

-/root
 |-index.html
 |-MainApp.js
 |-style.css
 |-/components
   |-PageZero.js
   |-PageOne.js
   |-PageTwo.js
   |-PageThree.js

위와 같은 디렉토리 구조를 갖고 있다.

 

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="style.css">
    <title>History Management</title>
</head>
<body>
    <main-app></main-app>

    <script type="module" src="MainApp.js"></script>
</body>
</html>

main-app은 4개의 page(boxes)가 위치할 사실상 root tag가 된다.

아래가 우리가 만들 main-app의 최종 모습이다.

 

MainApp.js

import PageOne from "./components/PageOne.js";
import PageTwo from "./components/PageTwo.js";
import PageThree from "./components/PageThree.js";
import PageZero from "./components/PageZero.js";

export default class MainApp extends HTMLElement {
    constructor(){
        super();
        this.pages = new Array();
        this.index = -1;
        this.pathname = new URL(window.location.href).pathname;
        this.render();
        this.router();
    }

    render(){
        this.innerHTML = this.getTemplate();
    }

    getTemplate(){
        return `
            <main>
                <page-zero id='page0' class="page"></page-zero>
                <page-one id='page1' class="page"></page-one>
                <page-two id='page2' class="page"></page-two>
                <page-three id='page3' class="page"></page-three>
            </main>
        `;
    }

    router(idx, evt){
        if(!evt){
            history.replaceState({},'Home',this.pathname+'root/');
            this.updateMain(-1);
        } else {
            history.pushState({idx:idx},'Home',this.pathname + `root/page/${idx}`);
            this.updateMain(idx);
        }
    }

    updateMain(idx){
        if(this.index === idx) return;
        this.index = idx;

        this.pages.forEach(page=> {
            page.classList.remove('expand');
            page.updatedClassList();
        });
        if(0 <= idx){
            this.pages[idx].classList.add('expand');
            this.pages[idx].updatedClassList();
        }
    }

    moveBack(nav){
        if(0 <= nav.state.idx){
            this.updateMain(nav.state.idx);
        } else {
            this.updateMain(-1);
        }
    }

    connectedCallback(){
        this.pages = this.querySelectorAll('.page');
        this.pages.forEach((page,idx)=>{
            page.addEventListener('click',this.router.bind(this,idx));
        })

        window.onpopstate = this.moveBack.bind(this);
    }

    disconnectedCallback(){

    }
};


customElements.get('main-app')?? customElements.define('main-app',MainApp);

Main App은 아래와 같이 4개의 Page를 갖는다.

            <main>
                <page-zero id='page0' class="page"></page-zero>
                <page-one id='page1' class="page"></page-one>
                <page-two id='page2' class="page"></page-two>
                <page-three id='page3' class="page"></page-three>
            </main>

각 페이지는 미리 main에 정의 되어있는 형식이다.

여기서 주의해서 봐야할 것이있다.

미리 Loading 해 놓을 것인가? 필요할 때 Loading할 것인가?

여기서는 미리 Loading해 놓는 방식을 취하고 있다. 필요할 때 Loading하는 방식은 SSR(server side rendering)에 조금더 맞는 형태라고 생각하고, 실제로 이와 같은 화면이 필요할 경우 SSR을 통해서 처리하는 것을 고려하자.

  • 미리 4개의 Page를 Loading해 놓고 필요할 때 각 Page의 view를 보여주는 형태로 진행 하고자 한다.

각 페이지별 소스를 작성하자.

 

PageOne.js

export default class PageOne extends HTMLElement {
    constructor(){
        super();
        this.render();
    }

    render(){
        this.innerHTML = this.classList.contains('expand') ? this.getTemplate() : this.innerHTML = "";

    }

    getTemplate(){
        return `
            <img src="https://cdn.pixabay.com/photo/2021/08/30/16/28/dewdrops-6586339_960_720.jpg"></img>
        `;
    }
    updatedClassList(){
        console.log('class upldated');
        this.render();
    }
    connectedCallback(){

    }

    disconnectedCallback(){

    }
};

customElements.get('page-one')?? customElements.define('page-one',PageOne);

PageTwo.js

export default class PageTwo extends HTMLElement {
    constructor(){
        super();
        this.render();
    }

    render(){
        this.innerHTML = this.classList.contains('expand') ? this.getTemplate() : this.innerHTML = "";

    }

    getTemplate(){
        return `
            <img src="https://cdn.pixabay.com/photo/2021/08/24/15/38/sand-6570980_960_720.jpg"></img>
        `;
    }
    updatedClassList(){
        console.log('class upldated');
        this.render();
    }
    connectedCallback(){

    }

    disconnectedCallback(){

    }
};

customElements.get('page-two')?? customElements.define('page-two',PageTwo);

PageThree.js

export default class PageThree extends HTMLElement {
    constructor(){
        super();
        this.render();
    }

    render(){
        this.innerHTML = this.classList.contains('expand') ? this.getTemplate() : this.innerHTML = "";
    }

    getTemplate(){
        return `
            <img src="https://cdn.pixabay.com/photo/2021/08/25/17/22/flowers-6574079_960_720.jpg"></img>
        `;
    }
    updatedClassList(){
        console.log('class upldated');
        this.render();
    }
    
    connectedCallback(){

    }

    disconnectedCallback(){

    }
};

customElements.get('page-three')?? customElements.define('page-three',PageThree);

PageZero.js

export default class PageZero extends HTMLElement {
    constructor(){
        super();
        this.render();
    }

    render(){
        this.innerHTML = this.classList.contains('expand') ? this.getTemplate() : this.innerHTML = "";

    }

    getTemplate(){
        return `
            <img src="https://cdn.pixabay.com/photo/2021/08/27/10/16/baby-6578335_960_720.jpg"></img>
        `;
    }

    updatedClassList(){
        console.log('class upldated');
        this.render();
    }

    connectedCallback(){
    }

    disconnectedCallback(){

    }
};

customElements.get('page-zero')?? customElements.define('page-zero',PageZero);

각 페이지의 소스는 모두 동일한 형태를 지녔다.

달라지는 점은 딱하나 image tag에서 보여줄 이미지의 url만 다르다.

단지 주의해서 봐야할 것은 이부분이다.

    render(){
        this.innerHTML = this.classList.contains('expand') ? this.getTemplate() : this.innerHTML = "";

    }

현재의 tag가 화면에 표현되고 있는가?

expand라고 하는 style class가 있으면 화면을 점유하고 있는 형태로 디자인이 되었다.

이부분은 답이 없다. 어떻게 만들어 갈지는 어떤 형태의 디자인이 오는가에 따라서 달라진다.

    updatedClassList(){
        console.log('class upldated');
        this.render();
    }

 Page자체가 화면에서 사라지지 않고 남아 있으면서 언제 Contents를 보여줄것인가? 가 주요 개발 포인트이다.

그래서 각 Page가 expand라고하는 class 추가를 통해서 보여지게 됨으로 classList가 변화를 할때마다 render를 실행하는 방식이다.

과거에는 classList가 update될때 event로 capture했었는데, 최근에 deprecated되었다고 한다.

참고로 MutationOberver로 확인하기에도 Overhead가 너무 크다고 판단했다.

아무튼 이와같이 components디자인을 간단히 끝을 냈다. 이제 URL을 어떻게 관리할지 알아보자.

 

최초 Router 개발

MainApp.js 가 실행되는 시점에 우리는 url의 형태를 알수 있는가?

  • http://localhost:3040/index.html
  • http://github.com:999/theyoung/index.html 

등등

해당 index.html이 어떤 root path를 갖고 실행하게 될지 알 수 없다.

단지 우리고 서비스를 만들면서 디자인 할 수 있는 부분은

  1. App이 실행되면 'root/' path로 시작한다
  2. Page는 'root' path이하 'page/' + 'index'로 표시된다.

이다.

이와 같은 설계를 반영하기 위해서는 MainApp에 최초 page로가 되었을 경우 pathname을 알아내야 한다.

export default class MainApp extends HTMLElement {
    constructor(){
...
        this.pathname = new URL(window.location.href).pathname;
...
    }

이 pathname은 main-app tag가 page reload되기 전까지 동일한 값을 유지한다.

예를 들자면

  • http://github.com:999/theyoung/index.html 

이런 url로 시작이 되었을 경우

'http://github.com:999/theyoung/' 여기까지가 pathname이 된다.

여기에 최초 실행은 root로 실행 되어야 함으로

  • http://github.com:999/theyoung/root/

로 나와야 한다.

    router(){
            history.replaceState({},'Home',this.pathname+'root/');
            this.updateMain(-1);
    }

이를 위해서 constructor이후 router를 실행 시키면, 최초 실행임으로 무조건 root를 붙여서. replaceState하는 것을 볼 수 있다.

  • replaceState : history의 마지막 State를 수정한다.

아래의 url이 최초에 왔을 때, 해당 url은 불필요 함으로 강제 rewirting한다고 생각하면 된다.

  • 원본 : http://github.com:999/theyoung/index.html 
  • 수정본 : http://github.com:999/theyoung/root/

이 경우는 history stack을 증가시키지 않는다.

여기까지의 화면이 아래와 같다.

 

PushState

이제 각 box를 click했을 때 어떻게 url을 변화 시킬지 생각해 보자.

최초 url에 + /page/ 와 + 'index'를 더한 url을 넣어 줘야 한다.

  • http://github.com:999/theyoung/root/

이와 같은 화면에서 box 0번을 클릭했을 경우

  •  http://github.com:999/theyoung/root/page/0

이 되어야 하는 것이다.

이를 위해서 각 box별로 click event를 걸어주자.

MainApp.js

    connectedCallback(){
        this.pages = this.querySelectorAll('.page');
        this.pages.forEach((page,idx)=>{
            page.addEventListener('click',this.router.bind(this,idx));
        })
    }

page class tag를 모두 읽어와서 click event를 설정해 준다.

click이 일어날 경우 router를 실행 시킨다.

router는 click된 index를 파마메터로 받아서 pushState를 통해서 url을 변화시켜 주면된다.

    router(idx, evt){
        if(!evt){
            history.replaceState({},'Home',this.pathname+'root/');
            this.updateMain(-1);
        } else {
            history.pushState({idx:idx},'Home',this.pathname + `root/page/${idx}`);
            this.updateMain(idx);
        }
    }

위에서 evt(event)가 나온이유는 click을 통해 event로 실행되는 경우와 최초 event없이 실행 되는 경우를 구분하기 위해 넣어줬다.

이번에는 최초실행과 달리 evt가 발생함으로 

        } else {
            history.pushState({idx:idx},'Home',this.pathname + `root/page/${idx}`);
            this.updateMain(idx);
        }

이와 같이 처리가 가능하다.

pushState에 대해서 잠깐 설명을 하자면,

  • 첫번째 파라메터 : 각 State를 대표하면 Status 값을 유지한다. 페이지별로 하나라고 생각하면 된다.
  • 두번재 파라메터 : 페이지 타이틀이다. 사용되는 브라우저없다. (21년기준으로)
  • 세번째 파라메터 : url을 변형시킬 url이 된다.

세번재 파라메터에서 this.pathname은 최초 실행될때 root path를 유지하기 위해서 넣어 놓았다.

참고로 3번째 파라메터의 String 시작이
'/' <-- 이걸로 시작하면 url host(http://localhost:3030/ <- 이렇게 생긴부분까지만 host임)이후를 모두 교체한다.
'abc/'이렇게 시작되면, 'http://localhost:3030/test' 가 'http://localhost:3030/test/abc/'
이런식으로 host + pathname + 추가 pathname을 붙힌 url이 만들어지게 된다.

pushState를 하게 되면 history.length가 늘어나게 된다.

Stack이라고 생각하면 된다.

단, 다른점은 popState가 되면 history.length가 줄어들어야 하는데 그렇지 않다. 그래서 history.length는 프로그램 작성시 사용하지 말자. 신뢰할 수 없는 값이다.

마지막으로 첫번째 파라메터는 반듯이 작성 되어야 한다.

            history.pushState({idx:idx},'Home',this.pathname + `root/page/${idx}`);

여기에서는 page에 변화에 따른 page view의 형태 변화가 index로만 변경이 됨으로 해당 url이었을때 index를 유지함으로써 아래 onpopstate 즉 navigation의 변화시 어떻게 화면을 구성할지에 대한 guide data가 된다.

 

onpopstate

위에까지는 pushState를 통해서 어떻게 stack에 url을 쌓는지 알아 보았다.

이제 우리가 목표로 하는 back 버튼을 눌리웠을때 어떻게 작동해야 하는지 알아보겠다.

    connectedCallback(){
        this.pages = this.querySelectorAll('.page');
        this.pages.forEach((page,idx)=>{
            page.addEventListener('click',this.router.bind(this,idx));
        })

        window.onpopstate = this.moveBack.bind(this);
    }

window.onpopstate를 method bind한 것을 확인할 수 있다.

이제부터 back버튼 혹은 forward버튼이 눌리워지면 무조건 this.moveBack이라는 method 가 불리게 된다.

    moveBack(nav){
        if(0 <= nav.state.idx){
            this.updateMain(nav.state.idx);
        } else {
            this.updateMain(-1);
        }
    }

moveBack은 현재 state에 들어있는 값을 바탕으로 화면을 재 구성하는 행위를 한다.

앞서서 pushState 첫번째 파라메터를 꼭 넣으라고 한 이유가 이것이다.

이를통해 현재 navigation이 바라는 화면의 모습을 유추할 수 있다.

    updateMain(idx){
        if(this.index === idx) return;
        this.index = idx;

        this.pages.forEach(page=> {
            page.classList.remove('expand');
            page.updatedClassList();
        });
        if(0 <= idx){
            this.pages[idx].classList.add('expand');
            this.pages[idx].updatedClassList();
        }
    }

선택된 index의 page에 expand라고 하는 선택 class를 넣어주고, 해당 page에 updatedClassList() method를 call해 줌으로써 페이지의 변화를 다시 일어나게 한다.

여기서 절대 중요한 것은

  • BackState함수에 pushState나 replaceState를 하는 행위를 지양해야 한다.

한번 url 내용이 꼬이기 시작하면 답이 없다.

 

분명 간단한 내용이지만 SPA를 개발하는 사람이라면 누구나 url routing이 꼬임으로써 지옥을 경험했을 꺼라 생각해서 설명을 남겨보았다.

 

혹시나 해서 한가지더...

url을 받아내는 방법은

  • server에서 request mapping을 통해서
  • client에서 pathname 및 param 분석으로 ui를 그리는 방법을 통해서

이렇게 2가지가 있는데, server에서 request mapping을 통하는 방법의 경우는 client에서 할수 있는 일이 많지 않다.

예를 들어서 

  • https://theyoung.github.io/VanillaUrlRouterSample/root/

를 url에 사용자가 직접 넣으면 오류가 날것이다. 해당 서비스 접근을 통해서 해당 url에 화면이 잘 나오는 것을 분명히 확인했는데 어떤 차이일까?

그것은 해당 url이 server에 request를 했느냐의 차이가 된다.

위의 url을 navigation bar에 직접 넣게 되면 서버에 해당 url을 요청하게 되는데, 서버에서는 해당 url에 대한 정보가 없음으로 page not found가 나게된다.

그래서 SPA기반 개발은 많은 request mapping을 index.html을 바라보게 하고 있다. 

client side에서 해당 url을 parsing해서 필요한 화면을 보여주기 때문이다.

위의 github page는 그런 기능이 없음으로 직접 입력은 오류를 발생 시킨다.

 

소스는

https://github.com/theyoung/VanillaUrlRouterSample

참고하세요~

728x90
반응형