[JS] 프레임워크 없이 SPA 만들기 - 3 (dynamic-route)
![[JS] 프레임워크 없이 SPA 만들기 - 3 (dynamic-route) 글의 썸네일"](/_next/image?url=%2Fassets%2Fimages%2Fthumbnails%2Fjs.png&w=2048&q=75)
소스코드
ChangJuneKim/vaniila-spa at view-1 (github.com) 이전 글까지 소스코드
ChangJuneKim/vaniila-spa at dynamic-route (github.com) 현재 글 까지 소스코드
이번 포스팅에서는 동적라우팅 구현과, index.html
의 네비게이션 부분을 layout.js
로 추출해보겠다.
#동적 라우팅 구현 (feat. 정규 표현식)
js 폴더에 utils.js
파일을 생성해주자. utils.js
에는 path를 정규표현식으로 변경하는 함수(pathToRegex)
와 주어진 match 객체에서 다이나믹 라우트의 값을 추출해 객체 형태로 반환하는 함수(``getParams)
를 작성할 것이다.
그전에 router.js
를 조금 수정해보자.
#router.js 수정하기
// 이전 코드들 ...
export const router = async () => {
const routes = [
// .. 다른 path
// 1)
{ path: "/posts/:id", view: postsDetail },
];
const matchingResults = routes.map((route) => {
return {
...route,
// isMatch: location.pathname === route.path,
// 2)
routerResult: location.pathname.match(pathToRegex(route.path))),
};
});
// let match = potentialMatches.find((potentialMatch) => potentialMatch.isMatch);
// 2)
let match = matchingResults.find(
(matchingResult) => matchingResult.routerResult !== null
);
if (!match) {
match = {
path: routes.at(-1).path,
view: routes.at(-1).view,
//isMatch: true,
// 3)
routerResult: [location.pathname]
};
}
// 4)
const { getHTML } = match.view(getParams(match));
const page = await getHTML();
// 나머지 코드...
};
// 이전 코드들 ...
export const router = async () => {
const routes = [
// .. 다른 path
// 1)
{ path: "/posts/:id", view: postsDetail },
];
const matchingResults = routes.map((route) => {
return {
...route,
// isMatch: location.pathname === route.path,
// 2)
routerResult: location.pathname.match(pathToRegex(route.path))),
};
});
// let match = potentialMatches.find((potentialMatch) => potentialMatch.isMatch);
// 2)
let match = matchingResults.find(
(matchingResult) => matchingResult.routerResult !== null
);
if (!match) {
match = {
path: routes.at(-1).path,
view: routes.at(-1).view,
//isMatch: true,
// 3)
routerResult: [location.pathname]
};
}
// 4)
const { getHTML } = match.view(getParams(match));
const page = await getHTML();
// 나머지 코드...
};
- 동적 라우트를 정의 (
:id
부분이 변경될 수 있으므로 일치 시키기 위해서 정규식을 사용해야함) - 기존에는 정확하게 일치만 하면
true
아니면false
였지만 이제는posts/1,
posts/abc
등 다양한 라우트도 매치가 되어야하기 때문에boolean
값이 아니라, location.pathname.match
(정규식)의 결과 값을 리턴 받도록 변경함String.prototype.match
는 주어진 정규식과 문자열이 일치하는지 확인한 후 일치 정보를 배열로 반환한다. 일치하지 않으면null
을 반환한다.potentialMatches
라는 변수명은 이제 오해가 생길 것 같아서matchingResults
(각 라우트와의 일치 여부에 대한 결과들) 으로 변수명을 수정했다.
routerResult
는 이제boolean
이 아닌 일치 정보를 배열로 반환하기 때문에 배열에 현재 입력한pathname
을 넣어주었다. (404페이지를 처리하기 위한 코드라서["/not-found"]
라고 해도 되지만, 잘못된 들어간 경로 그 자체를 넣어주었다.)- view 함수를 출력할때 매개변수를 넘겨줬다.
getParams
함수는 입력된 url에서 parameter를 얻어온다.- 라우트에서
posts/:id
로 라우트를 정의하고match
된 url이posts/123
이라면{ id: 123 }
형태의 객체를 받는다. posts/:id/:another
의 형태라면{id: 123, another: 어떤값 }
의 형태로params
객체를 생성해서 리턴한다.
- 라우트에서
router.js
에서 해야할 작업은 끝났다. 이제 utils.js
파일에 정규식과 관련된 함수를 작성해보자.
#utils.js 작성
export const pathToRegex = (path) => {
const slashPattern = /\//g; // 모든 슬래시(/)를 찾기 위한 패턴
const escapedSlash = "\\/"; // 슬래시를 이스케이프하기 위한 문자열
const pathVariablePattern = /:\w+/g; // 모든 ':<변수명>' 형태의 경로 변수를 찾기 위한 패턴
const pathVariableReplacement = "([^/]+)"; // 경로 변수를 캡쳐하기 위한 정규 표현식 그룹
// 예: path = "/posts/:id"
const regexPath = path
.replace(slashPattern, escapedSlash) // 첫 번째 replace 후: "\/posts\/:id"
.replace(pathVariablePattern, pathVariableReplacement); // 두 번째 replace 후: "\/posts\/([^/]+)"
// 결과: regexPath는 \/posts\/([^/]+) 형태의 스트링이 되고
// new RegExp 의 결과로 /^\/posts\/([^/]+)$/ 정규식이 만들어져서 리턴된다.
return new RegExp(`^${regexPath}$`);
};
export const getParams = (match) => {
// 경로에서 추출한 파라미터 값들
const pathParameterValues = match.routerResult.slice(1);
// match.routerResult 에는 location.pathname.match(정규식) 의 결과 값이 들어있다.
// 첫번째 값은 매칭된 문자열 전체이기 때문에 필요없고 그 뒤의 배열만 필요하다. pathToRegex 함수의 pathVariableReplacement를 보면
// 괄호로 감싸져있는데 그 캡처 그룹에 매칭되는 것들이 배열의 1번 인덱스부터 존재한다.
console.log(pathParameterValues, match.routerResult); // url에 posts/1이라고 입력했다면['1'] , ['/posts/1', '1'] 각각 이런 형태
// 경로에서 파라미터의 이름들 (예: ":id", ":username" 등) 추출
const pathParameterKeys = [...match.path.matchAll(/:(\w+)/g)].map(
(result) => {
return result[1];
}
);
const paramsObj = pathParameterKeys.reduce((obj, key, index) => {
obj[key] = pathParameterValues[index];
return obj;
}, {});
return paramsObj;
};
export const pathToRegex = (path) => {
const slashPattern = /\//g; // 모든 슬래시(/)를 찾기 위한 패턴
const escapedSlash = "\\/"; // 슬래시를 이스케이프하기 위한 문자열
const pathVariablePattern = /:\w+/g; // 모든 ':<변수명>' 형태의 경로 변수를 찾기 위한 패턴
const pathVariableReplacement = "([^/]+)"; // 경로 변수를 캡쳐하기 위한 정규 표현식 그룹
// 예: path = "/posts/:id"
const regexPath = path
.replace(slashPattern, escapedSlash) // 첫 번째 replace 후: "\/posts\/:id"
.replace(pathVariablePattern, pathVariableReplacement); // 두 번째 replace 후: "\/posts\/([^/]+)"
// 결과: regexPath는 \/posts\/([^/]+) 형태의 스트링이 되고
// new RegExp 의 결과로 /^\/posts\/([^/]+)$/ 정규식이 만들어져서 리턴된다.
return new RegExp(`^${regexPath}$`);
};
export const getParams = (match) => {
// 경로에서 추출한 파라미터 값들
const pathParameterValues = match.routerResult.slice(1);
// match.routerResult 에는 location.pathname.match(정규식) 의 결과 값이 들어있다.
// 첫번째 값은 매칭된 문자열 전체이기 때문에 필요없고 그 뒤의 배열만 필요하다. pathToRegex 함수의 pathVariableReplacement를 보면
// 괄호로 감싸져있는데 그 캡처 그룹에 매칭되는 것들이 배열의 1번 인덱스부터 존재한다.
console.log(pathParameterValues, match.routerResult); // url에 posts/1이라고 입력했다면['1'] , ['/posts/1', '1'] 각각 이런 형태
// 경로에서 파라미터의 이름들 (예: ":id", ":username" 등) 추출
const pathParameterKeys = [...match.path.matchAll(/:(\w+)/g)].map(
(result) => {
return result[1];
}
);
const paramsObj = pathParameterKeys.reduce((obj, key, index) => {
obj[key] = pathParameterValues[index];
return obj;
}, {});
return paramsObj;
};
여기서 사용된 String.prototype.matchAll
메서드는 MDN을 참고해보자..
이제 postsDetail.js
를 작성하고 동적 라우팅이 잘 되는지 확인해보자.
import { createView } from "./createView.js";
const createPostsDetailContent = () => {
const fragment = document.createDocumentFragment();
const title = document.createElement("h1");
title.textContent = `포스트 디테일`;
fragment.appendChild(title);
return fragment;
};
export const postsDetail = createView("상세 페이지", createPostsDetailContent);
import { createView } from "./createView.js";
const createPostsDetailContent = () => {
const fragment = document.createDocumentFragment();
const title = document.createElement("h1");
title.textContent = `포스트 디테일`;
fragment.appendChild(title);
return fragment;
};
export const postsDetail = createView("상세 페이지", createPostsDetailContent);


