이번 사이드 프로젝트에서는 라이브러리의 의존을 최소화하고, 자바스크립트만으로 애플리케이션을 개발하는 것이 목표였다. 이를 위해 Vanilla JavaScript로 SPA를 구현해 보았다. SPA를 구현하기 위해 컴포넌트, 라우터, 전역 Store를 구현했다. 최소한으로 필요한 부분들만 구현했다.
1. 컴포넌트 구현하기
ES6의 Class를 사용하여 React의 클래스형 컴포넌트와 유사한 컴포넌트를 구현해 보았다.
export default class Component {
$target;
props;
state;
constructor($target, props) {
this.$target = $target;
this.props = props;
this.setup();
this.render();
this.setEvent();
}
setup() {}
didMount() {}
template() {
return '';
}
render() {
const template = this.template();
if (template) {
this.$target.innerHTML = template;
this.didMount();
}
}
setState(newState) {
const updatedState = { ...this.state, ...newState };
if (JSON.stringify(this.state) === JSON.stringify(updatedState)) {
return;
}
this.state = updatedState;
this.render();
}
setEvent() {}
addEvent(eventType, selector, callback) {
const children = [...this.$target.querySelectorAll(selector)];
const isTarget = (target) =>
children.includes(target) || target.closest(selector);
this.$target.addEventListener(eventType, (event) => {
if (!isTarget(event.target)) return false;
return callback(event);
});
}
}
구현된 컴포넌트는 크게 상태 관리, 렌더링, 이벤트 세 가지 부분으로 나누어진다. 이 Base Component를 extends 하여 원하는 컴포넌트를 구현할 수 있다.
1. 렌더링
ES6에서 도입된 템플릿 리터럴(Template Literal)을 이용하여 컴포넌트의 HTML 코드를 작성한다. 그리고 이 템플릿을 render 함수에서 innerHTML을 사용하여 렌더링한다. 컴포넌트 클래스의 props로 target 요소를 받아 해당 HTML이 렌더링 될 위치를 지정할 수 있다.
template() {
const { message } = this.props;
return `
<div>
<button class='btn-primary'>
버튼
</button>
</div>
`;
}
2. 상태 관리
state, setup, setState로 상태를 관리한다. setup에서 state의 초기값을 설정한다. setState는 state를 업데이트할 때 사용되며, 새로운 값과 이전 값을 각각 JSON 형태로 변환 후 비교하여 이전 값과 다른 값일 때만 업데이트가 일어나도록 했다. 업데이트 후 render 메서드를 호출하여 리렌더링이 일어나도록 했다.
setState(newState) {
const updatedState = { ...this.state, ...newState };
if (JSON.stringify(this.state) === JSON.stringify(updatedState)) {
return;
}
this.state = updatedState;
this.render();
}
렌더링이 완료된 후 didMount라는 메서드를 호출하도록 하여 useEffect와 유사한 동작을 하도록 했다.
render() {
const template = this.template();
if (template) {
this.$target.innerHTML = template;
this.didMount();
}
}
didMount 내에 작성된 코드는 컴포넌트가 렌더링 될 때마다 실행된다. 당연하게도 state가 업데이트가 될 때도 마찬가지이다. 다만, 가상돔의 diffing 방식처럼 값이 변하는 부분만 리렌더링 되도록 구현하지는 못하여 현재는 어떤 값이 변경되든 컴포넌트 템플릿 전체가 리렌더링된다. 추후에 이 부분을 개선해 보고 싶다.
3. 이벤트
템플릿 리터럴과 innerHTML로 HTML 요소를 그리다 보니 이벤트 등록도 기존 HTML 함수로는 할 수 없다. addEvent라는 메서드는 이벤트를 등록하는 메서드이다.
addEvent(eventType, selector, callback) {
const children = [...this.$target.querySelectorAll(selector)];
const isTarget = (target) =>
children.includes(target) || target.closest(selector);
this.$target.addEventListener(eventType, (event) => {
if (!isTarget(event.target)) return false;
return callback(event);
});
}
부모-자식의 이벤트 전파를 막지 않고, 동적으로 생성되는 요소에도 이벤트 등록을 할 수 있게 구현했다.
setEvent에서 컴포넌트 내 요소의 이벤트를 등록하는 부분을 작성한다.
setEvent() {
this.addEvent('click', '.close', () => {
this.closeModal();
});
this.addEvent('click', '.save', () => {
this.save();
});
}
클래스 생성자 함수에서 setup, render, setEvent를 호출하여 클래스를 생성할 때 컴포넌트가 생성되고 초기화된다.
constructor($target, props) {
this.$target = $target;
this.props = props;
this.setup();
this.render();
this.setEvent();
}
2. 라우터 구현하기
SPA는 페이지 이동 시 HTML을 새로 받아오지 않고, History를 이용하여 주소창 URL만 바꾼 후 DOM의 하위 요소를 해당 URL에 해당하는 페이지 내용으로 바꾼다. 이를 통해 자연스러운 페이지 전환을 이끌어낸다. React Router는 브라우저의 History API를 기반으로 자체적인 History 객체를 만들었고, 이를 라우팅에 사용하고 있다. History API를 이용하여 간단하게 SPA 라우터를 구현해 보았다.
History API를 이용해 URL을 변경하고, 해당 URL에 맞는 페이지를 렌더링 해야 한다. 이를 위해 URL이 변경됨을 감지하는 이벤트가 필요하다. 하지만 이러한 이벤트가 따로 존재하지 않았는데, CustomEvent로 이벤트를 생성할 수 있다는 걸 알게 되어 이를 이용하기로 했다.
1. historyChange 커스텀 이벤트 정의 및 navigate 함수 구현
const navigate = (to, isReplace = false) => {
const historyChangeEvent = new CustomEvent('historychange', {
detail: {
to,
isReplace,
},
});
dispatchEvent(historyChangeEvent);
window.scrollTo(0, 0);
};
export default navigate;
navigate가 실행되면, dispatchEvent를 통해 historyChangeEvent를 트리거한다. 이로써 history에 변화가 일어났음을 인지할 수 있다. 또한 페이지 이동 시 스크롤 위치를 최상단으로 옮기도록 했다.
2. Router 구현하기
import { routes } from '@pages';
import navigate from '@utils/navigate';
const router = ($container) => {
const render = () => {
let page = routes.find((route) =>
route.path.test(window.location.pathname + window.location.search),
);
if (!page) {
window.alert('존재하지 않는 페이지입니다!');
window.history.pushState(null, null, '/');
page = routes[0];
}
new page.view($container);
};
const init = () => {
window.addEventListener('historychange', ({ detail }) => {
const { to, isReplace } = detail;
if (isReplace || to === window.location.pathname)
window.history.replaceState(null, '', to);
else {
const prev = window.history.state?.data;
const data = prev ? [prev[0], to] : [to];
window.history.pushState({ data }, '', to);
}
render();
});
window.addEventListener('popstate', render);
};
init();
render();
};
export default router;
초기화 시 historyChange와 popstate 이벤트에 이벤트 리스너를 등록해 준다. historyChange 이벤트 발생 시, 이벤트에서 받아온 to, isReplace 정보로 history를 변경해 주어야 한다. isReplace가 true이거나 이동할 경로와 현재 pathname이 동일하다면 replaceState를 호출하여 히스토리 스택이 쌓이지 않도록 한다.
render 함수에서는 URL에 매칭되는 페이지 컴포넌트를 렌더링하는 작업을 수행한다. 정규식을 사용하여 동적 페이지도 매칭이 가능하다.
3. Routes 정의하기
path와 페이지 컴포넌트 매칭 정보를 포함하는 Routes를 정의한다.
import Home from '@pages/Home';
import Write from '@pages/Write';
export const routes = [
{ path: /^\/$/, view: Home },
{
path: /^\/write(\?mode=edit&id=[^&\s]+)?$/,
view: Write,
},
// ...
];
4. <a> 태그 기본 이벤트 동작 방지
<a> 태그의 기본 이벤트 동작을 방지하고, 클릭 시 navigate를 호출하도록 하여 SPA 라우팅이 가능하도록 한다. 실제 React의 <Link> 컴포넌트도 클릭 시 내부에서 navigate를 호출하고 있다.
// Navigation 컴포넌트 내부
setEvent() {
this.addEvent('click', 'a', (e) => {
e.preventDefault(); // 기본 동작 방지
const target = e.target.tagName === 'I' ? e.target.parentNode : e.target;
navigate(target.href);
});
}
3. 전역 Store 구현
프로젝트를 진행하다보니, 인증 상태와 같은 애플리케이션 전역에서 사용할 수 있는 상태를 어딘가에 저장해야 했다.
Store의 상태가 업데이트되면 해당 상태가 사용되는 모든 컴포넌트가 리렌더링 되어야 한다. 상태 업데이트 여부를 알기 위해 상태를 구독해야 한다. 상대적으로 코드가 간단한 Zustand의 내부 로직을 살펴보았는데, Zustand도 Observer 패턴을 사용하고 있었다. 나는 간단하게 관찰 가능한 객체를 선언하고, 해당 객체의 속성에 접근할 때 구독 또는 리렌더링이 되도록 구현해 보았다.
1. 관찰 가능한 객체 구현
객체의 속성에 접근할 때 특정 동작을 수행하려면 ES6에 도입된 Proxy 문법을 이용할 수 있다. Proxy는 Object의 기본 get, set 작업을 가로채고 재정의할 수 있다.
let currentObserver = null;
export const setCurrentObserver = (observer) => {
currentObserver = observer;
};
export const observable = (obj) => {
const observers = {};
return new Proxy(obj, {
get(target, key) {
observers[key] = observers[key] || {};
if (currentObserver)
observers[key][currentObserver.constructor.name] = currentObserver;
return target[key];
},
set(target, key, value) {
// 이전과 같은 값을 저장하려는 경우 update하지 않는다.
if (target[key] === value) return true;
if (JSON.stringify(target[key]) === JSON.stringify(value)) return true;
target[key] = value;
// state가 포함된 컴포넌트 리렌더링
if (typeof observers[key] === 'object') {
Object.values(observers[key]).forEach((component) => {
component.render();
});
}
return true; // 성공 표시
},
});
};
get 접근 시 currentObserver(state가 사용되고 있는 컴포넌트)를 observer 목록에 추가한다. 각 컴포넌트를 구분하기 위해 클래스 이름을 사용했다. set 접근 시 state가 포함된 컴포넌트들(observers)을 리렌더링한다.
2. Store 구현
import { observable } from './observer';
const store = {
state: observable({ user: null, isLoggedIn: false }),
setState(newState) {
Object.entries(newState).forEach(([key, value]) => {
if (Object.prototype.hasOwnProperty.call(this.state, key))
this.state[key] = value;
});
},
};
export default store;
관찰 가능한 객체를 Store의 state 속성으로 설정하고, setState라는 상태 업데이트 메서드를 구현했다. setState 호출 시, 객체의 set 작업이 수행되면서 상태 업데이트와 리렌더링이 발생한다. (이벤트 전파)
3. 컴포넌트에 observer 설정 추가
render() {
setCurrentObserver(this);
const template = this.template();
if (template) {
this.$target.innerHTML = template;
this.didMount();
setCurrentObserver(null);
}
}
render 메서드에 observer 코드를 추가했다. 컴포넌트가 렌더링되기 전에 currentObserver를 this로 설정하고, 렌더링 및 didMount 작업 후에 다시 null로 초기화한다. state에 get 접근을 하게 되면 현재 컴포넌트가 observers에 추가된다.
이렇게 자바스크립트로 SPA를 만들어 보았다. 정말 오래 걸렸고 큰 도전이었다. 부족하고 아쉬운 점도 많지만, 이렇게 만든 것들로 하나의 웹 서비스 개발을 해냈다는 게 뿌듯하다. 부족한 부분은 시간을 들여서 조금씩 천천히 개선해 봐야겠다!
잘못된 부분이나 개선하면 좋을 점이 있다면 댓글로 알려주시면 감사하겠습니다!
references
https://junilhwang.github.io/TIL/Javascript/Design/Vanilla-JS-Component/#_1-%E1%84%80%E1%85%B5%E1%84%82%E1%85%B3%E1%86%BC-%E1%84%80%E1%85%AE%E1%84%92%E1%85%A7%E1%86%AB
https://nukw0n-dev.tistory.com/34
https://junilhwang.github.io/TIL/Javascript/Design/Vanilla-JS-Store/#_1-%E1%84%8C%E1%85%AE%E1%86%BC%E1%84%8B%E1%85%A1%E1%86%BC-%E1%84%8C%E1%85%B5%E1%86%B8%E1%84%8C%E1%85%AE%E1%86%BC%E1%84%89%E1%85%B5%E1%86%A8-%E1%84%89%E1%85%A1%E1%86%BC%E1%84%90%E1%85%A2%E1%84%80%E1%85%AA%E1%86%AB%E1%84%85%E1%85%B5
'Develop > JavaScript' 카테고리의 다른 글
[JavaScript] Firestore 검색 구현하기 with Aloglia (0) | 2024.04.19 |
---|---|
[JavaScript] 카카오맵 API를 이용한 장소 검색 및 위치 추가 구현하기 (0) | 2024.03.19 |
Vanilla JavaScript 프로젝트 초기 설정하기 (ES6) (0) | 2024.02.06 |
[Jest] 자바스크립트 테스트 코드 작성해 보기 (0) | 2023.09.07 |
[JavaScript] localStorage 사용하기 (0) | 2023.02.22 |