Vanilla Javascript 로 클래스형 컴포넌트 개발하기 (1) - 컴포넌트와 상태관리

46579 단어 javscriptjavscript

우리 회사는 클라이언트 개발도 스프링부트 환경으로 개발하고 있어, 최초 화면 즉 로그인 화면이 서버쪽에서 제공하는 View 에 종속되어 있다. 레거시 시스템이기 때문에 HTML 과 Vanilla Javscript로 개발되어 있는다. 그러던 중 고객사 추가요구사항을 개발하기 위해 로그인 페이지에 모달 개발이 필요해졌다. 하지만 html 에 script 로 자바스크립트를 넣어 통으로 조작하는 구조로는 모달에 필요한 요소를 컴포넌트화해 상태관리하는 것이 어렵기 때문에, 임시 방편으로 Vanilla Javscript 로 클래스형 컴포넌트를 만들어 개발하게 되었다.
또한, 다른 클라이언트 프로젝트가 클래스형 컴포넌트로 개발되어 있어 코드 일관성을 유지하기도 좋을것같단 판단에 도입을 결정하게 되었다.

아래 내용은 컴포넌트 기반 개발이 상용화된 역사와, 이의 장점에 대한 설명이다.

1. 컴포넌트와 상태관리

🤩 상태관리의 탄생

우리 팀의 레거시 시스템에는 JQuery 를 사용하고 있다. 이는 React 와 같은 CSR SPA 프레임워크가 나타나기 전 가장 많이 사용됐던, DOM 조작 라이브러리이다

JQuery 란 ?

  • Jquery는 빠르고 작고 기능이 풍부한 Javscript 라이브러리이다.
  • Jquery API 는 크로스 브라우징을 지원한다.
  • DOM, Event, Animation 및 Ajax 와 같은 작업을 훨씬 간단하게 만든다.

JQuery 를 사용하면, API 를 사용해 간결한 코드로 DOM 조작을 쉽게할 수 있게 된다. 이번 회사에서 레거시 시스템 유지보수를 하면서 처음 접했지만, 생각했던 것보다 더 간편하고 가독성이 좋은 라이브러리라는 생각이 들었다.

하지만, 점점 브라우저와 javscript 가 발전하는 과정에서 아예 브라우저(클라이언트) 단에서 렌더링을 하고, 서버에서는 REST API 혹은 GraphQL 같이 브라우저 렌더링에 필요한 데이터만 제공하는 형태로 기술이 변화했다.

상태(State)를 기준으로 DOM 을 렌더링하는 형태로 발전하게 된것이다. 다시 말해, DOM 이 변하는 경우가 State 에 종속 되어 버린것. 반대로 말하면 State 가 변하지 않을 경우 DOM 이 변하면 안되는 것이다.

이러한 과정 속에서 Client-Side Rendering 이라는 개념과 상태관리 라는 개념이 생기게 되었다.

💁 그래서 컴포넌트 단위로 상태를 관리하고, 렌더링하게 된것이지

AngularCSR 의 시작이었다면, React컴포넌트 기반 개발 의 시작이었다. 그리고 두 프레임워크의 장점을 모두 수용한 Vue 가 나왔다.

중요한 점은 현 시점의 웹 어플리케이션은 컴포넌트 단위로 설계되고 개발된다는 것이다. 그리고 컴포넌트마다 각각 컴포넌트를 렌더링할 때 필요한 상태를 지역적으로 관리하게 되었다.

1. state-setState-render

<div id="app"></div>
<script>
const $app = document.querySelector('#app');

let state = {
  items: ['item1', 'item2', 'item3', 'item4']
}

const render = () => {
  const { items } = state;
  $app.innerHTML = `
    <ul>
      ${items.map(item => `<li>${item}</li>`).join('')}
    </ul>
    <button id="append">추가</button>
  `;
  document.querySelector('#append').addEventListener('click', () => {
    setState({ items: [ ...items, `item${items.length + 1}` ] })
  })
}

