본문으로 바로 가기
로고
개발

[JS] 프레임워크 없이 SPA 만들기 - 2 (view)

읽는 시간 8분
[JS] 프레임워크 없이 SPA 만들기 -2 (view) 글의 썸네일"

소스코드

ChangJuneKim/vaniila-spa at router-1 (github.com) 이전 글 까지 소스코드

ChangJuneKim/vaniila-spa at view-1 (github.com) 현재 글까지 소스코드

이전 글에서 기본적인 router를 구현해봤다. 이번 포스팅에서는 각 경로에 맞는 간단한 화면을 렌더링 해보자.

폴더구조
폴더구조
CSS 소스 코드
css
header {
background: #3e70d9;
color: white;
font-weight: bold;
height: 75px;
left: 0;
position: fixed;
right: 0;
top: 0;
}
 
nav {
height: 100%;
}
 
.nav__link {
align-items: center;
color: #fff;
display: inline-flex;
height: 100%;
padding: 1rem;
text-decoration: none;
}
 
.nav__link:hover {
background: rgba(255, 255, 255, 0.05);
}
 
a {
color: #009579;
}
 
h1, p {
margin: 0;
}
 
main {
height: 100%;
padding: 1rem;
}
 
body {
background: #EEE;
padding-top: 75px;
}
 
body, html {
height: 200%;
}
 
* {
box-sizing: border-box;
}
css
header {
background: #3e70d9;
color: white;
font-weight: bold;
height: 75px;
left: 0;
position: fixed;
right: 0;
top: 0;
}
 
nav {
height: 100%;
}
 
.nav__link {
align-items: center;
color: #fff;
display: inline-flex;
height: 100%;
padding: 1rem;
text-decoration: none;
}
 
.nav__link:hover {
background: rgba(255, 255, 255, 0.05);
}
 
a {
color: #009579;
}
 
h1, p {
margin: 0;
}
 
main {
height: 100%;
padding: 1rem;
}
 
body {
background: #EEE;
padding-top: 75px;
}
 
body, html {
height: 200%;
}
 
* {
box-sizing: border-box;
}

참고한 영상에서는 템플릿 리터럴을 이용해서 html을 생성했지만, 본 글에서는 조금 번거롭더라도 DOM 메서드들을 사용해서 페이지를 작성해보겠다.

#View 구성하기

#home.js 작성하기

home.js
js
export const home = () => {
  // 1) 브라우저의 title을 설정
  document.title = "홈페이지";
 
  // 2) 우리가 작성한 home 함수는 돔을 생성하는 getHTML 함수를 리턴한다.
  const getHTML = async () => {
    const main = document.createElement("main");
 
    const title = document.createElement("h1");
    title.textContent = "";
    main.appendChild(title);
 
    const subTitle = document.createElement("h2");
    subTitle.textContent = "바닐라 JS로 SPA 만들기";
    main.appendChild(subTitle);
 
    const description = document.createElement("p");
    description.textContent = "Lorem ipsum ... Aut harum iste quia";
    main.appendChild(description);
 
    const links = document.createElement("ul");
 
    const postsLink = document.createElement("li");
    const postsLinkAnchor = document.createElement("a");
    postsLinkAnchor.setAttribute("href", "/posts");
    postsLinkAnchor.setAttribute("data-link", "");
    postsLinkAnchor.textContent = "최근 게시물 보기";
    postsLink.appendChild(postsLinkAnchor);
 
    links.appendChild(postsLink);
 
    const settingsLink = document.createElement("li");
    const settingsLinkAnchor = document.createElement("a");
    settingsLinkAnchor.setAttribute("href", "/settings");
    settingsLinkAnchor.setAttribute("data-link", "");
    settingsLinkAnchor.textContent = "설정 페이지";
    settingsLink.appendChild(settingsLinkAnchor);
 
    links.appendChild(settingsLink);
 
    main.appendChild(links);
 
    return main;
  };
 
  return {
    getHTML,
  };
};
home.js
js
export const home = () => {
  // 1) 브라우저의 title을 설정
  document.title = "홈페이지";
 
  // 2) 우리가 작성한 home 함수는 돔을 생성하는 getHTML 함수를 리턴한다.
  const getHTML = async () => {
    const main = document.createElement("main");
 
    const title = document.createElement("h1");
    title.textContent = "홈";
    main.appendChild(title);
 
    const subTitle = document.createElement("h2");
    subTitle.textContent = "바닐라 JS로 SPA 만들기";
    main.appendChild(subTitle);
 
    const description = document.createElement("p");
    description.textContent = "Lorem ipsum ... Aut harum iste quia";
    main.appendChild(description);
 
    const links = document.createElement("ul");
 
    const postsLink = document.createElement("li");
    const postsLinkAnchor = document.createElement("a");
    postsLinkAnchor.setAttribute("href", "/posts");
    postsLinkAnchor.setAttribute("data-link", "");
    postsLinkAnchor.textContent = "최근 게시물 보기";
    postsLink.appendChild(postsLinkAnchor);
 
    links.appendChild(postsLink);
 
    const settingsLink = document.createElement("li");
    const settingsLinkAnchor = document.createElement("a");
    settingsLinkAnchor.setAttribute("href", "/settings");
    settingsLinkAnchor.setAttribute("data-link", "");
    settingsLinkAnchor.textContent = "설정 페이지";
    settingsLink.appendChild(settingsLinkAnchor);
 
    links.appendChild(settingsLink);
 
    main.appendChild(links);
 
    return main;
  };
 
  return {
    getHTML,
  };
};