뭐 때문인지 모르겠어서 서버에 요청이 들어오는걸 콘솔에 추가해봤다.
const express = require("express");
const path = require("path");
const app = express();
// 요청 URL을 콘솔에 출력하는 미들웨어
app.use((req, res, next) => {
console.log("Request URL:", req.originalUrl);
next();
});
// 이전 코드들 ...
const express = require("express");
const path = require("path");
const app = express();
// 요청 URL을 콘솔에 출력하는 미들웨어
app.use((req, res, next) => {
console.log("Request URL:", req.originalUrl);
next();
});
// 이전 코드들 ...
posts 페이지를 요청했을 땐 우리가 필요한 파일을 알맞은 경로로 잘 찾아갔지만

문제가 됐던 posts/1
페이지로 요청을 했더니 이유를 알 수 있었다.

/static/js
가 아닌 /posts/static/js
로 요청이 가는 걸 보니 html에서 js를 요청할때 상대경로로 요청한 것 같다. 절대 경로로 바꿔주자!
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
name="viewport">
<meta content="ie=edge" http-equiv="X-UA-Compatible">
<title>Document</title>
<!-- <link href="static/css/style.css" rel="stylesheet">-->
<!-- <script src="static/js/index.js" type="module"></script> -->
<link href="/static/css/style.css" rel="stylesheet">
<script src="/static/js/index.js" type="module"></script>
</head>
<body>
<header>
<nav class="nav">
<a class="nav__link" data-link="" href="/">홈</a>
<a class="nav__link" data-link="" href="/posts">게시글</a>
<a class="nav__link" data-link="" href="/settings">설정</a>
</nav>
</header>
<div id="root"></div>
</body>
</html>
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"
name="viewport">
<meta content="ie=edge" http-equiv="X-UA-Compatible">
<title>Document</title>
<!-- <link href="static/css/style.css" rel="stylesheet">-->
<!-- <script src="static/js/index.js" type="module"></script> -->
<link href="/static/css/style.css" rel="stylesheet">
<script src="/static/js/index.js" type="module"></script>
</head>
<body>
<header>
<nav class="nav">
<a class="nav__link" data-link="" href="/">홈</a>
<a class="nav__link" data-link="" href="/posts">게시글</a>
<a class="nav__link" data-link="" href="/settings">설정</a>
</nav>
</header>
<div id="root"></div>
</body>
</html>


