Vanilla JavaScript로 SPA구현하기

Shim.SeongHo·2021년 9월 1일
34

[JavaScript]

목록 보기
15/15
post-thumbnail

Vanilla JavaScript로 SPA구현하기

본 포스트는
dcode - YOUTUBE
를 참고하였습니다.

React, Angular, Vue와 같은 프레임워크의 사용 없이 JavaScript만으로 SPA(Single Page Application)을 구현해보고자 한다.

history 또는 hash를 이용해서 SPA를 구현할 수 있는데 여기서는 history를 사용해서 만들어 보았다.

1. index.html 만들기

프로젝트 폴더에 frontend 폴더를 만들고 폴더 안에 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" />
        <title>Single Page App</title>
    </head>
    <body>
        <nav class="nav">
            <a href="/" class="nav_item" data-link>Home</a>
            <a href="/posts" class="nav_item" data-link>Posts</a>
            <a href="/settings" class="nav_item" data-link>Settings</a>
        </nav>
		<div id="root"></div>
        <script type="module" src="./static/js/index.js"></script>
    </body>
</html>

페이지 이동을 하기위해 3개의 a태그로 구성된 메뉴를 nav 로 감싸주었다.

그리고 ES6의 module을 사용하기 위해서 script 태그에 type=module을 추가했다.

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

2. express 서버 구축하기

우선 express를 사용해서 간단하게 서버를 구축하자.

먼저 node module을 관리해주기 위해 npm init 명령어를 터미널에 작성 후

express를 설치해준다.

npm install express

설치가 완료됐으면 프로젝트 폴더에 server.js 파일을 생성해서 서버를 만들어준다.

// server.js
// express server
// express 모듈 불러오기
const express = require("express");
const path = require("path");

// express 사용
const app = express();

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

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

// port 생성 서버 실행
app.listen(process.env.PORT || 3000, () => console.log("Server running ...."));

2-1. express에서 정적 파일 제공

이미지, CSS 파일, JavaScript 파일과 같은 정적 파일을 제공하기 위해서 express의 기본 미들웨어 함수인 express.static을 사용한다.

// server.js 의 실행경로 + "/static"을 localhost:port/static으로 마운트
app.use("/static", express.static(path.resolve(__dirname, "frontend", "static")));

path.resolve 를 사용해서 인자로 받은 값들을 하나의 문자열로 만들어 주고 정적 디렉토리에 대한 마운트 경로를 지정해 주면 /static 경로를 통해 frontend 디렉토리에 포함된 파일을 로드할 수 있게된다.

http://localhost:8080/static/js/index.js
http://localhost:8080/static/css/index.css

2-2. app.get으로 응답하기

get요청이 오면 frontend/index.html 파일을 읽고 내용을 클라이언트로 전송한다.

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

2-3. app.listen 포트 번호 지정하고 서버 실행하기

포트번호 3000에서 서버를 실행한다.

// port 생성 서버 실행
app.listen(process.env.PORT || 3000) => console.log("Server running ...."));

터미널에 다음과 같이 입력하면 서버를 실행할 수 있다.

node server.js

3. Router 구현하기

3-1. router 함수 구현

frontend 폴더 안에 static 폴더를 만들고 그 안에 다시 js 폴더를 만들고 !! js 폴더 안에 index.js 파일을 만들어주자.

이제 Home, Posts, Settings 페이지로 왔다갔다 할 수 있게끔 라우터를 구현해보자.