#router.js 수정

router.js
js
// 이전 코드...
export const router = async () {
	const routes = [
    { path: "/", view: home },
    // 다른 경로들 ...
  ];
 
  // 이전 코드들 ...
  const { getHTML } = match.view(); // 1)
  const page = await getHTML();
 
  const root = document.querySelector("#root");
  // 2)
  while (root.firstChild) {
    root.removeChild(root.firstChild);
  }
  // 3)
  document.querySelector("#root").appendChild(page);
}
router.js
js
// 이전 코드...
export const router = async () {
	const routes = [
    { path: "/", view: home },
    // 다른 경로들 ...
  ];
 
  // 이전 코드들 ...
  const { getHTML } = match.view(); // 1)
  const page = await getHTML();
 
  const root = document.querySelector("#root");
  // 2)
  while (root.firstChild) {
    root.removeChild(root.firstChild);
  }
  // 3)
  document.querySelector("#root").appendChild(page);
}
  1. home.js 에서 작성한 home 함수를 routes 객체 배열에 view 값으로 넣어준다. 그럼 path와 매치된 페이지 돔 엘리먼트를 리턴하는 getHTML 함수를 얻을 수 있다.
  2. root에 이전에 심어져있던 요소들을 모두 지우고 ( 이 코드가 없으면 이전 페이지 아래에 계속 붙어서 생성된다. innerHTML으로 처리하면 필요없는 코드)
  3. 새롭게 root에 새로운 요소(getHTML으로 얻은 page 돔 덩어리)를 심어준다.

#다른페이지들 작성

#not-found.js

not-found.js
js
export const notfound = () => {
  document.title = "404 not found";
 
  const getHTML = async () => {
    const fragment = document.createDocumentFragment(); // 1)
 
    const title = document.createElement("h1");
    title.textContent = "404 not found";
    fragment.appendChild(title);
 
    const description = document.createElement("p");
    description.textContent =
      "Lorem ipsum dolor sit amet, consectetur adipisicing elit. At dolores eius ipsa labore laborum magni non, quae recusandae reprehenderit repudiandae sapiente similique tempore ullam veniam veritatis? Aut harum iste quia";
    fragment.appendChild(description);
 
    const homeLinkWrapper = document.createElement("p");
    const homeLink = document.createElement("a");
    homeLink.setAttribute("href", "/");
    homeLink.setAttribute("data-link", "");
    homeLink.textContent = "홈으로";
    homeLinkWrapper.appendChild(homeLink);
 
    fragment.appendChild(homeLinkWrapper);
 
    return fragment;
  };
 
  return {
    getHTML,
  };
};
not-found.js
js
export const notfound = () => {
  document.title = "404 not found";
 
  const getHTML = async () => {
    const fragment = document.createDocumentFragment(); // 1)
 
    const title = document.createElement("h1");
    title.textContent = "404 not found";
    fragment.appendChild(title);
 
    const description = document.createElement("p");
    description.textContent =
      "Lorem ipsum dolor sit amet, consectetur adipisicing elit. At dolores eius ipsa labore laborum magni non, quae recusandae reprehenderit repudiandae sapiente similique tempore ullam veniam veritatis? Aut harum iste quia";
    fragment.appendChild(description);
 
    const homeLinkWrapper = document.createElement("p");
    const homeLink = document.createElement("a");
    homeLink.setAttribute("href", "/");
    homeLink.setAttribute("data-link", "");
    homeLink.textContent = "홈으로";
    homeLinkWrapper.appendChild(homeLink);
 
    fragment.appendChild(homeLinkWrapper);
 
    return fragment;
  };
 
  return {
    getHTML,
  };
};

