본문 바로가기

Frontend/JavaScript

[바닐라 자바스크립트로 SPA 만들기] 1. 컴포넌트 만들기

반응형

개요

프레임워크 없이 바닐라 자바스크립트로 웹 애플리케이션을 만들 수 있는가? 뭐 만들 수야 있지만 굳이..?라는 생각을 가지며 지내온 시간이 꽤 흘렀다. 그리고 사실 웹 개발 입문 당시 DOM 하나하나를 조작하며 노가다스럽게 만들어본 경험만 있을 뿐, 효율적으로 바닐라 자바스크립트를 활용하는 방법은 몰랐기 때문에 뭐부터 시작해야 할지 잘 몰랐다.

 

하지만 취업을 준비하면서 바닐라 자바스크립트만을 활용한 과제를 해결해야 하는 순간이 있었기에, 바닐라 자바스크립트를 리액트스럽게 활용하는 방법을 알긴 알아야겠다는 생각이 들었다. 게다가 최근 "프레임워크 없는 프론트엔드 개발"이라는 책을 읽게 되면서 실습 겸, 간단하게 바닐라 자바스크립트로 SPA를 만들어 보았다. 

 

 

컴포넌트 만들기

SPA의 구조를 잡기 위한 가장 첫 번째 목표는 다음과 같았다

  • document.createElement, appendChild 등 DOM을 직접 조작하지 않고 UI 만들기
  • 그렇게 만든 UI를 재사용할 수 있도록 컴포넌트화 시키기
  • 클라이언트 단 상태 관리를 통해 화면 렌더링하기

이 모든 것을 해결할 수 있는 참고자료를 찾았고, 해당 글의 예시를 기준으로 나만의 구조를 만들어 갔다. (이미 유명한 포스팅으로 정리가 정말 잘 되어 있다!)

 

폴더 구조

 

core 폴더에 컴포넌트 클래스를 만들어 놓은 것 말고는 일반적인 프로젝트 구조와 유사하다.

 

- components: 컴포넌트 모음

- pages: 페이지 모음

- App.js: App 컴포넌트

- index.js: 최초 진입 파일

 

 

 

 

 

각 파일을 자세히 살펴보자.

 

index.html

  • SPA의 유일한 HTML 문서
  • 리액트에서 public/index.html 문서로 볼 수 있다.
<!DOCTYPE html>
<html lang="en">
  <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" />
    <title>Javascript SPA</title>
  </head>
  <body>
    <div id="app"></div>
    <script src="./src/index.js" type="module"></script>
  </body>
</html>

 

src/index.js

  • 엔트리 파일로, App 컴포넌트를 렌더링 한다.
import App from './App.js';

new App(document.querySelector('#app'));

 

src/core/Component.js

  • 바닐라 자바스크립트로 컴포넌트를 만들 수 있게 하는 파일로, 위에서 언급했던 황준일 님의 포스팅의 결론이다. 
  • 이 컴포넌트 클래스를 기준으로 다른 컴포넌트들을 만들 수 있으며, 상태 관리를 통한 렌더링이 가능한 부분이다.
export default class Component {
  $target; //컴포넌트를 넣을 부모
  $props;
  $state;

  constructor($target, $props) {
    this.$target = $target;
    this.$props = $props;
    this.setup();
    this.setEvent();
    this.render();
  }

  setup() {} //컴포넌트 state 설정

  mounted() {} //컴포넌트가 마운트 되었을 때

  template() { //UI 구성 
    return '';
  }

  render() {
    this.$target.innerHTML = this.template(); //UI 렌더링
    this.mounted();
  }

  setEvent() {} //컴포넌트에서 필요한 이벤트 설정

  setState(newState) { //상태 변경 후 렌더링
    this.$state = { ...this.$state, ...newState };
    this.render();
  }

  addEvent(eventType, selector, callback) { //이벤트 등록 추상화
    this.$target.addEventListener(eventType, (event) => {
      if (!event.target.closest(selector)) return false;
      callback(event);
    });
  }
}

 

src/components/Counter.js

Component.js를 활용해 Counter 컴포넌트를 만들어보자.

 

1. state 설정

  • setup 메서드에서 state를 초기화하면 된다.
  setup() {
    this.$state = {
      counter: 0,
    };
  }

 