const setState = (newState) => {
  state = { ...state, ...newState };
  render();
}

render();
</script>

코드의 동작원리는 다음과 같다.

  • state 가 변경되면, render를 실행
  • state 는 setState 로만 변경해야 함.

이를 통해, 브라우저에 출력되는 내용은 무조건 state 에 종속시킬 수 있다. 즉, DOM 을 직접 조작할 필요가 없게 되는 것이다.

2. 추상화

Component.js


class Component {

  $target;
  $state;
  
  constructor ($target, $props) { 
    this.$target = $target;
    this.$props=$props; 
    this.setup();
    this.setEvent(); // constructor 에서 딱 한번만 실행.
    this.render();
  }
  
  setup () {};
  mounted() {}; 				// render 후에 실행되는 부분 
  template () { return ''; }	// 컴포넌트 템플릿 
  
  render () {					// 렌더링
    this.$target.innerHTML = this.template();
    this.mounted(); // render 후에 mounted 가 실행된다.
  }
  setEvent () {}
  setState (newState) { 		// 상태변경되면 리렌더링
    this.$state = { ...this.$state, ...newState };
    this.render();
  }
  addEvent (eventType, selector, callback) {
    const children = [ ...this.$target.querySelectorAll(selector) ]; 
    // selector에 명시한 것 보다 더 하위 요소가 선택되는 경우가 있을 땐
    // closest를 이용하여 처리한다.
    const isTarget = (target) => children.includes(target)
                                 || target.closest(selector);
    this.$target.addEventListener(eventType, event => {
      if (!isTarget(event.target)) return false;
      callback(event);
    })
  }
}
export default Component;

3. 이벤트 처리

render 가 실행될때마다 이벤트가 새로 등록되지 않도록 (이벤트 버블링 해결)

  • setEvent()가 constructor 에서 딱 한번만 실행되게 한다.
  • event를 각각의 하위 요소가 아니라 component 의 target 자체해 등록해 놓는 것이다.
  • 따라서 component 가 생성되는 시점에만 이벤트 등록을 해놓으면 추가로 등록할 필요가 없어진다.

이벤트 버블링 추상화

  • 이벤트 버블링을 통한 등록 과정을 메소드로 만들어서 사용하면 코드가 더 깔끔해진다.
export default class Component {
  $target;
  $state;
  constructor ($target) { /* 생략 */ }
  setup () { /* 생략 */ }
  template () { /* 생략 */ }
  render () { /* 생략 */ }
  setEvent () { /* 생략 */ }
  setState (newState) { /* 생략 */ }

  addEvent (eventType, selector, callback) {
    const children = [ ...this.$target.querySelectorAll(selector) ]; 
    // selector에 명시한 것 보다 더 하위 요소가 선택되는 경우가 있을 땐
    // closest를 이용하여 처리한다.
    const isTarget = (target) => children.includes(target)
                                 || target.closest(selector);
    this.$target.addEventListener(eventType, event => {
      if (!isTarget(event.target)) return false;
      callback(event);
    })
  }

}

이렇게 작성한 메소드는 다음과 같이 사용하면 된다.

export default class Items extends Component {
  setup () { /* 생략 */ }
  template () {/* 생략 */ }
  setEvent () {
    // target addBtn 클래스 click 이벤트 직접 등록
    this.addEvent('click', '.addBtn', ({ target }) => {
      const { items } = this.$state;
      this.setState({ items: [ ...items, `item${items.length + 1}` ] });
    });
    
    // target delete 클래스에 click 이벤트 직접 등록
    this.addEvent('click', '.deleteBtn', ({ target }) => {
      const items = [ ...this.$state.items ];
      items.splice(target.dataset.index, 1);
      this.setState({ items });
    });
  }
}

4. 컴포넌트 분할

진짜 컴포넌트 기반 개발을 하기 위헤 로직을 분리해 새로운 클래스들을 만들어주면 아래와 같은 코드가 될 수 있다.