모든 페이지가 형태가 똑같기 때문에 나머지 코드 내용들은 접은 글에 작성해놓았다. 하지만, 학습을 위해서 404 페이지는document.createDocumentFragment() 를 이용해보았다. document.createDocumentFragment() 는 리액트의 React.fragment(<></>)와 유사한 기능을 한다.

-- DOM에 추가적인 노드를 삽입하지 않으면서, 그룹화하는 역할은 같음

-- React.fragment(<></>)는 추가적인 DOM 요소를 줄이기 위해 사용되지만 document.createDocumentFragment()는 DOM 조작을 효율적으로 수행하기 위해 사용되는 차이점도 있음

나머지 페이지 - 설정 페이지 코드
settings.js
js
export const settings = () => {
document.title = "설정";
 
const getHTML = async () => {
const main = document.createElement("main");
 
const title = document.createElement("h1");
title.textContent = "설정";
main.appendChild(title);
 
const description = document.createElement("p");
description.textContent =
"Lorem ipsum dolor sit amet, consectetur adipisicing elit. At dolores eius ipsa labore laborum magni non, quae recusandae reprehenderit repudiandae sapiente similique tempore ullam veniam veritatis? Aut harum iste quia";
main.appendChild(description);
 
const homeLinkWrapper = document.createElement("p");
const homeLink = document.createElement("a");
homeLink.setAttribute("href", "/");
homeLink.setAttribute("data-link", "");
homeLink.textContent = "홈으로";
homeLinkWrapper.appendChild(homeLink);
 
main.appendChild(homeLinkWrapper);
 
return main;
};
 
return {
getHTML,
};
};
settings.js
js
export const settings = () => {
document.title = "설정";
 
const getHTML = async () => {
const main = document.createElement("main");
 
const title = document.createElement("h1");
title.textContent = "설정";
main.appendChild(title);
 
const description = document.createElement("p");
description.textContent =
"Lorem ipsum dolor sit amet, consectetur adipisicing elit. At dolores eius ipsa labore laborum magni non, quae recusandae reprehenderit repudiandae sapiente similique tempore ullam veniam veritatis? Aut harum iste quia";
main.appendChild(description);
 
const homeLinkWrapper = document.createElement("p");
const homeLink = document.createElement("a");
homeLink.setAttribute("href", "/");
homeLink.setAttribute("data-link", "");
homeLink.textContent = "홈으로";
homeLinkWrapper.appendChild(homeLink);
 
main.appendChild(homeLinkWrapper);
 
return main;
};
 
return {
getHTML,
};
};
나머지 페이지 - 포스트 페이지 코드
posts.js
js
export const posts = () => {
document.title = "포스트";
 
const getHTML = async () => {
const main = document.createElement("main");
 
const title = document.createElement("h1");
title.textContent = "포스트";
main.appendChild(title);
 
return main;
};
 
return {
getHTML,
};
};
posts.js
js
export const posts = () => {
document.title = "포스트";
 
const getHTML = async () => {
const main = document.createElement("main");
 
const title = document.createElement("h1");
title.textContent = "포스트";
main.appendChild(title);
 
return main;
};
 
return {
getHTML,
};
};

#공통로직 분리

위 view 코드들은 title을 수정하고, getHTML을 정의해서 리턴하는 동일한 구조를 가지고 있다. 공통 로직을 분리해서 createView 함수를 작성해보자.


#createView.js

