바닐라 자바스크립트로 SPA를 구현할 때 컴포넌트 패턴을 활용하면 코드의 재사용성과 유지보수성을 크게 향상시킬 수 있다.
이 글에서는 React와 같은 프레임워크 없이 순수 자바스크립트만으로 컴포넌트 기반 아키텍처를 구현하는 방법을 알아본다.
바닐라 JS 컴포넌트 설계 패턴
애플리케이션 진입점 설정
SPA의 시작점에서 애플리케이션을 초기화한다.
import App from './App.js';
const $app = document.querySelector('#app');
new App($app);
App 컴포넌트가 #app DOM 요소에 마운트되어 애플리케이션이 시작된다. $app 요소는 전체 애플리케이션의 최상위 컨테이너 역할을 한다.
부모 컴포넌트 구현
부모 컴포넌트는 애플리케이션의 상태를 관리하고 자식 컴포넌트들을 초기화하는 역할을 한다. React의 상태 관리와 유사한 패턴을 따른다.
export default function App($app) {}
상태 관리
this.state = {
data: [],
currentPage: window.location.pathname
};
애플리케이션 전체 상태를 객체로 관리하며, 여러 자식 컴포넌트가 공유할 데이터를 포함한다.
자식 컴포넌트 초기화
const childComponent = new ChildComponent({
$app,
initialState: this.state.data,
handleEvent: (id) => {
// 이벤트 핸들러 로직
}
})
자식 컴포넌트를 생성할 때 부모 DOM 요소, 초기 상태, 이벤트 핸들러를 전달한다.
상태 업데이트 함수
this.setState = (newState) => {
this.state = { ...this.state, ...newState };
childComponent.setState(this.state.data);
}
불변성을 유지하며 상태를 업데이트하고, 관련된 자식 컴포넌트의 상태도 함께 업데이트한다.
초기 데이터 로드
const init = async () => {
const data = await fetchData();
this.setState({ data });
}
init();
API 호출 등 비동기 작업을 수행하여 초기 데이터를 로드한다.
자식 컴포넌트 구현
자식 컴포넌트는 실제 UI를 렌더링하고 사용자 이벤트를 처리한다.
export default function ChildComponent({ $app, initialState, handleEvent }) {}
상태 및 DOM 설정
this.state = initialState;
this.$target = document.createElement('div');
this.$target.className = 'component-class';
$app.appendChild(this.$target);
컴포넌트의 상태를 초기화하고, 새로운 DOM 요소를 생성하여 부모 요소에 추가한다.
템플릿 정의
this.template = () => {
return /* HTML 문자열 생성 */;
};
컴포넌트의 상태를 기반으로 HTML 문자열을 생성하는 함수다. 상태가 변경될 때마다 새로운 HTML을 생성한다.
렌더링 함수
this.render = () => {
this.$target.innerHTML = this.template();
this.$target.querySelectorAll('.item').forEach(el => {
el.addEventListener('click', () => handleEvent(el.id));
});
};
생성된 HTML을 DOM에 반영하고, 필요한 이벤트 리스너를 등록한다.
초기 렌더링
this.render();
컴포넌트 초기화 시 첫 렌더링을 실행한다.
실전 예제: PokemonList 컴포넌트
위 패턴을 적용하여 포켓몬 목록을 표시하는 컴포넌트를 구현해보자.
App.js에서 컴포넌트 초기화
const pokemonList = new PokemonList({
$app,
initialState: this.state.pokemonList,
handleItemClick: (id) => {
history.pushState(null, null, `/detail/${id}`);
},
handleTypeClick: (type) => {
this.setState({
...this.state,
type
});
history.pushState(null, null, `/type`);
}
});
부모 컴포넌트에서 PokemonList를 생성할 때 초기 상태와 이벤트 핸들러를 전달한다. handleItemClick은 포켓몬 카드 클릭 시, handleTypeClick은 타입 태그 클릭 시 실행된다.
템플릿 구현
this.template = () => {
if (!this.state || this.state.length === 0) {
return '<div class="loading">로딩 중...</div>';
}
return this.state.map(pokemon => `
<div class="pokemon-wrapper">
<div class="img-wrapper" id="${pokemon.id}">
<img src="${pokemon.image}" alt="${pokemon.name}">
</div>
<div class="pokemon-info">
<div class="index">No.${pokemon.id}</div>
<div class="name">${pokemon.name}</div>
<div class="type">
${pokemon.types.map(type =>
`<span class="type-tag" style="background-color: ${setPokemonType(type).color}">${type}</span>`
).join('')}
</div>
</div>
</div>
`).join('');
};
상태 배열의 각 포켓몬 데이터를 HTML 문자열로 변환한다. 로딩 상태일 때는 로딩 메시지를 표시하고, 데이터가 있을 때는 포켓몬 카드 목록을 생성한다.
이벤트 처리 구현
this.render = () => {
this.$target.innerHTML = this.template();
this.$target.querySelectorAll('.img-wrapper').forEach(item => {
item.addEventListener('click', () => {
this.handleItemClick(item.id);
});
});
this.$target.querySelectorAll('.type-tag').forEach(tag => {
tag.addEventListener('click', (e) => {
e.stopPropagation();
this.handleTypeClick(tag.innerText);
});
});
};
렌더링 후 각 요소에 이벤트 리스너를 등록한다.
포켓몬 이미지 클릭 시 상세 페이지로 이동하고, 타입 태그 클릭 시 해당 타입으로 필터링한다.
e.stopPropagation()으로 이벤트 버블링을 방지하여 타입 태그 클릭 시 부모 요소의 클릭 이벤트가 발생하지 않도록 한다.
컴포넌트 패턴의 장점
- 재사용성: 독립적인 컴포넌트를 여러 곳에서 재사용할 수 있다
- 유지보수성: 각 컴포넌트가 독립적이므로 수정이 다른 부분에 영향을 미치지 않는다
- 명확한 책임 분리: 부모는 상태 관리, 자식은 UI 렌더링과 이벤트 처리를 담당한다
- 확장성: 새로운 컴포넌트를 추가하거나 기존 컴포넌트를 수정하기 쉽다
'Frontend > JavaScript' 카테고리의 다른 글
| Vanilla JS로 클라이언트 사이드 라우팅과 검색 기능 구현하기 (0) | 2025.11.10 |
|---|---|
| 화살표 함수와 객체 (0) | 2025.11.09 |
| API 호출 (0) | 2025.10.31 |
| async와 await (0) | 2025.10.31 |
| Promise 객체 (0) | 2025.10.31 |
