Hi, I’m Kang Byeong-hyeon.

..

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




1. 컴포넌트 생성 클래스

코어 Component 클래스 정의

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

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

  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!";
  }
}

전체 코드 통합

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




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);
    });
  }
}




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>
    `;
  }
}




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
      )
    );
  }
}

자식 컴포넌트(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);
    });
  }
}




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);
}

createRouter 함수

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

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

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];
    }
  }
}

상태를 구독하는 컴포넌트

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>
    `;
  }
}

Store 인스턴스 생성

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

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

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