createaView.js
js
export const createView =
  (title, content) =>
  () => {
    document.title = title;
 
    const getHTML = async () => content()
 
    return {
      getHTML,
    };
  };
createaView.js
js
export const createView =
  (title, content) =>
  () => {
    document.title = title;
 
    const getHTML = async () => content()
 
    return {
      getHTML,
    };
  };

#home.js 에서 createView를 사용하도록 수정

home.js
js
import { createView } from "./createView.js";
 
const createHomeContent = () => {
  const fragment = document.createElement("main");
 
  const title = document.createElement("h1");
  title.textContent = "";
  fragment.appendChild(title);
 
  const subTitle = document.createElement("h2");
  subTitle.textContent = "바닐라 JS로 SPA 만들기";
  fragment.appendChild(subTitle);
 
  const description = document.createElement("p");
  description.textContent = "Lorem ipsum ... Aut harum iste quia";
  fragment.appendChild(description);
 
  const links = document.createElement("ul");
 
  const postsLink = document.createElement("li");
  const postsLinkAnchor = document.createElement("a");
  postsLinkAnchor.setAttribute("href", "/posts");
  postsLinkAnchor.setAttribute("data-link", "");
  postsLinkAnchor.textContent = "최근 게시물 보기";
  postsLink.appendChild(postsLinkAnchor);
 
  links.appendChild(postsLink);
 
  const settingsLink = document.createElement("li");
  const settingsLinkAnchor = document.createElement("a");
  settingsLinkAnchor.setAttribute("href", "/settings");
  settingsLinkAnchor.setAttribute("data-link", "");
  settingsLinkAnchor.textContent = "설정 페이지";
  settingsLink.appendChild(settingsLinkAnchor);
 
  links.appendChild(settingsLink);
 
  fragment.appendChild(links);
 
  return fragment;
};
 
export const home = createView("바닐라 SPA", createHomeContent);
home.js
js
import { createView } from "./createView.js";
 
const createHomeContent = () => {
  const fragment = document.createElement("main");
 
  const title = document.createElement("h1");
  title.textContent = "홈";
  fragment.appendChild(title);
 
  const subTitle = document.createElement("h2");
  subTitle.textContent = "바닐라 JS로 SPA 만들기";
  fragment.appendChild(subTitle);
 
  const description = document.createElement("p");
  description.textContent = "Lorem ipsum ... Aut harum iste quia";
  fragment.appendChild(description);
 
  const links = document.createElement("ul");
 
  const postsLink = document.createElement("li");
  const postsLinkAnchor = document.createElement("a");
  postsLinkAnchor.setAttribute("href", "/posts");
  postsLinkAnchor.setAttribute("data-link", "");
  postsLinkAnchor.textContent = "최근 게시물 보기";
  postsLink.appendChild(postsLinkAnchor);
 
  links.appendChild(postsLink);
 
  const settingsLink = document.createElement("li");
  const settingsLinkAnchor = document.createElement("a");
  settingsLinkAnchor.setAttribute("href", "/settings");
  settingsLinkAnchor.setAttribute("data-link", "");
  settingsLinkAnchor.textContent = "설정 페이지";
  settingsLink.appendChild(settingsLinkAnchor);
 
  links.appendChild(settingsLink);
 
  fragment.appendChild(links);
 
  return fragment;
};
 
export const home = createView("바닐라 SPA", createHomeContent);

다른 페이지들도 위처럼 처리해주면 된다.


#마치며

이번 포스팅에서는 뷰 로직의 추상화와 그것을 활용한 화면 구성 방법에 대해서 살펴보았다.

바닐라JS로 SPA를 만들다보면, JSX가 너무 그리워지는 것 같다. 프레임워크의 편리함과 컴포넌트 기반의 구조가 얼마나 강력한지, 직접 SPA를 구축하면서 체감하게됐다. 

다음 포스팅에서는 더 다양하고 복잡한 라우팅 문제를 해결하기 위해 posts/:id 형식의 동적 라우팅 처리 방법과 index.html 에 있는 네비게이션을 레이아웃으로 추출하는 방법에 대해서 알아보자.


#참고자료

  1. Document.createDocumentFragment() - Web API | MDN (mozilla.org)