posts/:id
에 대한 경로도 잘 동작하는걸 확인했으니 우리가 route.js
에서 매치된 view 함수를 호출할 때 매개변수로 getParmas(match)
의 결과값인 paramsObj
를 넘겨줬었다. 이제 그걸 활용해서, 상세페이지마다 다른 params
를 사용해보자.
posts/:id
경로일때 match.view(getParmas(match))
를 호출하면 postsDetail(paramsObj)
가 실행된다.
postsDetail.js
의 코드에서 paramsObj
를 받게 수정해주자
import { createView } from "./createView.js";
const createPostsDetailContent = (params) => {
const fragment = document.createDocumentFragment();
const title = document.createElement("h1");
title.textContent = `포스트 디테일 | ${params.id}`;
// 나머지 코드...
return fragment;
};
export const postsDetail = createView(
'포스트 상세 페이지'
createPostsDetailContent
);
import { createView } from "./createView.js";
const createPostsDetailContent = (params) => {
const fragment = document.createDocumentFragment();
const title = document.createElement("h1");
title.textContent = `포스트 디테일 | ${params.id}`;
// 나머지 코드...
return fragment;
};
export const postsDetail = createView(
'포스트 상세 페이지'
createPostsDetailContent
);
그리고 createView.js
에 콜백을 넘기기 때문에 createView.js
에도 매개변수를 추가해줘야한다. 브라우저의 title에도 params를 사용하기 위해서 코드를 약간 수정했다.
// 1)
export const createView = (title, content) => (params) => {
document.title =
Object.keys(params).length > 0 ? `${title} | ${params.id}` : title;
const getHTML = async () => content(params); // 2)
return {
getHTML,
};
};
// 1)
export const createView = (title, content) => (params) => {
document.title =
Object.keys(params).length > 0 ? `${title} | ${params.id}` : title;
const getHTML = async () => content(params); // 2)
return {
getHTML,
};
};

