💀

Vanilla Javascript로 구현하는 SPA - 라우팅


SPA Router

Single-Page Application(SPA)는 기존 멀티 페이지 웹 어플리케이션과 다르게 경로가 변경되도 새로 페이지를 불러오지 않는다. 대신 URL 경로에 따라 올바른 콘텐츠를 그때 그때 그려준다.

이 일을 'Router'가 해준다.

모든 router는 최소 2개의 핵심 기능을 제공해야한다.

  1. 어플리케이션의 모든 경로를 저장한 레지스트리(registry)
  2. URL의 변경을 감지해 적절한 화면을 보여줌

URL 변경을 감지하는 방법은 두 가지가 있다. Fragment identifier를 사용하는 방식과 History API를 사용하는 방식이다.

Fragment identifier는 현재 웹 페이지의 특정 부분을 식별하기 위해 사용된다. 예를 들어, www.domain.org/foo.html#bar에서 #bar이 fragment identifier다. foo.html에서 id="bar"인 요소를 나타낸다. Fragment가 변경되면 hashchange가 발생하는데, 이 이벤트에 대해 리스너를 붙여서 router를 구현할 수 있다. 하지만, 못생겼다고 생각해서 History API를 사용하겠다.

구현하기

라우터는 새로운 경로 추가, 경로에 맞는 컴포넌트 호출, URL 변경을 감지해야 할 수 있어야 한다.

  • addRoute: 새로운 경로와 컴포넌트를 추가한다.
  • start: URL 변경 감지를 시작한다.
  • checkRoutes: 현재 경로에 맞는 컴포넌트를 호출한다. 현재는 간단하게 Map을 사용해 일치하는 것을 찾도록 했다.
1const createRouter = () => {
2 const routeToView = new Map();
3 let notFound = () => {};
4
5 function addRoute(route, view) {
6 routeToView.set(route, view);
7 return this;
8 }
9
10 function setNotFound(cb) {
11 notFound = cb;
12 return this;
13 }
14
15 function start() {
16 window.addEventListener("click", (event) => {
17 const { target } = event;
18 if (target.matches("button[data-navigate]")) {
19 const { navigate } = target.dataset;
20 history.pushState({}, "", navigate);
21 checkRoutes();
22 }
23 });
24
25 window.addEventListener("popstate", () => {
26 checkRoutes();
27 });
28
29 checkRoutes();
30 return this;
31 }
32
33 function checkRoutes() {
34 const currentRoute = routeToView.get(window.location.pathname);
35 if (!currentRoute) {
36 notFound();
37 return;
38 }
39 currentRoute();
40 }
41
42 return {
43 addRoute,
44 setNotFound,
45 start,
46 checkRoutes,
47 };
48};

setNotFound는 맞는 경로가 하나도 없을 때 보여줄 화면이나 기능을 수행할 수 있도록 설정하는 함수다.

가장 중요한 start 함수를 살펴보자.

1function start() {
2 window.addEventListener("click", (event) => {
3 const { target } = event;
4 if (target.matches("button[data-navigate]")) {
5 const { navigate } = target.dataset;
6 history.pushState({}, "", navigate);
7 checkRoutes();
8 }
9 });
10
11 window.addEventListener("popstate", () => {
12 checkRoutes();
13 });
14
15 checkRoutes();
16 return this;
17}

첫 번째 이벤트 리스너는 화면 전환을 담당한다. 클릭한 버튼 요소에 data-navigate 속성이 있다면 화면 전환 이벤트가 발생했다고 간주한다. History API의 pushstate 메서드를 이용하면 브라우저의 세션 기록 스택에 상태를 추가할 수 있다. 페이지를 새로 갱신하지 않고 주소만 새로 바꿀 수 있다. 주소를 새로 바꾼 후에 checkRoutes를 통해 적절한 화면을 보여준다.

data- 속성을 사용한 화면 전환이 싫다면, router에 navigate 같은 경로 변경용 함수를 선언해 사용해도 괜찮을 것 같다.

두 번째 이벤트 리스너는 앞으로 가기와 뒤로가기를 담당한다. popstate 이벤트는 클라이언트가 앞으로 가기, 뒤로가기를 했을 때 발생한다. 이때는 경로에 맞는 적절한 화면을 보여주면 된다.

사용하기

구현한 router를 시험해보기 위해 간단한 html을 준비했다. 경로에 적절한 화면을 main 요소 안에 보여줄 것이다.

1<body>
2 <ul>
3 <li>
4 <button data-navigate="/">main</button>
5 </li>
6 <li>
7 <button data-navigate="/list">list</button>
8 </li>
9 </ul>
10 <main></main>
11</body>