// frontend/static/js/index.js
const router = async () => {
    const routes = [
        { path: "/", view: () => console.log("Viewing Home") },
        { path: "/posts", view: () => console.log("Viewing Posts") },
        { path: "/settings", view: () => console.log("Viewing Settings") },
    ];
// ...

view 는 해당 경로에서 나타내는 html을 의미하는데 우선 작동이 잘 되는지 확인하기 위해서 콘솔로 먼저 확인한다.

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

map 을 이용해서 routespageMatches 에 담고 출력해보면 아래와 같이 나온다.

// Home 페이지 일 때
(3) [{}, {}, {}]
0: 
  isMatch: true
  route: {path: "/", view: ƒ} // view: () => console.log("Viewing Home")
1: 
  isMatch: false
  route: {path: "/posts", view: ƒ} // view: () => console.log("Viewing Posts")
2: 
  isMatch: false
  route: {path: "/settings", view: ƒ} // view: () => console.log("Viewing Settings")
// ...

다음은 isMatchtrue 일 때 path 값과 location.pathname 의 값이 같다면 해당 페이지를 보여주면 된다.(우선은 console.log)

find 메소드를 사용해서 구현한다.

Array.prototype.find()
find() 메서드는 주어진 판별 함수를 만족하는 첫 번째 요소의 값을 반환합니다. 그런 요소가 없다면 undefined를 반환합니다. -MDN-

// ...
let match = pageMatches.find((pageMatch) => pageMatch.isMatch);

console.log(match.route.view());

이렇게 만들면 pageMatch.isMatch 인 값, 즉 true 인 값의 view 함수를 실행한 Viewing ... 을 보여주게 될 것이다.

3-2. 이벤트 구현하기

다음에 해야 할 일은 페이지가 처음 로드됐을 때, 각각의 페이지들을 클릭할 때 router 함수를 실행시켜 해당되는 페이지에 대한 정보를 띄워주는 것이다.

HTML 이 모두 로드됐을 때 첫 페이지를 보여주기 위해서 DOMContentLoaded 를 사용할 것이다.

document.addEventListener("DOMContentLoaded", () => {
    document.body.addEventListener("click", (e) => {
        if (e.target.matches("[data-link]")) {
            e.preventDefault();
            history.pushState(null, null, e.target.href);
            router();
        }
    });
    router();
});

click 이벤트를 등록하고 data-link라는 속성(a 태그)이 있는 곳에서만 동작하도록 조건을 달아준다.

history.pushState 를 사용해서 url을 변경할 수 있게 만들어준다. 그리고 router 함수를 실행시켜 렌더링을 해주면된다.

여기까지 작성하고 실행해보면 다음과 같이 작동할 것이다.

하지만 한 가지 문제가 발생하는데 바로 뒤로가기 버튼이나 앞으로가기 버튼을 누를 때는 콘솔에 출력이 안 된다는 점이다.

그 이유는 body 에만 클릭 이벤트를 주었기 때문인데, popstate 이벤트를 사용해서 해결할 수 있다.

popstate 이벤트는 브라우저의 백 버튼이나 (history.back() 호출) 등을 통해서만 발생된다. 그리고 그 이벤트는 같은 document에서 두 히스토리 엔트리 간의 이동이 있을 때만 발생이 된다. -MDN-

아래와 같이 코드를 작성해주자.

// 뒤로 가기 할 때 데이터 나오게 하기 위함
window.addEventListener("popstate", () => {
    router();
});

popstate 이벤트가 발생할 때(뒤로가기, 앞으로가기 등) router 함수를 실행시켜서 해당하는 페이지가 렌더링 될 수 있도록 해준다.

4. View 구현하기

다음으로는 화면에 그려지는 각 페이지들을 만들어본다.

js 폴더에 pages 폴더를 만들고 Home.js 파일을 생성하고 다음과 같이 작성해준다.

// frontend/static/js/pages/Home.js
export default class {
    constructor() {
        document.title = "Home";
    }
    async getHtml() {
        return `
            <h1>This is Home Page</h1>
        `;
    }
}

제일 먼저 constructor 함수로 문서의 title 을 지정해주고 페이지에 해당하는 내용을 적어준다.

PostsSettings 파일도 각각 만들어서 동일하게 작성해준다.

// frontend/static/js/pages/Posts.js
export default class {
    constructor() {
        document.title = "Posts";
    }
    async getHtml() {
        return `
            <h1>This is Posts Page</h1>
        `;
    }
}
// 
// frontend/static/js/pages/Settings.js
export default class {
    constructor() {
        document.title = "Settings";
    }
    async getHtml() {
        return `
            <h1>This is Settings Page</h1>
        `;
    }
}

5. View 렌더링하기

5-1. View 렌더링

이제 콘솔에 찍지 않고 만들어진 View를 렌더링해보자.
index.js 파일을 열어서 view 부분을 변경한다.

import Home from "./pages/Home.js";
import Posts from "./pages/Posts.js";
import Settings from "./pages/Settings.js";

const router = async () => {
    const routes = [
        { path: "/", view: Home },
        { path: "/posts", view: Posts },
        { path: "/settings", view: Settings },
    ];

view: () => console.log("Viewing Home") 였던 부분을 view: Home과 같이 Home.js에서 불러온 class로 적용해준다.

다음은 해당하는 페이지의 클래스의 새 인스턴스를 만들고 getHtml 메소드를 실행해서 HTML파일에 추가하는 작업을 해준다.

// router 함수 안
// ...
const page = new match.route.view();
    
document.querySelector("#root").innerHTML = await page.getHtml();

여기까지 작성해주면 다음과 같이 작동할 것이다.

page title 도 잘 변경되고 안의 내용도 잘 바뀌는 것을 볼 수 있다.

여기까지만 해도 spa의 기본적인 구현은 끝난 것이다.

5-2. Not Found 404

/, /posts, /settings 가 아닌 이상한 경로로 들어왔을 때의 설정은 어떻게 해주면 될까

우선 NotFound.js 파일을 js 폴더 안에 생성한다.

// frontend/static/js/pages/NotFound.js
export default class {
    constructor() {
        document.title = "404 Not found";
    }
    async getHtml() {
        return `
            <h1>404 Not found</h1>
        `;
    }
}

그 다음 index.js 파일에서 불러온 후 설정을 해주면 된다. 다음 코드를 보자.

기존 router 함수의 부분

// index.js
let match = pageMatches.find((pageMatch) => pageMatch.isMatch);

const page = new match.route.view();
document.querySelector("#root").innerHTML = await page.getHtml();

find 함수는 만족하는 요소가 없다면 undefined 를 반환하는데, 경로에 이상한 값이 들어오면 맞는 값이 없어서 undefined 를 반환하게 된다.

변경 후 router 함수의 부분

let match = pageMatches.find((pageMatch) => pageMatch.isMatch);

if (!match) {
  match = {
    route: location.pathname,
    isMatch: true,
  };
  const page = new NotFound();
  document.querySelector("#root").innerHTML = await page.getHtml();
} else {
  const page = new match.route.view();
  document.querySelector("#root").innerHTML = await page.getHtml();
}

만약 matchundefinedNotFound 페이지를 렌더링하고 undefined 가 아닌 경우에는 해당하는 페이지를 렌더링해준다.

6. CSS 적용하기

이거는 각자 원하는 스타일링을 해주면 된다.

static 폴더에 css 폴더를 만들고 index.css 파일을 만들어주었다.

* {
    padding: 0;
    margin: 0;
}

body {
    display: flex;
}

.nav {
    display: flex;
    flex-direction: column;
    background: #222222;
    height: 100vh;
}

.nav_item {
    padding: 12px 18px;
    text-decoration: none;
    color: #eeeeee;
    font-weight: 500;
}

.nav_item:hover {
    background: rgba(255, 255, 255, 0.05);
}

#root {
    margin: 2em;
    line-height: 1.5;
    font-weight: 500;
}

a {
    color: #009579;
}

HTML 파일에 CSS 파일 추가

<link rel="stylesheet" href="./static/css/index.css" />

최종으로 완성된 모습은 다음과 같다.

Vanilla JavaScript에 대한 기본기가 많이 부족함을 느낀 공부였다고 생각한다..

앞으로 이 프로젝트에 Vanilla JavaScript로 구현할 수 있는 애들을 조금씩 만들어서 넣어봐야겠다.

소스 코드 보기

7. 참고

profile
알아가는 재미

0개의 댓글