22.12.06 (화) : 최초 작성
22.12.11 (일) : 절대 경로 적용 및 navigate() 기능 추가
22.12.14 (수) : 결론 업데이트 및 제목 수정(만들기 -> 회고록)
https://github.com/jinyoung4478/gglim
npm run dev
Express
로 webRTC
사이드 프로젝트를 하는김에 바닐라 자바스크립트로 SPA(Single Page Application)를 구현해보았다.
처음에는 검색하면 나오는 자료로 쉽게 만들 수 있을것 같았다. 하지만 자료들이 새로고침 없이 페이지 이동까지만 있는 등 대부분 같은 내용만 찾을 수 있었다. 그렇게 직접 구현하다보니 다양한 문제들이 발생했고 해당 트러블들을 어떻게 해결해나갔는지 정리해보려고 한다.
프론트단에서는 Vanilla JS만 사용하였고, 서버에서는 Express.js를 사용할 것이며 SSR에 유리한 pug
와 같은 template 엔진은 따로 사용하지 않았다.
개발환경으로는 babel
과 nodemon
등 기본적인 node 패키지들을 활용하였다.
SPA는 최초 로딩을 제외하고 현재 url에 해당하는 페이지 요소들의 렌더링을 브라우저단에서 처리한다. 그래서 url이 바뀌어도 서버로 페이지를 요청하지 않고 화면만 다시 렌더링되는 방식이다.
이를 구현하기 위하여 다음과 같은 기능이 필요하다.
- url이 바뀌어도 서버로 요청하지 않도록 만들기
- JavaScript로 url에 따른 페이지 렌더링
- 뒤로 가기 등 history 이동에 따른 페이지 렌더링
이 기능들은 history나 hash를 이용해서 구현할 수 있다고 한다. 하지만 뒤에서 설명할 이슈들에 의해서 history 객체를 활용하는 방법을 사용하는 것이 더 편리하였다.
history 객체를 사용할 경우 history의 pushState() 메서드와 window의 popstate 이벤트를 이용하여 SPA를 만든다.
위의 Stackblitz 프로젝트에서 코드를 확인하고 구현 과정은 참고만해도 좋다.
npm init 등의 기본 node 프로젝트 설정 이후 간단하게 서버를 구동시킬 수 있는 express 라이브러리를 설치한다.
npm i express
그리고 server.js 파일 생성 후 다음과 같이 서버가 동작하도록 설정한다.
// /src/server.js
import http from 'http';
import express from 'express';
import path from 'path';
const app = express();
app.use(express.json());
// 가상 경로 접두부 마운팅
app.use('/views', express.static(__dirname + '/views'));
app.use('/components', express.static(__dirname + '/views/components'));
app.use('/pages', express.static(__dirname + '/views/pages'));
app.use('/public', express.static(__dirname + '/views/public'));
app.get('/*', function (_, res) {
res.sendFile(path.join(__dirname, 'views', 'index.html'));
});
const httpServer = http.createServer(app);
const handleListen = () => console.log(`Listening on http://localhost:3000`);
httpServer.listen(3000, handleListen);
모든 url 요청에 대한 응답을 /src/views/index.html
파일로 해준다. 이는 CRA로 만든 리액트에서의 public에 있는 index.html과 비슷하다.
절대경로를 설정하기 위해서 express.static 함수를 통해 제공되는 파일에 대한 가상 경로 접두부를 작성하였다. 추후 파일 import시 절대경로를 이용할 수 있다.
Express 가상 경로 마운팅 참고
http는 WebSocket 프로토콜과 연동하기 위해서 사용했으므로 필요한 경우에만 사용하면 된다.
index.html 파일을 적절한 디렉토리에 생성한다.
<!-- /src/views/index.html -->
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- CSS -->
<link rel="stylesheet" href="/views/public/css/styles.css" />
<!-- Title -->
<title>Vanilla JS</title>
</head>
<!-- body -->
<body>
<div id="root">hi</div>
</body>
<script src="/views/index.js" type="module"></script>
</html>
body
의 script 태그처럼 module type으로 index.js
를 불러오도록 한다. 모든 클라이언트 사이드의 자바스크립트가 index.js
에서부터 시작된다.
구글링으로 찾은 SPA 구현 방법에 대한 내용을 보면 index.js에서 DOMContentLoaded 이벤트부터 등록한다. 왜냐하면 HTML의 모든 요소가 로드된 이후에 실행되어야하기 때문이다. 하지만 script 태그에 defer 속성을 추가하는 것으로 같은 동작을 구현할 수 있다.
<!-- <script> 태그의 defer 속성은 페이지가 모두 로드된 후에 해당 외부 스크립트가 실행됨을 명시 -->
<script src="/views/index.js" type="module" defer></script>
이제 해당 스크립트에서 라우팅과 관련된 동작을 만들어보자.
라우팅을 하기 앞서 HTML의 body 요소들 중 data-link라는 anchor 태그가 클릭되었을 경우 발생하는 이벤트를 등록한다.
src/views/index.js
import Router from './Router.js';
document.body.addEventListener('click', e => {
if (e.target.matches('[data-link]')) {
e.preventDefault();
let path = e.target.href;
if (path === undefined) {
if (e.target.parentElement.tagName === 'A') {
path = e.target.parentElement.href;
} else {
return;
}
}
if (location.href == path) {
// 현재 페이지 리렌더링
navigate();
} else {
// 새로운 페이지 렌더링
navigate(path);
}
}
});
// 페이지 최초 로드 시 라우팅
navigate();
src/views/public/js/navigate.js
import Router from '/frontend/Router.js';
const navigate = path => {
console.clear();
// 새로운 url일 경우
if (path) history.pushState(null, null, path);
// 렌더링
Router();
};
export default navigate;
a 태그에 일일이 data-link 속성을 추가해야하는 단점이 있다. 하지만 시멘틱 웹 관점에서 페이지 이동 관련 태그는 모두 a 태그로 통일 할 생각이라서 그냥 넘어갔다. 데이터 속성과 관련해서는 이 문서를 참고하면 된다.
이제 페이지 뒤로가기나 앞으로가기 동작에 대해서도 이벤트 등록이 필요하다.
// 현재 활성화된 히스토리 엔트리 변동 시 렌더링
window.addEventListener('popstate', () => {
Router();
});
이와 같이 popstate 이벤트 발생 시 Router()를 한번 호출해주면 뒤로 가기나 앞으로 가기 동작 시 SPA와 같이 페이지 이동이 일어나게된다.
이제 Router.js와 page를 반환하는 컴포넌트를 살펴보자.
src/views/Router.js
// import Components
import Header from '/components/Header.js';
// import Pages
import Home from '/pages/Home.js';
import Store from '/pages/Store.js';
import NotFound from '/pages/NotFound.js';
// routes mapping
const routes = [
{ path: '/', view: Home, type: 'normal' },
{ path: '/store', view: Store, type: 'normal' },
];
const Router = () => {
const match = routes.find(route => route.path === location.pathname);
const page = match ? match.view : NotFound;
// Load Header
const header = Header.template(match.type);
// Load Page
const main = page.template();
const contents = header + main;
document.querySelector('#root').innerHTML = contents;
// script
Header.script(match.type);
page.script();
};
export default Router;
src/views/pages/Home.js
const Home = {
template: () => {
return `<h1>Home</h1>`;
},
script: () => {
console.log('This is Home!');
},
};
export default Home;
Router.js의 routes 변수에 각 path들을 매핑해주었다. 그리고 index.js로부터 페이지 이동 이벤트 발생 시 Router() 함수가 실행된다.
Router()에서는 routes 변수와 현재 url의 pathname을 비교하여 어떤 페이지를 렌더링할 지 결정한다. 만약 매칭되는 url이 아닐 경우 NotFound() 페이지가 렌더링된다.
각 페이지 혹은 컴포넌트의 script 동작은 render와 function이라는 이름의 메서드로 주었다. 이처럼 지정해둔 이유는 각 페이지에 해당하는 script를 함께 다루기 위해서이다. 단순하게 text로 script 태그를 추가해주면 스크립트가 동작하지 않는다.
routes에서의 type은 추후 로그인 여부 혹은 특수한 페이지를 구분하기 위하여 넣은 파라미터이다.
JavaScript로 렌더링해준 HTML 요소에 CSS를 적용해야한다. 이 과정에서도 다양한 트러블이 있었다. 그 결과는 다음과 같다.
express에서 처음으로 제공하는 index.html에서 root css file인 style.css
을 import한다. 해당 파일은 말 그대로 CSS 관련 root 파일이며 각 컴포넌트나 페이지, reset.css 등의 CSS 모듈들을 import한다.
@charset "utf-8";
@import 'reset.css';
@import 'variables.css';
/* Components */
@import 'components/Header.css';
/* Pages */
@import 'pages/Home.css';
@import 'pages/Store.css';
@import 'pages/NotFound.css';
#root {
width: 100%;
max-width: 1280px;
margin: 0 auto;
}
@charset "utf-8"
은 유니코드 문자열(Non-ASCII)이 있을 때 utf-8로 명시적으로 선언해주어 utf-8 인코딩 셋을 지정해주는 역할을 한다. reset.css는 각 브라우저에서 동일한 디자인을 적용하기 위해서 리셋을 해준다.
각 컴포넌트나 페이지의 class나 id가 겹치지 않도록 네이밍 규칙을 정해두었다. 예를들어 Home 페이지의 경우 container라는 이름 대신 home-container처럼, 이름이 조금 길어지지만 css가 중복 적용되는 문제를 방지할 수 있다.
history.pushState()를 이용하여 SPA routing을 구현했다. 그리고 popstate 이벤트를 감지하여 뒤로가기를 테스트하는 도중 알게되었다.
pushState()에서 알 수 있듯이 history에 url을 추가해주는 메서드이다. 현재 페이지와 같은 url을 요청해도 history에 같은 url이 추가되어서 발생하였다.
data-link
클릭 이벤트 발생 후 pushState() 메서드 실행 전에 현재 url과 요청 url이 같은지 판별하는 코드를 추가해주었다.
if (e.target.matches('[data-link]')) {
e.preventDefault();
// 같은 url인지 판별 (같은 history 중복 방지)
if (location.href !== e.target.href) {
// 새로운 url일 경우
history.pushState(null, null, e.target.href);
}
// 렌더링
Router();
}
같은 url일 경우 history는 그대로인채로 Router() 함수가 실행된다. 즉, 화면 리렌더링이 된다.
JavaScript로 무사히 템플릿 코드를 JavaScript를 이용하여 렌더링을 하였다. 그런데 해당 페이지에서 작동해야할 script를 어떻게 적용하는가에 대한 문제가 발생하였다. 물론 단순한 JS코드는 return하기 전에 동작시킬 수 있다. 하지만 DOM 요소가 그려진 이후 이벤트를 등록하거나 DOM 요소를 select하는 것이 불가능하다.
// Home.js
const Home = () => {
return `
<h1>Home</h1>
<script src="/views/pages/homeScript.js" type="module"></script>
`;
}
export default Home;
이처럼 script 태그를 이용하거나, 혹은
const Home = () => {
const script = document.createElement('script');
script.src = "/views/pages/homeScript.js";
script.type = "module"
document.body.appendChild(script)
return `
<h1>Home</h1>
`;
}
export default Home;
첫번째 방법은 script가 아예 작동하지 않았고, 두번쨰 방법은 페이지를 이동해도 script 태그가 사라지지 않는 문제가 있었다.
페이지 이동 시 마다 스크립트 태그를 지우는 방법도 생각해보았지만 추후 코드 보수 혹은 다른 기능을 추가할 때 소소한 문제들이 많이 발생할 것 같았다. 또한 원하는 script를 매번 판별해야하고 로직 자체가 직관적이지 않다고 생각했다.
템플릿 코드가 렌더링 된 이후 JavaScript가 동작하여야하는데, return 이후에 JS코드를 실행시키지 못해서 발생한 문제이다.
그래서 템플릿 코드가 return된 이후 해당 페이지에 맞는 JS코드를 실행시켜주었다. 따라서 페이지만 렌더링하는게 아닌, Object 형식으로 template과 script를 메서드로 만들었다.
const Home = {
template: () => {
`<h1>Home</h1>`
},
script: () => {
console.log('This is Home script');
console.log(document.querySelector('h1'));
}
}
export default Home;
그렇게 Router()에서 각 페이지에 해당하는 템플릿을 page.template()과 같이 불러와 렌더링 한 뒤, page.script()를 실행시켜주면 정상적으로 동작한다.
jsx를 사용했다면 신경도 쓰지 않을 문제였다...
CSS를 적용시키는 것도 2번의 script와 비슷한 문제였다. document.head
에 createElement('link')로 만든 HTML 요소를 추가해주는 방법도 마찬가지로 같은 link가 계속 추가되는 문제가 있었다.
페이지가 새로고침되지 않기 때문에, CSS를 동적으로 생성되었다가 사라지는 각 HTML 요소마다 적용시키기 어렵다.
페이지가 로드될 때마다 CSS를 적용시켜줄 필요가 없다고 생각하였다. 따라서 각 컴포넌트, 페이지 등의 모든 CSS를 style.css에서 한번에 import하여 다루는 방법을 선택하였다.
네이밍이 중복되는 것을 방지하여 각 페이지나 컴포넌트 이름을 기준으로 네이밍해주었다. 예를들면 다음과 같다.
.home-container {
display: flex;
}
.home-wrapper {
background-color: yellowgreen;
}
DOM 선택자의 이름이 길어지는 단점이 있지만, 장점이 더 크다고 판단하여 이 방법을 채택하여 문제를 해결하였다.
a 태그 페이지 이동 이벤트를 구분하기 위해서 data-link를 사용했었다. 그런데 a 태그가 다음과 같이 img 태그처럼 자식 요소를 품고 있으면 자식 요소로부터 이벤트 버블링이 발생한다.
<a href="/" data-link>
<img src="/img/logo.png" alt="logo" />
</a>
해당 로고를 클릭하면 img 태그가 클릭되어 data-link에서 걸리지 않아서 페이지 새로고침이 일어난다. 오직 data-link를 가진 태그에만 라우팅이 동작하기 때문이다.
img에 data-link를 추가한다면? 기존 코드는 data-link를 포함한 요소에서 href 속성값을 가져오는 로직으로 되어있다. img 태그에 href 속성을 추가하는 것은 바람직하지 않으므로 다른 방법을 찾아야했다.
원인은 이벤트 버블링을 고려하지 않아서 data-link 속성을 가진 a 태그의 자식 요소에 의해 의도하지 않은 동작이 일어났다.
이벤트 버블링을 고려하여 이벤트 처리를 해주어야한다.
우선 a 태그의 자식 요소가 클릭되어도 routing 처리를 하는 함수를 거쳐야한다. 그래서 의도적으로 a 태그의 자식 요소들에게도 data-link 속성을 추가하여 새로고침 동작을 막는다.
다음으로 이벤트 타겟이 올바른 a 태그인지 혹은 data-link 속성을 가진 a 태그의 자식 요소들인지 구분해야한다. 구분하는 방법은 e.target.tagName
을 확인하면된다. a 태그의 경우 A
라는 String
으로 출력된다.
반면, a 태그의 자식 요소들의 경우 다른 태그가 출력 될 것인데 이 경우에는 부모 태그를 확인해보면 된다. 이벤트 타겟의 부모 요소는 e.target.parentElement
로 알 수 있다. 부모 태그가 a 태그임이 확인된다면 path를 구할 수 있다.
코드로 확인해보자.
// SPA 동작
document.body.addEventListener('click', e => {
if (e.target.matches('[data-link]')) {
e.preventDefault();
let path = e.target.href;
if (path === undefined) {
if (e.target.parentElement.tagName === 'A') {
path = e.target.parentElement.href;
} else {
return;
}
}
if (location.href !== path) {
// 새로운 url일 경우
history.pushState(null, null, path);
}
// 렌더링
Router();
}
});
이처럼 이벤트 버블링을 고려하여 href 속성이 없을 시(== a 태그가 아닐 경우) 부모 태그가 a 태그인지 한번 더 확인하는 로직으로 해결하였다. 유의할 사항은 a 태그를 이용하여 네비게이션을 줄 때 data-link 속성을 넣어주었는지 확인해야하고, 이미지 링크나 a 태그가 다른 자식을 가지는 경우 자식 태그들도 data-link 속성을 주었는지 한번 더 확인해야한다.
특정 로직을 처리하고서 페이지 이동 처리가 필요한 상황이 발생했다. 그런데 페이지 이동 관련 javascript 동작은 새로고침이 발생하여 SPA의 원칙에 어긋난다.
location.href = '/'; // 페이지 새로고침이 발생
SPA로 동작하는 웹을 위하여 모든 페이지 렌더링은 router()에서 처리하고있다. 따라서 일반적인 방법으로 url을 바꾸려고 했기 때문에 페이지 새로고침이 발생했다.
React에서의 useNavigate()과 비슷한 navigate() 함수를 만들어야겠다고 생각했다. 그렇다면 페이지 이동이 필요할 때마다 navigate()를 실행시키면 되기 때문이다.
navigate()에 필요한 기능은 url의 history를 조작하는 것과 Router()를 호출하여 페이지를 렌더링해주는 기능이다. 이 기능들은 /src/index.js의 data-link 클릭 이벤트 발생 시 동작하는 코드에 있다.
따라서 다음과 같이 index.js에서 history.pushState()와 Router() 호출 기능을 분리하였다.
// 기존 src/views/public/js/navigate.js
import Router from '/public/js/navigate.js';
const navigate = path => {
console.clear();
// 새로운 url일 경우
if (path) history.pushState(null, null, path);
// 렌더링
Router();
};
export default navigate;
만약 "/login"
으로의 이동이 필요하다면 navigate("/login")
과 같이 함수를 실행시키면 페이지 새로고침 없이 화면 이동이 가능할 것이다.
추가적으로 console.clear()
를 실행시켜줌으로써 개발자 도구의 콘솔 창을 clear할 수 있다.
Vanilla JavaScript로 SPA를 구현해보았다. react를 사용하면 신경도 쓰지 않았을 문제들을 많이 마주했고 많이 헤맸던것 같다. 가장 어려웠던 부분은 페이지를 이동할 떄마다 DOM에 남아있는 요소들을 확인하고 처리하는 것이었다. 또한 prop을 템플릿에 내려주는 것은 템플릿 리터럴을 이용하면 간단하지만, 반대의 경우 script 파일 적용 방법 같이 신경 쓸 부분들이 많았다. 무엇보다 react의 가장 큰 장점이 JSX라고 느꼈다.
리액트의 생명 주기에 따른 동작을 구현해볼 방법도 생각했었다. 바로 DOMContentLoaded, load, beforeunload, unload의 HTML 생명 주기에 관여하는 이벤트를 이용하는 것이다. 이를 구현하기 위해서 컴포넌트를 마운트, 언마운트 하는 방법을 생각해보아야 할 것 같다.
그리고 아직 상태 관리를 제대로 테스트해보지 않았다. 개인 프로젝트가 어느정도 진행되면 상태 관리를 다루어야할 일이 분명 생길 것이다. 그때 상태 관리를 제대로 다루어봐야겠다.
현재 자바스크립트 SPA 프로젝트는 로그인과 회원가입까지 프론트와 서버 모두 완료하였다. 그리고 본격적으로 API를 주고받도록 구현하는 중이다. 그런데 정말 많은 문제들이 발생하고 있다.
가장 큰 문제점이 스크립트 실행 시점이다. 현재 시점의 프로젝트는 단순하게 DOM이 렌더링 된 이후에 스크립트를 실행시키도록 되어있다. 그러나 로그인 여부에 따른 렌더링처럼 DOM이 생성되기 이전에 실행되어야 할 스크립트가 필요한 상황이 생겼다. 지금의 구조로는 깔끔한 방법이 없다. 구조를 다시 수정해야한다.
그래서 더 느끼게되었다. 리액트를 사용할 때 컴포넌트의 생명주기를 왜 중요하게 생각하는지 말이다. 클래스형 리액트를 사용했다면 다음과 같은 함수들을 고려해볼 수 있었을 것이다.
getDerivedStateFromProp
componentDidMount
shouldComponentUpdate
...
이 프로젝트는 아무래도 프론트 단 페이지 렌더링 로직을 전면적으로 수정해야할 것 같다. 점점 리액트스럽게 변해가는 중이다.
프로그래머스에서 Vanilla JS로 SPA를 구현하는 과제도 있다. 답안도 제공되어 있으니 참고하면 좋을것 같다.
이벤트 버블링 참고