🍪 카카오 테크 캠퍼스 2기

[카카오 테크 캠퍼스 2기] 1단계 7주차 WIL - SPA 개발을 위한 컴포넌트 기반 아키텍처 구축하기

kangkibong 2024. 5. 27. 19:37

1. 컴포넌트 생성 클래스

코어 Component 클래스 정의

먼저, 코어 Component 클래스를 정의하기 위해 payload 인수를 받아 tagName과 같은 속성을 지정하여 동적으로 HTML 요소를 생성할 수 있게 해준다.

 

export class Component {
  constructor(payload = {}) {
    const { tagName = 'div' } = payload;
    this.el = document.createElement(tagName);
    this.render();
  }

  render() {}
}

 

  • 생성자: 생성자는 payload 객체를 인수로 받아, 구조 분해 할당을 통해 tagName을 추출하고 기본값은 'div'로 설정한다.
  • 요소 생성: this.el은 지정된 tagName을 사용하여 새로 생성된 요소로 설정된다.
  • 렌더 메서드: render 메서드가 호출되고 하위 클래스에서 특정 렌더링 로직을 정의하기 위해 재정의될 수 있도록 설계한다.

 

App 컴포넌트 확장

코어 Component 클래스를 확장하여 커스텀 컴포넌트를 만든다.

하위 클래스는 자체 tagName을 지정하고 커스텀 렌더링 로직을 정의한다.

 

import { Component } from './core/heropy';

export default class App extends Component {
  constructor() {
    super({ tagName: 'h1' });
  }

  render() {
    this.el.textContent = 'Hello World!';
  }
}

 

  • 생성자: App 클래스는 Component 클래스를 확장하고, 생성자는 super()를 호출하여 tagName: 'h1'을 지정하는 객체를 전달하여 <h1> 요소를 생성한다.
  • 렌더 메서드: render 메서드를 재정의하여 요소의 textContent를 "Hello World!"로 설정한다.

 

전체 코드 통합

const app = new App();
document.body.append(app.el);

 

  • 인스턴스화: App의 인스턴스를 생성한다.
  • DOM 조작: "Hello World!" 텍스트가 포함된 <h1> 요소를 문서의 본문에 추가한다.

 

 

2. 선언적 렌더링과 이벤트 핸들링

입력(input) 요소와 버튼(button) 요소를 만들어, 버튼을 클릭하면 입력 값이 콘솔에 출력되는 코드를 작성한다.

이를 위해 선언적 렌더링과 이벤트 핸들링을 구현해본다.

 

코어 Component state 설정

export class Component {
  constructor(payload = {}) {
    const { tagName = 'div', state = {} } = payload;
    this.el = document.createElement(tagName);
    this.state = state;
    this.render();
  }

  render() {}
}

 

먼저, payload 객체를 받아 tagNamestate를 설정한다. state는 기본값으로 빈 객체 {}를 갖도록 한다.

 

App 컴포넌트 확장

input 요소와 button 요소를 생성하고, 입력 이벤트와 클릭 이벤트를 처리하도록 한다.

 

import { Component } from './core/heropy';

export default class App extends Component {
  constructor() {
    super({ state: { inputText: '' } });
  }

  render() {
    this.el.classList.add('search');
    this.el.innerHTML = `
      <input />
      <button>Click!</button>
    `;

    const inputEl = this.el.querySelector('input');
    inputEl.addEventListener('input', () => {
      this.state.inputText = inputEl.value;
    });

    const buttonEl = this.el.querySelector('button');
    buttonEl.addEventListener('click', () => {
      console.log(this.state.inputText);
    });
  }
}

 

  • 생성자: 생성자에서 state{ inputText: '' }로 초기화한다.
  • 렌더 메서드: render 메서드를 재정의하여 요소를 렌더링하고, 클래스 이름을 추가한다.
  • HTML 설정: this.el.innerHTML을 통해 inputbutton 요소를 생성한다.
  • 이벤트 핸들링:
    • input 요소에 input 이벤트 리스너를 추가하여 입력 값을 this.state.inputText에 저장한다.
    • button 요소에 click 이벤트 리스너를 추가하여 현재 this.state.inputText 값을 콘솔에 출력한다.

 

 

3. 조건과 반복을 활용한 컴포넌트 렌더링

App 컴포넌트 확장

상태로 갖는 과일 목록을 가격이 3000 미만인 과일들만 렌더링하도록 한다.

 

import { Component } from './core/heropy';

export default class App extends Component {
  constructor() {
    super({
      state: {
        fruits: [
          { name: 'Apple', price: 1000 },
          { name: 'Banana', price: 2000 },
          { name: 'Cherry', price: 3000 },
        ],
      },
    });
  }

