[Vanilla JS Project (1)] SPA 라우터 구현하기

쥬롬의 코드착즙기·2023년 1월 12일
2
post-thumbnail

본 포스팅은 Youtube dcode - Build a Single Page Application with JavaScript (No Frameworks)의 내용을 바탕으로 작성되었습니다.
영상의 모든 내용이 글에 포함되어 있지는 않습니다. 전체 코드는 출처 영상을 시청해주세요!

React, Vue, Angular 등의 프레임워크/라이브러리 없이 라우터를 구현해 보자.
react-router 등의 편리함에 가려 보지 못했던 라우팅의 원리를 이해할 수 있다.

0. 라우팅?

라우팅은 각 페이지들이 url에 따라 하나의 페이지 위에 선택된 데이터를 보여주는 것이다. 예를 들어 base url을 https://myurl 이라고 했을 때 위 세 개의 페이지를 왼쪽부터 각각 https://myurl/edit, https://myurl/list, https://myurl/show 에 접속하면 보이게 만드는 것이다.

1. 파일 구조

먼저 파일 구조를 훑어보자.

  • client
    클라이언트 사이드에 필요한 파일이다.
  • static
    라우팅을 통해 정적으로 제공될 파일이다.
  • js
    메인 js파일(index.js)이 들어가며 여기에서 라우팅이 일어난다.
  • views
    컴포넌트들이 포함된다. index.html의 일정 태그 안 요소들을 바꾸어 화면 일부만 리렌더링되도록 한다.
    - AbstractView.js : 모든 컴포넌트를 상속하는 부모 클래스이다. 컴포넌트의 사용 방식을 정의한다.
    - Edit.js / List.js / Show.js : AbstractView.js를 상속받는 자식 클래스이다. 실제로 index.html 안에 제공되는 요소를 return한다.
  • index.html
    SPA의 기본 html파일이다. 이 html 안의 일정 태그를 변화시켜 페이지 안의 내용을 바꾼다.
  • server.js
    로컬환경에서 구동할 수 있도록 만들어진 서버이다.

2. 라우팅 구현해보기

1. server.js

nodejs를 통해 간단한 서버를 만들어보자.
콘솔창에서 npm init으로 package.json을 만들어 주고, 정적 파일을 제공하기 위해 npm i express로 express를 추가한다.

server.js

const express = require("express");
const path = require("path");
const app = express();

app.get("/*", (req, res)=>{
    res.sendFile(path.resolve(__dirname, "client", "index.html"));
})

app.listen(4000, ()=>console.log("server on"));

각 줄의 의미를 살펴보자.

app.get("/*", (req, res)=>{
    res.sendFile(path.resolve(__dirname, "client", "index.html"));
})

모든 url에 대해 get 요청을 받으면, file을 보내준다. file의 주소는 sendFile의 파라미터로 전달된다.
즉, 주소를 http://localhost:4000 로 해도, http://localhost:4000/hihi 로 해도 동일한 index.html이 주어지는 것이다. 디폴트 페이지를 index.html로 하고 있는 것이라고 이해하면 된다.

app.listen(4000, ()=>console.log("server on"));

4000번 포트에 서버를 띄운다.

2. index.html에서 index.js 불러오기

여기까지 하고 콘솔창에서 node server.js로 서버를 시작시키면 localhost:4000에서 index.html을 확인할 수 있다.
중요한 것은 index.html에서 js 파일을 읽어들이도록 하는 것이다.
html의 body 태그 안쪽 가장 아랫줄에서 script로 index.js를 포함해 준다.
index.html

    <script type="module" src="./static/js/index.js"></script> 	

테스트를 위해 index.js에서 console.log를 찍어주자.
index.js

	console.log("this is index.js")

그러나 아래와 같은 에러가 뜰 것이다.

해석해 보면, 자바스크립트 모듈이 들어올 자리에 html 파일이 들어왔다는 말이다. 어떻게 된 일일까?

server.js

app.get("/*", (req, res)=>{
    res.sendFile(path.resolve(__dirname, "client", "index.html"));
})

server.js에서 모든 경로("/*")에서 get 요청시 index.html 파일을 보내주게 했다. 따라서 위 src에도 index.html이 들어간다.
우리가 원하는 것은 "./static/js/index.js"를 가져오는 것이다. server.js에서 /static경로에 맞는 행동을 취할 수 있게 아래 문장을 추가해주자.

//전략
const app = express();