#마치며
드디어 바닐라JS만을 이용해서 간단한 SPA를 만들어보는 프로젝트? 가 마무리 됐다. 1, 2편과는 다르게 익숙하지 않은 정규표현식이 튀어나와서 머리가 좀 아팠지만 그냥 프론트엔드 개발을 하면서 그냥 필요해서 가져다 쓰기만했던 라우팅 부분을 직접 구현해보면서 많은 것을 배웠다.
처음에 유튜브 영상을 봤을 땐 조금 헷갈렸는데, 최대한 의미 있는 변수를 사용하는 등 내가 이해한 만큼 풀어쓰려고 노력했다. 이 포스팅이 다른 사람들에게도 도움이 됐으면 한다. 솔직히.. class 기반의 코드를 내 입맛대로 그냥 함수형으로 쓰고싶어서 이게 맞는지 틀린건지도 모르고 작성한 코드라 올바른 방법인지는 모르겠지만, 잘 돌아간다는 것에 만족하면서 바닐라JS로 SPA 만들기를 마무리한다.
레이아웃 뽑아보기
현재 index.html
에는 nav
가 직접 박혀있는데.. 이게 불-편 해서 layout으로 뽑아보려고한다. 리액트 프로젝트를 만들었을 때 처럼 html에는 root div만 남겨놓고 싶기도하고..
view
폴더에 layout.js
파일을 생성해주자
export const layout = (content) => {
const fragment = document.createDocumentFragment();
const header = document.createElement("header");
const nav = document.createElement("nav");
const paths = [
{ href: "/", text: "홈" },
{ href: "/posts", text: "게시글" },
{ href: "/settings", text: "설정" },
];
paths.forEach((path) => {
const navLink = document.createElement("a");
navLink.setAttribute("class", "nav__link");
navLink.setAttribute("data-link", "nav__link");
navLink.setAttribute("href", path.href);
navLink.textContent = path.text;
nav.appendChild(navLink);
});
header.appendChild(nav);
fragment.appendChild(header);
fragment.appendChild(content);
return fragment;
};
export const layout = (content) => {
const fragment = document.createDocumentFragment();
const header = document.createElement("header");
const nav = document.createElement("nav");
const paths = [
{ href: "/", text: "홈" },
{ href: "/posts", text: "게시글" },
{ href: "/settings", text: "설정" },
];
paths.forEach((path) => {
const navLink = document.createElement("a");
navLink.setAttribute("class", "nav__link");
navLink.setAttribute("data-link", "nav__link");
navLink.setAttribute("href", path.href);
navLink.textContent = path.text;
nav.appendChild(navLink);
});
header.appendChild(nav);
fragment.appendChild(header);
fragment.appendChild(content);
return fragment;
};
index.html
에서는 네비게이션 부분을 삭제해주자.
그리고 createView.js
에서 content를 리턴할때 layout함수로 content를 감싸주면 자동으로 네비게이션이 생성될 것이다.
import { layout } from "./layout.js";
export const createView =
(title, content, useLayout = true) =>
(params) => {
document.title =
Object.keys(params).length > 0 ? `${title} | ${params.id}` : title;
const getHTML = async () => useLayout ? layout(content(params)) : content(params);
return {
getHTML,
};
};
import { layout } from "./layout.js";
export const createView =
(title, content, useLayout = true) =>
(params) => {
document.title =
Object.keys(params).length > 0 ? `${title} | ${params.id}` : title;
const getHTML = async () => useLayout ? layout(content(params)) : content(params);
return {
getHTML,
};
};
나중에 layout이 필요하지 않은 페이지도 있을 수 있으니, boolean 값을 이용해서 조건부로 적용되게 했다.