import Component from "./core/Component.js";
import Items from "./components/Items.js";
import ItemAppender from "./components/ItemAppender.js";
import ItemFilter from "./components/ItemFilter.js";

export default class App extends Component {

  setup () {
    this.$state = {
      isFilter: 0,
      items: [
        {
          seq: 1,
          contents: 'item1',
          active: false,
        },
        {
          seq: 2,
          contents: 'item2',
          active: true,
        }
      ]
    };
  }

  template () {
    return `
      <header data-component="item-appender"></header>
      <main data-component="items"></main>
      <footer data-component="item-filter"></footer>
    `;
  }

  // mounted에서 자식 컴포넌트를 마운트 해줘야 한다.
  mounted () {
    const { filteredItems, addItem, deleteItem, toggleItem, filterItem } = this;
    const $itemAppender = this.$target.querySelector('[data-component="item-appender"]');
    const $items = this.$target.querySelector('[data-component="items"]');
    const $itemFilter = this.$target.querySelector('[data-component="item-filter"]');

    // 하나의 객체에서 사용하는 메소드를 넘겨줄 bind를 사용하여 this를 변경하거나,
    // 다음과 같이 새로운 함수를 만들어줘야 한다.
    // ex) { addItem: contents => addItem(contents) }
    new ItemAppender($itemAppender, {
      addItem: addItem.bind(this)
    });
    new Items($items, {
      filteredItems,
      deleteItem: deleteItem.bind(this),
      toggleItem: toggleItem.bind(this),
    });
    new ItemFilter($itemFilter, {
      filterItem: filterItem.bind(this)
    });
  }

  get filteredItems () {
    const { isFilter, items } = this.$state;
    return items.filter(({ active }) => (isFilter === 1 && active) ||
      (isFilter === 2 && !active) ||
      isFilter === 0);
  }

  addItem (contents) {
    const {items} = this.$state;
    const seq = Math.max(0, ...items.map(v => v.seq)) + 1;
    const active = false;
    this.setState({
      items: [
        ...items,
        {seq, contents, active}
      ]
    });
  }

  deleteItem (seq) {
    const items = [ ...this.$state.items ];;
    items.splice(items.findIndex(v => v.seq === seq), 1);
    this.setState({items});
  }

  toggleItem (seq) {
    const items = [ ...this.$state.items ];
    const index = items.findIndex(v => v.seq === seq);
    items[index].active = !items[index].active;
    this.setState({items});
  }

  filterItem (isFilter) {
    this.setState({ isFilter });
  }

}

자식 컴포넌트

ItemAppender.js

import Component from "../core/Component.js";

export default class ItemAppender extends Component {

  template() {
    return `<input type="text" class="appender" placeholder="아이템 내용 입력" />`;
  }

  setEvent() {
    const { addItem } = this.$props;
    this.addEvent('keyup', '.appender', ({ key, target }) => {
      if (key !== 'Enter') return;
      addItem(target.value);
    });
  }
}

마치며

이제 위의 방법을 적용해서, 로그인 화면을 개발하기만 하면 된다 :) 근데 지금 모든 이벤트 등록이 jquery 로 되어 있고, Ajax 로 비동기 통신하는 부분이 많아. 이부분까지 잘 고려한 클래스형 컴포넌트를 설게해야 될 것같다.

레거시 시스템으로 개발할 때마다 다시금 리액트의 위대함에 대해 깨닫고,, 그리고 이런 구조를 만들어내기 위해서 그동안 수없이 시행착오 하셨을 조상 개발자(?) 님들의 수고가 대단한것같다. 새로운 기술로 트렌디하게 개발하는 것도 좋지만, 이렇게 전신이되는 기술을 익히고 하나하나 뜯어보며 동작원리를 익히는 과정도 재밌는 것 같다.

출처

좋은 웹페이지 즐겨찾기