  render() {
    this.el.innerHTML = `
    <h1>Fruits</h1>
    <ul>
      ${this.state.fruits
        .filter(fruit => fruit.price < 3000)
        .map(fruit => `<li>${fruit.name}</li>`)
        .join('')}
    </ul>
    `;
  }
}

 

  • 생성자: state에 과일 목록을 설정한다.
  • 렌더 메서드: render 메서드를 재정의하여 과일 목록을 렌더링한다.
    • 필터링: this.state.fruits 배열을 filter 메서드를 사용하여 가격이 3000 미만인 과일들만 추출한다.
    • 매핑: map 메서드를 사용하여 각 과일 객체를 <li> 요소로 변환한다.
    • 문자열 병합: join('')을 사용하여 배열을 하나의 문자열로 결합한다.
  • HTML 설정: 필터링되고 매핑된 과일 목록을 포함하는 HTML 구조를 this.el.innerHTML에 설정한다.

 

 

4. 자식 컴포넌트에게 데이터 전달

코어 Component props 설정

export class Component {
  constructor(payload = {}) {
    const { tagName = 'div', state = {}, props = {} } = payload;
    this.el = document.createElement(tagName);
    this.state = state;
    this.props = props;
    this.render();
  }

  render() {}
}

 

먼저, payload 객체를 받아 props를 설정한다. props는 기본값으로 빈 객체 {}를 갖도록 한다.

 

App 컴포넌트 확장

상태로 갖는 과일 목록을 가지고, 각 과일 항목을 자식 자식 컴포넌트인 FruitItem에게 전달한다.

 

import { Component } from './core/heropy';
import FruitItem from './components/FruitItem';

export default class App extends Component {
  constructor() {
    super({
      state: {
        fruits: [
          { name: 'Apple', price: 1000 },
          { name: 'Banana', price: 2000 },
          { name: 'Cherry', price: 3000 },
        ],
      },
    });
  }

  render() {
    this.el.innerHTML = `
    <h1>Fruits</h1>
    <ul></ul>
    `;

    const ulEl = this.el.querySelector('ul');
    ulEl.append(
      ...this.state.fruits.map(
        fruit =>
          new FruitItem({
            props: {
              name: fruit.name,
              price: fruit.price,
            },
          }).el,
      ),
    );
  }
}

 

  • 자식 컴포넌트 생성: this.state.fruits 배열을 순회하며 각 과일 항목에 대해 FruitItem 인스턴스를 생성하고, 이를 <ul> 요소에 추가한다.

 

자식 컴포넌트(FruitItem) 구현

FruitItem 컴포넌트는 Component 클래스를 상속받아 각 과일 항목의 정보를 표시한다.

 

jsx코드 복사
import { Component } from '../core/heropy';

export default class FruitItem extends Component {
  constructor(payload) {
    super({ tagName: 'li', props: payload.props });
  }

  render() {
    this.el.innerHTML = `
      <span>${this.props.name}</span>
      <span>${this.props.price}</span>
    `;

    this.el.addEventListener('click', () => {
      console.log(this.props.name, this.props.price);
    });
  }
}

 

  • 생성자: FruitItem 클래스는 Component 클래스를 상속하며, tagName'li'로 설정하고 props를 전달받는다.
  • 렌더 메서드: render 메서드를 재정의하여 과일 이름과 가격을 표시한다.
  • 이벤트 핸들링: 각 과일 항목을 클릭하면 해당 과일의 이름과 가격을 콘솔에 출력한다.

 

 

5. 해쉬 라우터 관리

SPA(Single Page Application) 구현을 위해, URL 해쉬를 기반으로 페이지를 라우팅하는 방법이다.

이를 통해 전체 페이지를 다시 로드하지 않고도 URL 변경에 따라 다른 컴포넌트를 렌더링할 수 있다.

 

routeRender 함수

해쉬 변경 시 적절한 컴포넌트를 찾아 렌더링할 수 있도록 하는 routeRender함수를 작성한다.

 

function routeRender(routes) {
  // URL에 해쉬가 없는 경우 기본 해쉬 설정
  if (!location.hash) {
    history.replaceState(null, '', '/#/');
  }

  const routerView = document.querySelector('router-view');

  // 해쉬와 쿼리 문자열 분리
  const [hash, queryString = ''] = location.hash.split('?');

  // 쿼리 문자열을 객체로 변환
  const query = queryString.split('&').reduce((acc, cur) => {
    const [key, value] = cur.split('=');
    acc[key] = value;
    return acc;
  }, {});
  // 쿼리 객체를 히스토리 상태로 설정
  history.replaceState(query, '', '');

  // 현재 해쉬에 맞는 라우트 찾기
  const currentRoute = routes.find(route => new RegExp(`${route.path}/?$`).test(hash));

  // router-view 요소 비우고 새 컴포넌트 렌더링
  routerView.innerHTML = ``;
  routerView.append(new currentRoute.component().el);

  // 스크롤을 최상단으로 이동
  window.scrollTo(0, 0);
}

 

  • 기본 해쉬 설정: 페이지가 처음 로드될 때 해쉬가 없으면 기본 해쉬를 /#/로 설정한다.
  • router-view 요소 선택: 라우터가 컴포넌트를 렌더링할 router-view 요소를 선택한다.
  • 해쉬와 쿼리 문자열 분리: location.hash를 사용하여 해쉬와 쿼리 문자열을 분리한다.
  • 쿼리 문자열 파싱:
    • 쿼리 문자열을 &로 분리하고, 각 쿼리 파라미터를 =로 분리하여 객체 형태로 변환한다.
    • 이 쿼리 객체를 history.replaceState를 통해 히스토리 상태로 설정한다.
  • 현재 라우트 찾기: 라우트 배열에서 현재 해쉬에 맞는 라우트를 정규식을 사용하여 찾는다.
  • 컴포넌트 렌더링:
    • router-view 요소를 비우고, 현재 라우트에 맞는 컴포넌트를 생성하여 추가한다.
    • 화면을 최상단으로 스크롤되도록 한다.

 