app.use("/static", express.static(path.resolve(__dirname, "client", "static")));

app.get("/*", (req, res)=>{
//후략

express 라이브러리 안에는 static이라는 메소드가 포함되어 있다. 이 메소드의 파라미터로 전달되는 것은 디렉터리명이다.
path.resolve(__dirname, "client", "static")는 ./client/static를 대신한다. 단 os에 따라 차이가 있는 구분자(윈도우는 슬래시'/', 맥은 역슬래시'\')때문에 발생할 수 있는 에러를 방지해준다.

3. index.js에서 라우팅

먼저 라우팅을 담당하는 함수를 살펴보자.

import List from "../views/List.js";
import Edit from "../views/Edit.js";
import Show from "../views/Show.js";

const router = async()=>{
    const routes=[
        {path: "/list", view: List},
        {path: "/edit", view: Edit},
        {path: "/show", view: Show},
    ];

    const potentialMatches = routes.map(route=>{
        return {
            route: route,
            isMatch: location.pathname===route.path,
        }
    });

    let match = potentialMatches.find(potentialMatch => potentialMatch.isMatch);

    //default route
    if(!match){
        match = {
            route: routes[0],
            isMatch: true,
        };
    };
    
    const view = new match.route.view();

    document.querySelector("#App").innerHTML = await view.getHtml();
};

의미 단위로 끊어서 살펴보자.

 const routes=[
        {path: "/list", view: List},
        {path: "/edit", view: Edit},
        {path: "/show", view: Show},
    ];

routes는 라우팅 경로와, 경로에 해당하는 컴포넌트를 저장하고 있는 객체이다. List, Edit, Show는 파일 구조 중 views(컴포넌트) 디렉터리에서 가져온다.

const potentialMatches = routes.map(route=>{
        return {
            route: route,
            isMatch: location.pathname===route.path,
        }
    });
let match = potentialMatches.find(potentialMatch => potentialMatch.isMatch);

potentialMatches는 현재 주소와 routes 객체의 주소가 일치하는지를 확인해준다.
location.pathname는 현재 주소의 baseurl을 제외한 부분이다. 만약 주소가 http://localhost:4000/abcd/efg 이면 localhost.pathname은 /abcd/efg이다.
따라서 match는 routes의 객체 중 현재 url과 일치하는 것을 저장하게 된다.


    //default route
    if(!match){
        match = {
            route: routes[0],
            isMatch: true,
        };
    };

만약 url주소가 일치하는 것이 없을 때, 즉 위 경우에서 /list, /edit, /show가 아닌 다른 주소가 들어왔을 때는 기본값으로 /list 페이지로 들어갈 수 있도록 해준다.

    const view = new match.route.view();

    document.querySelector("#App").innerHTML = await view.getHtml();

view는 컴포넌트를 만들어준다. view 객체는 바로 다음에 살펴볼 것이다. getHtml 메소드는 해당 route에 맞는 html을 반환해주는 것이다.

지금까지 route() 함수를 작성했다. 그런데 이 함수는 화면의 특정 부분 클릭을 조건으로 이루어진다.

위 페이지로 예를 들면 가운데 페이지의 '새 글 작성하기'를 클릭하면 왼쪽 페이지로, 가운데 페이지의 글 중 하나를 클릭하면 오른쪽 페이지로 이동되어야 한다.

클릭 이벤트를 감지하고 라우터 함수를 실행시켜주자.

document.addEventListener("DOMContentLoaded",()=>{
    document.body.addEventListener("click",e=>{
        if (e.target.matches("[data-link]")){
            navigateTo(e.target.dataset.link);
        }
    })
})

이 data-link는 html 속성으로 들어간다. 예를 들어, 새 글 작성하기 버튼은 data-link 속성을 통해 edit 페이지로 이동할 것을 명시해 준다. '새 글 작성하기' 버튼의 html은 아래와 같다.

<button id="ListAddBtn" data-link="edit">새 글 작성하기</button>

route()를 바로 실행하지 않고 navigateTo()로 한번 더 감싸준 이유는 라우팅이 history api를 통해 실행되기 때문이다.

history API는 브라우저의 세션 기록을 저장할 수 있는 메소드를 담고 있는 객체이다. 페이지 이동, 뒤로가기, 앞으로 가기 등의 조작을 가능하게 해준다. url이동만 해주고 화면을 리렌더링해주지는 않는다.

navigateTo에서 history API를 어떻게 사용하는지 살펴보자.

const navigateTo = url =>{
    history.pushState(null, null, url);
    router();
};

pushState의 파라미터는 다음과 같다.

pushState(state, title, url);
  • state = 상태 값을 나타내는 것으로 브라우저에서 앞/ 뒤로 갈 때, 넘겨줄 데이터
  • title = 변경할 브라우저 제목 (변경을 원하지 않으면 null)
  • url = 변경할 브라우저 URL
    pushState()에서는 url만 바꿔 주도록 했기 때문에 state와 title 파라미터는 null로 했다. pushState()에 의해 경로가 바뀐 다음, router()함수 안에서 바뀐 경로에 맞는 컴포넌트를 찾아 html을 반환해주는 것이다.

4. 배운 점

1. history API를 이용한 라우팅

history API를 이용해 라우팅을 할 수 있게 됐다! 라우팅 방법을 세줄요약하면 아래와 같다.

  1. html 태그 (dataset 등)을 통해, html에서 route를 저장한다.
  2. js에서 html에 있는 route 값을 읽어와서 history.pushState로 route를 바꿔준다.
  3. 바뀐 route에 따라 맞는 컴포넌트를 반환한다.

2. 모듈화

폴더 구조 자체를 처음부터 만들어 보는 과정에서 기능별로 파일/디렉토리를 분할해서 관리하는 모듈화의 중요성을 알게 되었다. 특히 view 디렉토리에서 부모 클래스 AbstractView로 틀을 짜 주고, 그 틀에 맞게 모듈을 짤 수 있다는 것을 알게 됐다. 이것이 객체지향이다... 하는 느낌이었다. 여러사람이 함께 개발할 때 이렇게 부모클래스에서 미리 필요한 변수/함수를 정의해두고 그 틀에 맞춰 작업하면 협업할 때 편하겠구나 하고 생각했다.

3. map 함수 괄호 주의하기

index.js에서 map 함수를 통해 potentialMatches를 찾았다.

    const potentialMatches = routes.map(route=>{
        return {
            route: route,
            isMatch: location.pathname===route.path,
        }
    });

List.js에서도 map을 통해 모양이 같고 내용만 다른 컴포넌트를 찍어낸다.

    async getHtml(){
        const list = await this.getList();

        return (
        `<div id="List">
            <button id="ListAddBtn" data-link="edit">새 글 작성하기</button>
            ${list.map((elm, key)=>{
                  `<div id="ListItem" data-link="show">
                    <div id="ListItemImage" data-link="show">
                        <image src=${elm.image}></image>
                    </div>
                    <div id="ListItemTitle" data-link="show">${elm.title}</div>
                    <div id="ListItemContent" data-link="show">${elm.content}</div>
                </div>`
            })}
        </div>`
        );
    }

그런데 List.js에서는 map을 아무리 해도 div가 화면에 나타나지 않는 것이다...!!
뭐가 잘못된 건지 생각해보고 이리저리 고쳐도 봤는데 진짜 어이없는 이유 때문이었다.

map 함수에서 괄호의 차이: 중괄호 '{'는 return문을 포함할 때, 소괄호 '('는 return문이 필요없을 때 사용한다.

  • array.map(a)=>{ return }
  • array.map(a)=>( //does not have return )

진심100%로 열받았던 기억때문에 쓴다... 괄호만 고치면 된다.
index.js에서 map 함수를 통해 potentialMatches를 찾았다.

    const potentialMatches = routes.map(route=>{
        return {
            route: route,
            isMatch: location.pathname===route.path,
        }
    });

List.js에서도 map을 통해 모양이 같고 내용만 다른 컴포넌트를 찍어낸다.

    async getHtml(){
        const list = await this.getList();

        return (
        `<div id="List">
            <button id="ListAddBtn" data-link="edit">새 글 작성하기</button>
            ${list.map((elm, key)=>(
                  `<div id="ListItem" data-link="show">
                    <div id="ListItemImage" data-link="show">
                        <image src=${elm.image}></image>
                    </div>
                    <div id="ListItemTitle" data-link="show">${elm.title}</div>
                    <div id="ListItemContent" data-link="show">${elm.content}</div>
                </div>`
            ))}
        </div>`
        );
    }

꼼꼼하게... 내 모든 코드가 틀렸다고 생각하고 버그를 찾아내는 습관을 들여야겠다...

profile
코드를 짭니다...

0개의 댓글