본 포스팅은 Youtube dcode - Build a Single Page Application with JavaScript (No Frameworks)의 내용을 바탕으로 작성되었습니다.
영상의 모든 내용이 글에 포함되어 있지는 않습니다. 전체 코드는 출처 영상을 시청해주세요!
React, Vue, Angular 등의 프레임워크/라이브러리 없이 라우터를 구현해 보자.
react-router 등의 편리함에 가려 보지 못했던 라우팅의 원리를 이해할 수 있다.
라우팅은 각 페이지들이 url에 따라 하나의 페이지 위에 선택된 데이터를 보여주는 것이다. 예를 들어 base url을 https://myurl 이라고 했을 때 위 세 개의 페이지를 왼쪽부터 각각 https://myurl/edit, https://myurl/list, https://myurl/show 에 접속하면 보이게 만드는 것이다.
먼저 파일 구조를 훑어보자.
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번 포트에 서버를 띄운다.
여기까지 하고 콘솔창에서 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에 따라 차이가 있는 구분자(윈도우는 슬래시'/', 맥은 역슬래시'\')때문에 발생할 수 있는 에러를 방지해준다.
먼저 라우팅을 담당하는 함수를 살펴보자.
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);
history API를 이용해 라우팅을 할 수 있게 됐다! 라우팅 방법을 세줄요약하면 아래와 같다.
폴더 구조 자체를 처음부터 만들어 보는 과정에서 기능별로 파일/디렉토리를 분할해서 관리하는 모듈화의 중요성을 알게 되었다. 특히 view 디렉토리에서 부모 클래스 AbstractView로 틀을 짜 주고, 그 틀에 맞게 모듈을 짤 수 있다는 것을 알게 됐다. 이것이 객체지향이다... 하는 느낌이었다. 여러사람이 함께 개발할 때 이렇게 부모클래스에서 미리 필요한 변수/함수를 정의해두고 그 틀에 맞춰 작업하면 협업할 때 편하겠구나 하고 생각했다.
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문이 필요없을 때 사용한다.
진심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>`
);
}
꼼꼼하게... 내 모든 코드가 틀렸다고 생각하고 버그를 찾아내는 습관을 들여야겠다...