리액트와 같은 프레임워크를 안쓰고 사이트를 구축해보려고하니
가장 부딪혔던 부분이 바로 주소창 URL과 내 화면 간의 일치 작업이 매우 힘들다는 부분이었다.
그 작업이 복잡해지다보니 자꾸 뒤로가기를 눌렀을 때 두번 뒤로가기를 하면 인덱스로 가버리는 현상이 발생했다.
사실 이 부분의 경우 지난번 프로젝트때 Vanilla JS 기반으로 해결해보는걸 시도는 했었으나 그땐 실패했었다.
결국 이번 프로젝트 어드민 구축에 있어서 그런 문제가 또 발생했고
이번에 풀지 않으면 계속해서 발생하겠다고 판단이 되어서 커서와 함께 다시 이 문제를 해결해보았다.
화면에 맞는 주소반영, 원활한 뒤로가기, 페이지 관리 등이 중요했고
이를 해결하는 방법으로 State와 Router 파일을 구축해서 사용하게 되었다.
(리액트를 안썼지만 결국 작업의 방식이 리액트스럽게 흐르긴 한다..ㅎㅎ)
둘 다 코드의 경우 커서를 통해 제작한거라 꽤 어려워서 나도 코드 전체를 제대로 분석하진 못했지만
코드의 주요 메서드는 중요하다고 판단되어서 블로그에 정리해둔다.
특히 State 파일이 내가 고민했던 문제를 해결해준 키인데
일단 현재 프로젝트 어드민 부분에 해당하는 State파일을 살펴보면 아래와 같다.
class AdminState {
constructor() {
this._state = {
page: 1,
sort: 'descending',
category: 'all',
query: ''
};
}
update(newState) {
this._state = { ...this._state, ...newState };
this.persist();
}
persist() {
const url = new URL(window.location);
Object.entries(this._state).forEach(([key, value]) => {
if (value) url.searchParams.set(key, value);
else url.searchParams.delete(key);
});
history.pushState({ path: window.location.pathname, ...this._state }, '', url);
}
get current() {
return this._state;
}
restoreFromUrl() {
const urlParams = new URLSearchParams(window.location.search);
this._state = {
page: urlParams.get('page') || 1,
query: urlParams.get('name') || '',
sort: urlParams.get('sort') || 'descending',
category: urlParams.get('category') || 'all'
};
return this._state;
}
}
export { AdminState };
현 프로젝트 어드민의 경우 겟 요청시 주소에 총 4가지 정보를 담아서 보내는데
페이지, 검색쿼리, sort필터정보, 카테고리필터정보 이렇게 4가지이다.
class AdminState {
constructor() {
this._state = {
page: 1,
sort: 'descending',
category: 'all',
query: ''
};
}
따라서 초기 클래스 설정 때 이 4가지를 반영해서 구축이 되어있고 이는 프로젝트마다 커스터마이즈가 필요할 것이다.
나머지는 유저의 액션에 따른 스테이트 변화를 주는 메서드들에 대한 코드들인데 하나하나 살펴보면
update(newState) {
this._state = { ...this._state, ...newState };
this.persist();
}
업데이트 메서드는 현재 상태를 새로운 상태로 업데이트 해준다.
- 스프레드 연산자(...)를 사용해 기존 상태를 유지하면서 새로운 값만 덮어쓰는 구조.
- 업데이트 후 자동으로 persist() 메서드를 호출.
persist() {
const url = new URL(window.location);
Object.entries(this._state).forEach(([key, value]) => {
if (value) url.searchParams.set(key, value);
else url.searchParams.delete(key);
});
history.pushState({ path: window.location.pathname, ...this._state }, '', url);
}
persist 메서드는 현재 상태를 URL의 쿼리 파라미터로 저장한다. (현재상태, 즉 state -> URL에 반영)
- 브라우저 히스토리에 현재 상태를 추가.
예를 들어, 사용자가 페이지를 2로 변경하고 카테고리를 'books'로 변경하면
URL이 /admin?page=2&category=books&sort=descending으로 업데이트됨
restoreFromUrl() {
const urlParams = new URLSearchParams(window.location.search);
this._state = {
page: urlParams.get('page') || 1,
query: urlParams.get('name') || '',
sort: urlParams.get('sort') || 'descending',
category: urlParams.get('category') || 'all'
};
return this._state;
}
리스토어 메서드는 URL의 쿼리 파라미터에서 상태를 복원한다. (URL -> 현재상태(state) 복원)
- 페이지 새로고침이나 뒤로가기 후에도 이전 상태를 유지
- 각 파라미터가 없을 경우 기본값을 설정
아래는 프론트엔드 라우터 코드. 라우터는 페이지 이동과 관련된 부분을 제어해준다.
class Router {
constructor(routes) {
this.routes = routes;
this.currentPath = window.location.pathname;
this.initialPath = this.currentPath;
this.isInitialized = false;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => this.initialize());
} else {
this.initialize();
}
window.addEventListener('popstate', this.handlePopState.bind(this));
}
initialize() {
if (this.isInitialized) return;
history.replaceState(
{ path: this.initialPath },
'',
window.location.href
);
this.handleRoute();
this.isInitialized = true;
}
handlePopState(e) {
if (!e.state?.path) {
history.pushState(
{ path: this.initialPath },
'',
this.initialPath
);
}
this.handleRoute();
}
handleRoute() {
const path = window.location.pathname;
if (path === '/' && this.initialPath !== '/') {
history.pushState(
{ path: this.initialPath },
'',
this.initialPath
);
this.routes[this.initialPath]?.();
return;
}
const route = this.routes[path] || this.routes[404];
this.currentPath = path;
route?.();
}
navigateTo(url) {
if (url === this.currentPath) return;
history.pushState({ path: url }, '', url);
this.handleRoute();
}
}
export { Router };
특히 navigateTo를 많이 사용하게 되는데, 이 메서드가 페이지 이동을 시켜준다.
이 state와 router를 잘 활용하면 주소창과 내 화면상태의 일치화와 원활한 페이지 이동이 가능하다.
아래는 이번 프로젝트 어드민에서의 활용 사례 중 일부.
import { Router } from '../common/router.js';
import { AdminState } from './adminState.js';
import { getAsset } from './adminGetAsset.js';
import { getMainCategory } from '../module/categoryModule.js';
import { loadAssetInfo } from './loadAssetInfo.js';
import { setupPagination } from '../common/pagination.js';
const state = new AdminState();
~~~생략~~~
function renderAdminPage() {
const currentState = state.restoreFromUrl();
const { page, query, sort, category } = currentState;
const addAssetBtn = document.getElementById('admin-add-asset-btn');
const listControl = document.querySelector('admin-listcontrol-component');
listControl.style.display = 'block';
addAssetBtn.style.display = 'block';
const { setupSortFilter, setupCategoryFilter, setupFilterButton } = setupFilters();
setupSortFilter();
setupCategoryFilter();
setupFilterButton();
getAsset(page, query, sort, category);
}
function renderAssetInfo() {
loadAssetInfo();
}
// 라우트 설정
const routes = {
'/admin': renderAdminPage,
'/admin-assetinfo': renderAssetInfo,
'404': () => console.log('Page not found')
};
// 라우터 초기화
const router = new Router(routes);
// 이벤트 리스너 설정
document.getElementById('admin-menu-asset-btn').addEventListener('click', () => {
router.navigateTo('/admin');
});
document.getElementById('admin-add-asset-btn').addEventListener('click', () => {
router.navigateTo('/admin-assetinfo');
});
document.getElementById('admin-search-btn').addEventListener('click', () => {
const searchInput = document.getElementById("admin-search-input");
const searchQuery = searchInput.value.trim();
state.update({ query: searchQuery, page: 1 });
const { page, query, sort, category } = state.current;
getAsset(page, query, sort, category);
});
export { router, state };
참고로 state를 활용하면서 페이지네이션 코드도 아래와같이 간단해졌다.
import { state } from '../admin/admin.js';
function setupPagination(totalPageNum, getDataCallback) {
const paginationContainer = document.getElementById("pagination-container");
if (!paginationContainer) return;
paginationContainer.innerHTML = "";
for (let i = 1; i <= totalPageNum; i++) {
const pageButton = document.createElement("button");
pageButton.textContent = i;
pageButton.addEventListener("click", () => {
state.update({ page: i });
const { page, query, sort, category } = state.current;
getDataCallback(page, query, sort, category);
});
paginationContainer.appendChild(pageButton);
}
}
export { setupPagination };
이 두가지를 잘 활용하면 앞으로도 원활한 페이지 관리를 할 수 있을 것 같다.