2. UI 구성

  • template 메서드에서 마크업 구현을 하면 된다.
  • 템플릿 리터럴을 통해 마크업을 작성함으로써 DOM을 직접 조작하지 않고 UI 만들 수 있게 되었다! (NO MORE createElement & appendChild)
  • 이렇게 만들어진 UI는 render 메서드의 target.innerHTML를 통해 렌더링 된다.
  template() {
    const { counter } = this.$state;
    return `
      <div>
        <h2>Counter Component</h2>
        <div>${counter}</div>
        <button class='up'>증가</button>
        <button class='down'>감소</button>
      </div>
    `;
  }

 

3. 상태 변경 함수

  • Component의 setState 메서드를 통해 상태를 변경할 수 있다.
  • 카운트 증가, 감소에 따른 상태 변경 함수를 작성하자.
  up(prev) {
    this.setState({ counter: prev + 1 });
  }

  down(prev) {
    this.setState({ counter: prev - 1 });
  }

 

4. 이벤트 설정

  • Component의 setEvent 메서드에 이벤트를 설정할 수 있다.
  • 이벤트를 생성할 때는 Component에서 추상화시켜 놓은 addEvent를 활용하면 된다.
  • 증가, 감소 버튼 클릭 시 상태 변경 동작을 위한 이벤트를 설정하자.
  setEvent() {
    this.addEvent('click', '.up', ({ target }) => {
      const prev = this.$state.counter;
      this.up(prev);
    });

    this.addEvent('click', '.down', ({ target }) => {
      const prev = this.$state.counter;
      this.down(prev);
    });
  }

 

최종 코드

  • Component 클래스를 활용해 아래와 같이 컴포넌트를 만들 수 있다.
  • 리액트의 클래스 컴포넌트와 비슷한 느낌이 난다!
import Component from '../core/Component.js';

export default class Counter extends Component {
  setup() {
    this.$state = {
      counter: 0,
    };
  }

  template() {
    const { counter } = this.$state;
    return `
      <div>
        <h2>Counter Component</h2>
        <div>${counter}</div>
        <button class='up'>증가</button>
        <button class='down'>감소</button>
      </div>
    `;
  }

  setEvent() {
    this.addEvent('click', '.up', ({ target }) => {
      const prev = this.$state.counter;
      this.up(prev);
    });

    this.addEvent('click', '.down', ({ target }) => {
      const prev = this.$state.counter;
      this.down(prev);
    });
  }

  up(prev) {
    this.setState({ counter: prev + 1 });
  }

  down(prev) {
    this.setState({ counter: prev - 1 });
  }
}

 

src/pages/CounterPage.js

카운터 컴포넌트를 페이지 컴포넌트에 넣어보자. 바로 App 컴포넌트에서 렌더링해도 무방하지만, 규모가 커지면 어차피 컴포넌트랑 페이지를 분리할 것이기 때문에 pages 폴더를 만들었다. (사실 별 이유 없고 그냥 구조 맞추고 싶었음 🙃)

  • template에서 렌더링 할 컴포넌트를 넣을 태그를 만들어야 한다.
  • 아래 코드에서는 data-component 속성을 넣은 div태그를 의미하며,  이 태그가 Counter 컴포넌트의 target이 되는 것이다.
  • 참고) data-* 은 커스텀 속성이다. 관련하여 여기를 참고하자.
//JSX로 치면 이런 느낌
<div data-component="counter-up">
    <Counter />
</div>
//CounterPage.js
import Component from '../core/Component.js';
import Counter from '../components/Counter.js';

export default class CounterPage extends Component {
  template() {
    return `
        <h1>Counter Page</h1>
        <div data-component="counter-up"></div>
    `;
  }

  mounted() {
    const $counter = this.$target.querySelector(
      '[data-component="counter-up"]'
    );
    new Counter($counter);
  }
}

 

src/App.js

마지막으로 App 컴포넌트에서 페이지 컴포넌트를 렌더링 하면 된다.

import Component from './core/Component.js';
import CounterPage from './pages/CounterPage.js';

export default class App extends Component {
  template() {
    return `
      <main data-component="counter-app"></main>
    `;
  }

  mounted() {
    const $counterApp = this.$target.querySelector(
      '[data-component="counter-app"]'
    );
    new CounterPage($counterApp);
  }
}

 

렌더링 결과

 

 

Reference

 

 

반응형