Vanilla Javascript로 간단한 SPA 라우터 구현하기
서론
본문을 이해하려면 History API와 브라우저의 History 스택에 대한 이해가 필요하다.
SPA의 경우 페이지를 이동할 때 anchor 태그를 활용해 새로운 html을 불러오는 게 아니다.
index.html 하위의 DOM을 갈아 끼우면서 다른 페이지를 보여주는 방식이다.
이때 새로운 HTML을 불러오지 않으면서 어떻게 앞으로 가기 뒤로 가기를 구현할 수 있을까?
답은 History API에 있다.
브라우저에서 제공하는 History API의 자세한 설명은 여기서 참고하길 바란다.
프로젝트 구조
├── index.html
├── main.js
├── package.json
├── 📦 src
│ ├── app.js
│ ├── 📦 constants
│ │ └── routeInfo.js
│ ├── 📦 pages
│ │ ├── main.js
│ │ ├── notfound.js
│ │ ├── post.js
│ │ └── shop.js
│ ├── router.js
│ └── 📦 utils
│ ├── navigate.js
│ └── querySelector.js
├── style.css
├── yarn.lock
vite로 환경을 세팅해주었다.
pages 디렉토리 하위에 총 4개의 페이지를 구성했다.
웹페이지에 접속했을 때. 즉 UR이 '/' 일 때의 화면인 Main 페이지와 경로를 찾지 못했을 때의 NotFound.
그리고 post, shop 두 개의 페이지이다.
// main.js
window.addEventListener("DOMContentLoaded", (e) => {
new App($("#app"));
});
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>간단한 바닐라 SPA</title>
</head>
<body>
<nav class="navbar">
<a href="/">HOME</a>
<a href="/post/123">POST</a>
<a href="/shop">SHOP</a>
</nav>
<div id="app"></div>
<script type="module" src="/main.js"></script>
</body>
</html>
우리의 페이지는 `#app` 하위에 위치하게 만들 것이다.
우선 navbar의 a 태그를 눌렀을 때 SPA처럼 동작하도록 만들어보자.
Anchor 태그 입맛대로 바꾸기
// app.js 중 일부
$(".navbar").addEventListener("click", (e) => {
const target = e.target.closest("a");
if (!(target instanceof HTMLAnchorElement)) return;
e.preventDefault();
const targetURL = e.target.href.replace(BASE_URL, "");
navigate(targetURL);
});
navbar의 a 태그를 클릭했을 때 페이지를 새로고침하며 이동하는 기존의 동작을 차단한 뒤 우리가 원하는 동작을 정의할 것이다.
이벤트 위임을 사용해 이벤트를 등록했다.
개발서버일 때 e.target.href 는 다음과 같은 값을 갖는다.
- href="/" 일 때 `http://localhost:3000/`
- href="/post/1234" 일 때 `http://localhost:3000/post/1234`
- href="/shop" 일 때 `http://localhost:3000/shop`
때문에 BASE_URL을 http://localhost:3000으로 등록해놓은 뒤 이 부분을 제거해주었다.
`http://localhost:3000/post/1234` -> `/post/1234` 가 될 것이다.
이 URL을 naviate라는 함수에 전달했다.
navigate 함수를 통해 우리가 원하는 SPA 라우팅을 구현할 예정이다.
페이지 구성
// pages/main.js
function Main($container) {
this.$container = $container;
this.setState = () => {
this.render();
};
this.render = () => {
this.$container.innerHTML = `
<main class="mainPage">
메인 페이지에요.
</main>
`;
};
this.render();
}
export default Main;
메인페이지는 생성자 함수를 통해서 구현했다.
$container에는 #app에 해당하는 DOM이 들어올 것이다.
메인페이지를 화면에 렌더링 하고 싶다면 `new Main()` 을 통해 Main 인스턴스를 생성해주면 된다.
다른 페이지도 마찬가지로 구성되어 있다.
커스텀 이벤트를 통한 History 변경 감지 (navigate func)
/**
* @param { string } to
* @param { boolean } isReplace
*/
export const navigate = (to, isReplace = false) => {
const historyChangeEvent = new CustomEvent("historychange", {
detail: {
to,
isReplace,
},
});
dispatchEvent(historyChangeEvent);
};
CustomEvent와 dispatchEvent를 사용해 navigate 함수가 호출되었을 때 History가 변경될 것임을 이벤트를 통해 알려주도록 하자.
CustomEvent를 사용하면 detail 객체를 통해 이벤트에 대한 추가적인 정보를 전달할 수 있다.
- to: 이동하게 될 URL
- isReplace: History 스택에 추가할 건지, 대체할 건지 여부
자. 그럼 navigate 함수를 실행하면 `historychange`라는 커스텀 이벤트가 발생할 것이다.
이제 historychange 이벤트가 발생했을 때 우리가 원하는 동작을 하도록 이벤트 핸들러를 등록해주면 된다.
여기서 우리가 원하는 동작이란?
-> 사용자가 이동하고자 하는 URL에서 나타나야 할 페이지를 #app 하위에 렌더링
Routes 정의하기
export const routes = [
{ path: /^\/$/, element: () => console.log('메인페이지') },
{ path: /^\/post\/[\w]+$/, element: () => console.log('포스트페이지') },
{ path: /^\/shop$/, element: () => console.log('샵페이지') },
];
routes 배열은 위와 같이 구성되어있다.
현재 URL이 path 속성 값에 match 될 경우 element에 해당하는 값을 보여주도록 한다.
포스트페이지의 경우 `/post/123` `/post/456` 둘 다 매치되어야 하기 때문에 정규식을 통해 나타냈다.
element 부분은 우선 잘 동작하는지 확인하기 위해 console.log를 찍도록 했다.
이후에 동작함이 확인되면 element 부분을 각 페이지의 생성자 함수로 대체해보자.
Router 정의하기
function Router($container) {
this.$container = $container;
const findMatchedRoute = () =>
routes.find((route) => route.path.test(location.pathname));
const route = () => {
findMatchedRoute().element();
};
const init = () => {
window.addEventListener("historychange", ({ detail }) => {
const { to, isReplace } = detail;
if (isReplace || to === location.pathname)
history.replaceState(null, "", to);
else history.pushState(null, "", to);
route();
});
window.addEventListener("popstate", () => {
route();
});
};
init();
route();
}
하나하나 살펴보자.
historychange 이벤트 핸들러
window.addEventListener("historychange", ({ detail }) => {
const { to } = detail;
history.pushState(null, "", to);
route();
});
historychange 이벤트가 발생했다는 건 페이지의 이동을 요청했다는 것이다. (navigate 함수 호출)
현재 URL이 http://localhost:3000 인 상태에서 navigate('/shop')을 호출했다고 가정해보자.
detail.to = '/shop' 이 될 것이다.
history.pushState를 통해 실제 브라우저의 History 스택에 '/shop'을 추가해준다.
이에 URL은 http://localhost:3000/shop으로 바뀌었을 것이다.
그리고는 route 함수를 호출한다.
그럼 route 함수는 무슨 일을 해야 할까?
맞다. 실제 DOM에 원하는 페이지를 보여줘야 한다.
현재 URL을 분석해서 어떤 페이지를 보여주어야 하는지 판단하여 #app 하위에 렌더링 해야 한다는 것이다.
route 함수
const findMatchedRoute = () =>
routes.find((route) => route.path.test(location.pathname));
const route = () => {
findMatchedRoute().element();
};
route 함수는 위와 같은 일을 한다.
현재 http://localhost:3000/shop에
위에서 정의한 routes 배열에서 해당 URL이 매치될 수 있는 path와 element를 찾아낸다.
그리고 해당 element를 실행한다.
동작하는지 확인해보자.
anchor 태그를 누를 때마다 기존의 페이지 깜빡임 없이 URL만 변경되면서 원하는 element가 호출되는 것을 확인할 수 있다.
자, 그럼 이제 실제로 SPA 라우팅을 하도록 원하는 페이지를 렌더링 시켜보도록 하자.
Router, routes 수정하기
import Main from "../pages/main";
import Post from "../pages/post";
import Shop from "../pages/shop";
export const routes = [
{ path: /^\/$/, element: Main },
{ path: /^\/post\/[\w]+$/, element: Post },
{ path: /^\/shop$/, element: Shop },
];
위에서 작성했던 console.log 함수를 지우고 미리 만들어놓은 각 페이지 생성자 함수로 대체한다.
const findMatchedRoute = () =>
routes.find((route) => route.path.test(location.pathname));
const route = () => {
const TargetPage = findMatchedRoute()?.element || NotFound;
new TargetPage(this.$container);
};
findMatchedRoute 함수의 결과는 둘 중 하나이다.
- 현재 URL와 match 되는 path가 존재해서 해당 element를 반환한다.
- match 되는 path를 찾을 수 없어 undefined를 반환한다.
2번의 경우 존재하지 않는 (routes에 등록되지 않은) 페이지를 요청한 것이므로 이에 대한 처리로 NotFound 페이지를 반환하도록 했다.
동작하는지 확인해보자.
현재 path에 따라 잘 동작하는 것을 확인할 수 있다!
근데 뭔가 문제가 있어 보인다. 발견했는지?
그렇다!
메인 페이지에서 메인 페이지로 이동하더라도 History 스택이 추가되고 있다.
그래서 뒤로 가기를 눌러서 popstate 이벤트를 발생시켜도 계속 메인 페이지에 위치해있고 여러 번 눌러야 그제야 이전 페이지로 돌아갈 수 있었다.
그럼 이걸 한번 해결해보도록 하자.
replaceState를 통해 중복 스택 방지
뒤로가기에 의해 popstate 이벤트가 발생해도 이전 페이지가 나온 원인은 같은 페이지로의 이동에 대해서도 History 스택에 pushState 요청을 보냈기 때문이다.
그러면 이런 생각을 해볼 수 있다.
같은 페이지일 경우에 스택에 push 하는 게 아니라 replace를 한다면?
window.addEventListener("historychange", ({ detail }) => {
const { to, isReplace } = detail;
if (isReplace || to === location.pathname)
history.replaceState(null, "", to);
else history.pushState(null, "", to);
route();
});
historychange 이벤트 핸들러 부분을 위와 같이 바꿔보자.
- navigate 함수에서 navigate('/shop', true) 와 같이 직접 isReplace 값을 전달했을 경우
- 이동하고자 하는 to와 현재 location.pathname과 같을 경우
위 두 가지 경우에는 history.pushState가 아닌 history.replaceState를 해줌으로써 History 스택에 같은 값이 여러 개가 주르륵 쌓이는 경우를 방지할 수 있게 되었다.
정말 간단하게 SPA 라우팅을 구현해보았다.
historychange 이벤트에 대한 핸들러가 잘 등록되어있다면 어디서든 navigate 함수를 통해서 라우팅 할 수 있다!
마치며
History API를 공부해본 적은 있지만 이렇게 실제로 활용해본 적은 처음이다.
바닐라 자바스크립트로 처음 라우팅을 구현해본 거라 아직은 부족한 점이 많다.
틀린 점이나 보완할 점이 있다면 댓글 남겨주시길 바란다.
포스트 작성에 사용했던 코드는 아래 Github에 업로드되어 있다.
https://github.com/KimKwon/frontend-basic/tree/main/content/history/vanilla-router
'유연해지기 > Javascript' 카테고리의 다른 글
JavaScript Set 내 맘대로 구현하기 (1) | 2022.11.30 |
---|---|
[Javascript] Iterable, 반복자 그리고 생성자 함수에 대하여 (0) | 2021.09.27 |
[Javascript] Promise 예제 풀어보기 (0) | 2021.09.12 |
[Javascript] 자바스크립트 Map 정렬하기 (0) | 2021.05.03 |