바닐라 자바스크립트로 SPA 만들기
기본 세팅
폴더 구조
프로젝트 폴더에 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>Routing</title>
<link rel="stylesheet" href="./static/css/index.css">
</head>
<body>
<nav class="nav">
<a class="nav__link" href="/" data-link>Dashboard</a>
<a class="nav__link" href="/posts" data-link>Posts</a>
<a class="nav__link" href="/settings" data-link>Settings</a>
</nav>
<div id="app"></div>
<script src="./static/js/index.js" type="module"></script>
</body>
</html>
express 서버 구축
간단한 서버를 구축하기 위해 express 를 설치해준다.
npm i express
설치 후 프로젝트 폴더에 server.js 생성
// servser.js
// express 서버 만들기
const express = require("express");
const path = require("path");
const app = express();
// 정적 (static) 구조 세팅 frontend 의 static 폴더
app.use(
"/static",
express.static(path.resolve(__dirname, "frontend", "static"))
);
// response > frontend 의 index.html
app.get("/*", (req, res) => {
res.sendFile(path.resolve("frontend", "index.html"));
});
// 서버 실행
app.listen(process.env.PORT || 3000, () => console.log("Server running ... "));
서버 실행
node server.js
라우트 구현
라우터 목록
// frontend/static/js/index.js
const router = async () => {
const routes = [
// path는 url 의 끝 주소, view 는 렌더링할 페이지
// 현재는 페이지가 없으므로 임시로 console.log를 찍어 잘 불러오는 지 확인
{ path: "/", view: () => console.log("Viewing Dashboard") },
{ path: "/posts", view: () => console.log("Viewing Posts") },
{ path: "/settings", view: () => console.log("Viewing Settings") },
];
// location.pathname 은 기본 url 뒤에 붙는 주소
// 주소와 일치하면 isMatch 를 true 로 바꿔준다.
const potentialMatches = routes.map((route) => {
return {
route,
isMatch: route.path === location.pathname,
};
});
};
// 돔이 로딩될 때 router 함수를 실행
document.addEventListener("DOMContentLoaded", () => {
router();
});
콘솔에 아래와 같이 나타나게 된다 ( 현재 주소는 localhost:3000/ 이므로 0번이 true 이다)
일치하는 라우터 찾기
// frontend/static/js/index.js
const router = async () => {
// ...
let match = potentialMatches.find(
(potentialMatches) => potentialMatches.isMatch
);
console.log(match.route.view());
// ...
};
// 돔이 로딩될 때(DOMContentLoaded 이벤트) router 함수를 실행
document.addEventListener("DOMContentLoaded", () => {
router();
});
find 함수를 통해 isMatch 값이 true 인 라우터를 찾는다. ( locallhost:3000/ 일 때 )
라우터 클릭 이벤트
각각의 메뉴를 클릭해서 해당 페이지를 불러와야 된다. (지금은 페이지가 없으므로 console 로 대체)
// frontend/static/js/index.js
const navigateTo = url => {
history.pushState(null, null, url);
router()
}
// ...
// 돔이 로딩될 때 router 함수를 실행
document.addEventListener("DOMContentLoaded", () => {
document.body.addEventListener("click", (e) => {
if (e.target.matches("[data-link]")) {
e.preventDefault();
navigateTo(e.target.href);
router();
}
});
router();
});
여기서는 body 에 클릭이벤트를 달고 클릭 한 요소에 data-link 라는 속성이 있을 경우(라우터 클릭 시)에만 이벤트가 발생하도록 하고 있다.
e.target.matches 는 태그의 특정 속성이 있는 지 확인 할 수 있음
위의 경우 e.target 은 a태그를 가리키고, 그 태그의 속성 중 data-link 라는 속성이 있으면 true 를 반환
e.preventDefault() 는 a태그의 기본 속성인 페이지 새로고침이 되는것을 막는다.
history.pushState 는 url 을 동적으로 바꿔주는 기능
메뉴를 선택했을 때 그에 맞는 url 로 바꿔준다.
이제 메뉴들을 눌러보면 이벤트가 정상적으로 동작하는것을 볼 수 있다.
하지만 뒤로가기나 앞으로가기를 눌렀을 때는 실행되지 않는다.
// frontend/static/js/index.js
window.addEventListener("popstate", router);
document.addEventListener("DOMContentLoaded", () => {
// ...
popstate 는 앞으로가기 뒤로가기를 눌렀을 때 이벤트 발생
View
View 기본 틀 작성
// frontend/static/js/view/AbstractView.js
export default class {
constructor() {}
setTitle(title) {
document.title = title; // 타이틀 바꿔주는 부분
}
async getHtml() { // 메소드 상속을 위해 임의값으로 정의
return "";
}
}
View 생성
AbstractView 를 상속 받아 View 를 만들어준다.
// frontend/static/js/view/Dashboard.js
import AbstractView from "./AbstractView.js";
export default class extends AbstractView {
constructor() {
super();
this.setTitle("Dashboard");
}
async getHtml() {
return `
<h1>Welcom Back, Dom</h1>
<p>
Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.
</p>
<p>
<a href="/posts" data-link>View recent posts</a>
</p>
`;
}
}
Dashboard 만 만들어줬음. 나머지 두 화면도 똑같이 만든다. ( getHtml 의 return 값은 다르게 만들어도 된다. )
View 렌더링
// frontend/static/js/index.js
import Dashboard from "./view/Dashboard.js";
// ...
const router = async () => {
const routes = [
// path는 url 의 끝 주소, view 는 렌더링할 페이지
// 현재는 페이지가 없으므로 임시로 console.log를 찍어 잘 불러오는 지 확인
{ path: "/", view: Dashboard },
// { path: "/posts", view: () => console.log("Viewing Posts") },
// { path: "/settings", view: () => console.log("Viewing Settings") },
];
// ...
const view = new match.route.view();
document.querySelector('#app').innerHTML = await view.getHtml();
}
// ...
DashBoard 클래스를 불러와서 임시로 () => console.log("Viewing ...") 을 넣어줬던 view 를 Dashboard 클래스로 바꿔준다.
이제 match 안의 route 가 클래스 이므로 new 를 통해 객체를 생성해준다.
객체의 getHtml 메소드를 통해서 동적으로 생성된 태그들을 innerHTML 로 렌더링해준다.
나머지 두 페이지도 똑같은 방법으로 생성하고 import 해서 routes 배열의 view 부분만 바꿔준다.
출처: YOUTUBE - DECODE
css
/* frontend/static/css/index.css */
body {
--nav-width: 200px;
margin: 0 0 0 var(--nav-width);
font-family: "Quicksand", sans-serif;
font-size: 18px;
}
.nav {
position: fixed;
top: 0;
left: 0;
width: var(--nav-width);
height: 100vh;
background: #222222;
}
.nav__link {
display: block;
padding: 12px 18px;
text-decoration: none;
color: #eeeeee;
font-weight: 500;
}
.nav__link:hover {
background: rgba(255, 255, 255, 0.05);
}
#app {
margin: 2em;
line-height: 1.5;
font-weight: 500;
}
a {
color: #009579;
}
💦 import 할 때 꼭 .js 를 붙여준다.