Router를 직접 사용하면 아래와 같다.

1const createPages = (container) => {
2 const home = () => {
3 container.innerHTML = "home page";
4 };
5 const list = () => {
6 container.innerHTML = "list page";
7 };
8 const notFound = () => {
9 container.innerHTML = "not found";
10 };
11
12 return {
13 home,
14 list,
15 notFound,
16 };
17};
18
19const pages = createPages(document.querySelector("main"));
20const router = createRouter();
21
22router.addRoute("/", pages.home).addRoute("/list", pages.list).setNotFound(pages.notFound).start();

실제 사용해보면 원하는 대로 잘 동작하는 것을 확인할 수 있다.

경로 파라미터

어떤 페이지들은 경로 정보로 데이터를 요청하는 기능이 필요하다. 예를 들어, https://woong-jae.com/javascript/220325-spa-from-scratch-2는 이 블로그의 javascript 카테고리의 220325-spa-from-scratch-2 글을 화면에 보여준다. 이 정보를 URL으로 표현하면 /:category/:post가 될 것이다. 이처럼 파라미터를 가진 경로를 구현해보자.

우선 addRoute에서 경로를 등록하는 방식을 변경해야 한다. 경로를 매칭할 수 있게 받은 경로를 정규표현식으로 바꿔주는 작업을 할 것이다.

경로에서 파라미터에 해당하는 정규 표현식은 /:(\w)+로 나타낼 수 있다. String.prototype.replace 메서드를 통해 받은 경로의 파라미터를 전부 ([^\\/]+)로 치환해준다. ([^\\/]+)는 / 나 \ 를 포함하지 않는 문자열을 의미한다.

1const ROUTE_PARAMETER_REGEX = /:(\w+)/g;
2const URL_FRAGMENT_REGEX = "([^\\/]+)";
3
4const createRouter = () => {
5 // const routeToView = new Map();
6 const routes = [];
7 // ...
8 function addRoute(route, view) {
9 const params = [];
10
11 const parsedRoute = route
12 .replace(ROUTE_PARAMETER_REGEX, (match, paramName) => {
13 params.push(paramName);
14 return URL_FRAGMENT_REGEX;
15 })
16 .replace(/\//g, "\\/");
17
18 routes.push({
19 testRegExp: new RegExp(`^${parsedRoute}$`),
20 view,
21 params,
22 });
23
24 return this;
25 }
26};

이제 경로에서 파라미터를 추출할 수 있다. 경로에 대해 String.prototype.match와 방금 구한 testRegExp를 사용하면 쉽게 추출할 수 있다.

1const createRouter = () => {
2 // const routeToView = new Map();
3 const routes = [];
4 // ...
5 const extractURLParams = (route, pathName) => {
6 if (route.params.length === 0) return {};
7
8 const matches = pathName.match(route.testRegExp);
9
10 matches.shift();
11
12 const params = {};
13 matches.forEach((paramValue, index) => {
14 const paramName = route.params[index];
15 params[paramName] = paramValue;
16 });
17
18 return params;
19 };
20};

checkRoutes에서는 testRegExp를 사용해 일치하는 경로를 찾고, 파라미터를 추출해 view에 전달해주면 된다.

1function checkRoutes() {
2 const path = window.location.pathname;
3 const currentRoute = routes.find(({ testRegExp }) => testRegExp.test(path));
4 if (!currentRoute) {
5 notFound();
6 return;
7 }
8
9 const urlParams = extractURLParams(currentRoute, path);
10
11 currentRoute.view(urlParams);
12}

아까 사용하기에 경로를 추가해서 사용해보면 원하는 파라미터를 화면에 제대로 보여주는 것을 확인할 수 있다.

1const createPages = (container) => {
2 const home = () => {
3 container.innerHTML = "home page";
4 };
5 const list = () => {
6 container.innerHTML = "list page";
7 };
8 const blog = ({ category, post }) => {
9 contaner.innerHTML = `category: ${category}, post: ${post}`;
10 };
11 const notFound = () => {
12 container.innerHTML = "not found";
13 };
14
15 return {
16 home,
17 list,
18 blog,
19 notFound,
20 };
21};
22
23const pages = createPages(document.querySelector("main"));
24const router = createRouter();
25
26router
27 .addRoute("/", pages.home)
28 .addRoute("/list", pages.list)
29 .addRoute("/blog/:category/:post", pages.blog)
30 .setNotFound(pages.notFound)
31 .start();

참조: Frameworkless Frontend Development
https://developer.mozilla.org/ko/docs/Web/API/History/pushState