createRouter 함수

라우터를 초기화하고, 해쉬 변경 시 routeRender 함수를 호출하도록 이벤트 리스너를 설정한다.

 

export function createRouter(routes) {
  return function () {
    window.addEventListener('popstate', () => {
      routeRender(routes);
    });
    routeRender(routes);
  };
}

 

  • popstate 이벤트 리스너 추가: 브라우저의 뒤로 가기/앞으로 가기 버튼 클릭 시 popstate 이벤트가 발생한다. 이 때 routeRender 함수를 호출하여 현재 상태에 맞는 컴포넌트를 렌더링한다.
  • 초기 렌더링: 페이지 로드 시 routeRender 함수를 호출하여 초기 상태에 맞는 컴포넌트를 렌더링한다.

 

routeRendercreateRouter 함수 사용

import TheHeader from './components/TheHeader';
import { Component } from './core/heropy';

export default class App extends Component {
  render() {
    const routerView = document.createElement('router-view');
    this.el.append(new TheHeader().el, routerView);
  }
}

 

import App from './App';
import router from './routes';

const root = document.querySelector('#root');
root.append(new App().el);
router();

 

App 컴포넌트를 루트 요소에 추가하고, 라우터를 초기화하여 페이지 로드 시 라우터가 동작하도록 설정한다.

 

컴포넌트 예제

HomeAbout 컴포넌트를 정의하여 각각의 페이지에서 렌더링되도록 한다.

 

import { Component } from '../core/heropy';

export default class Home extends Component {
  render() {
    this.el.innerHTML = `<h1>Home</h1>`;
  }
}

import { Component } from '../core/heropy';

export default class About extends Component {
  render() {
    const { a, b, c } = history.state;
    this.el.innerHTML = `
    <h1>About</h1>
    <h2>${a}</h2>
    <h2>${b}</h2>
    <h2>${c}</h2>
    `;
  }
}

 

 

6. 상태 관리

Store 클래스를 사용하여 상태를 관리하고, 컴포넌트가 해당 상태를 구독(subscribe)하여 상태 변화에 따라 자동으로 업데이트되도록한다.

 

코어 Store 클래스 정의

Store 클래스는 상태(state)를 관리하고, 상태가 변경될 때 이를 구독하여 컴포넌트에 알리는 역할을 한다.

 

export class Store {
  constructor(state) {
    this.state = {};
    this.observers = {};

    for (const key in state) {
      Object.defineProperty(this.state, key, {
        get: () => state[key],
        set: val => {
          state[key] = val;
          if (this.observers[key]) {
            this.observers[key].forEach(observer => observer(val));
          }
        },
      });
    }
  }

  subscribe(key, cb) {
    if (Array.isArray(this.observers[key])) {
      this.observers[key].push(cb);
    } else {
      this.observers[key] = [cb];
    }
  }
}

 

  • 상태 초기화: constructor는 초기 상태를 받아 이를 this.state에 저장하고, 각 상태 값에 대해 gettersetter를 정의한다.
  • 상태 변경 감지: setter는 상태가 변경될 때마다 해당 상태를 구독하는 모든 콜백 함수를 호출한다.
  • 구독 관리: subscribe 메서드는 특정 상태 키를 구독하는 콜백 함수를 등록한다.

 

상태를 구독하는 컴포넌트

Message 컴포넌트는 Store의 상태를 구독하고, 상태가 변경될 때마다 자동으로 다시 렌더링된다.

 

import { Component } from '../core/heropy';
import messageStore from '../store/message';

export default class Message extends Component {
  constructor() {
    super();
    messageStore.subscribe('message', () => {
      this.render();
    });
  }

  render() {
    this.el.innerHTML = `
      <h2>${messageStore.state.message}</h2>
    `;
  }
}

 

  • 상태 구독: constructor에서 messageStoremessage 상태를 구독하여 상태가 변경될 때마다 render 메서드를 호출한다.
  • 렌더링: render 메서드는 현재 상태 값을 사용하여 컴포넌트의 내용을 업데이트한다.

 

Store 인스턴스 생성

Store 클래스를 이용하여 애플리케이션에서 사용할 상태를 관리하는 messageStore 인스턴스를 생성합니다.

 

import { Store } from '../core/heropy';

export default new Store({ message: 'Hello~' });

 

  • 초기 상태 설정: message라는 초기 상태 값을 설정하여 messageStore 인스턴스를 생성한다.