본문 바로가기

프론트엔드 멘토링/과제

1주차 과제 - Vanilla Javascript 로 웹 Component 만들기

 

 


1. 컴포넌트와 상태관리

*컴포넌트란?
- 재사용 가능한 웹의 구성요소, 독립된 모듈, 단위

 

 

 

먼저 컴포넌트의 설계의 기반이 되는 코드를 작성해본다.

 


 

2. state - setState - render

 

(1) setState 메서드를 통해 state를 기반으로 render 를 해주는 코드 작성하기

* state 가 변경되면 render 를 실행한다.

* state 는 setState 로만 변경해야한다.

이 규칙을 지키며 작성하면, 브라우저에 출력되는 내용은 무조건 state 에 종속된다. 즉,  DOM 을 직접적으로 다룰 필요가 없다.

<div id="app"></div>
  
  <script>
    const $app = document.querySelector('#app');
  
  // state 객체안에 items 배열을 선언
    let state = {
      items: ['item1', 'item2', 'item3', 'item4']
    }
  
  // render 함수 선언
    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}`] })
      })
      console.log(items)
      console.log(state)
    }
  
  // setState 함수 선언
    const setState = (newState) => {
      state ={ ...state, ...newState };
      render();
    }
    render();
  </script>

실행한 화면

 

 

2) 추상화하기

특정 데이터에 대해 로직을 적용하는 코드의 중복을 줄이는 방법은, 액션을 따로 파라미터화 하는 것이다.

이처럼 같은것을 묶고, 변하는 부분과 변하지 않는 부분을 잘 발라내어 코드의 중복을 줄이는 방법을 추상화라 한다.

<div id="app"></div>
<script>
class Component {
  $target;
  state;
  constructor ($target) { 
    this.$target = $target;
    this.setup();
    this.render();
  }
  setup () {};
  template () { return ''; }
  render () {
    this.$target.innerHTML = this.template();
    this.setEvent();
  }
  setEvent () {}
  setState (newState) {
    this.state = { ...this.state, ...newState };
    this.render();
  }
}

class App extends Component {
  setup () {
    this.state = { items: ['item1', 'item2'] };
  }
  template () {
    const { items } = this.state;
    return `
        <ul>
          ${items.map(item => `<li>${item}</li>`).join('')}
        </ul>
        <button>추가</button>
    `
  }
  
  setEvent () {
    this.$target.querySelector('button').addEventListener('click', () => {
      const { items } = this.state;
      this.setState({ items: [ ...items, `item${items.length + 1}` ] });
    }); 
  }
}

new App(document.querySelector('#app'));
</script>

실행한 화면

 

 

3) 모듈화하기

 

index.html

<!doctype html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title>Simple Component 2</title>
</head>
<body>
<div id="app"></div>
<script src="./src/app.js" type="module"></script>
</body>
</html>

 

 

src/app.js

import Items from "./components/Items.js";

class App {
  constructor() {
    const $app = document.querySelector('#app');
    new Items($app);
  }
}

new App();

 

 

src/components/Items.js

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

export default class Items extends Component {
  setup () {
    this.state = { items: ['item1', 'item2'] };
  }
  template () {
    const { items } = this.state;
    return `
      <ul>
        ${items.map(item => `<li>${item}</li>`).join('')}
      </ul>
      <button>추가</button>
    `
  }

  setEvent () {
    this.$target.querySelector('button').addEventListener('click', () => {
      const { items } = this.state;
      this.setState({ items: [ ...items, `item${items.length + 1}` ] });
    });
  }
}

 

 

 

src/core/Component.js

export default class Component {
  $target;
  state;
  constructor ($target) {
    this.$target = $target;
    this.setup();
    this.render();
  }
  setup () {};
  template () { return ''; }
  render () {
    this.$target.innerHTML = this.template();
    this.setEvent();
  }
  setEvent () {}
  setState (newState) {
    this.state = { ...this.state, ...newState };
    this.render();
  }
}

 

 


 

 

3. 이벤트 처리

 

(1) 불편함을 감지하기

각각의 아이템에 대한 삭제 기능 추가

 

src/components/Items.js

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

export default class Items extends Component {
  setup () {
    this.state = { items: ['item1', 'item2'] };
  }
  template () {
    const { items } = this.state;
    return `
      <ul>
        ${items.map((item, key) => `
          <li>
            ${item}
            <button class="deleteBtn" data-index="${key}">삭제</button>
          </li>
        `).join('')}
      </ul>
      <button class="addBtn">추가</button>
    `
  }

  setEvent () {
    this.$target.querySelector('.addBtn').addEventListener('click', () => {
      const { items } = this.state;
      this.setState({ items: [ ...items, `item${items.length + 1}` ] });
    });

    this.$target.querySelectorAll('.deleteBtn').forEach(deleteBtn =>
      deleteBtn.addEventListener('click', ({ target }) => {
        const items = [ ...this.state.items ];
        items.splice(target.dataset.index, 1);
        this.setState({ items });
      }))
  }
}

실행한 화면

 

(2) 이벤트 버블링

 

이때 이벤트 버블링을 사용하면 훨씬 직관적으로 처리할 수 있다.

 

이벤트 버블링 : 특정 화면 요소에서 이벤트가 발생했을 때 해당 이벤트가 더 상위의 화면 요소들로 전달되어 가는 특성을 의미.

src/components/Items.js

export default class Items extends Component {
  setup () {/* 생략 */}
  template () { /* 생략 */}
  setEvent () {
    // 모든 이벤트를 this.$target에 등록하여 사용하면 된다.
    this.$target.addEventListener('click', ({ target }) => {
      const items = [ ...this.state.items ];

      if (target.classList.contains('addBtn')) {
        this.setState({ items: [ ...items, `item${items.length + 1}` ] });
      }

      if (target.classList.contains('deleteBtn')) {
        items.splice(target.dataset.index, 1);
        this.setState({ items });
      }

    });
  }
}

 

 

그러나 기존의 setEvent 는 render 를 할 때마다 실행하기 때문에 core/Component.js 의 라이프 사이클을 변경해야한다.

 

src/core/Component.js

 export default class Component {
   $target;
   state;
   constructor ($target) {
     this.$target = $target;
     this.setup();
+    this.setEvent(); // constructor에서 한 번만 실행한다.
     this.render();
   }
   setup () {};
   template () { return ''; }
   render () {
     this.$target.innerHTML = this.template();
-    this.setEvent(); 
   }
   setEvent () {}
   setState (newState) {
     this.state = { ...this.state, ...newState };
     this.render();
   }
 }

실행한 화면

 

 

(3) 이벤트 버블링 추상화

 

src/core/Component.js

export default class Component {
  $target;
  state;
  constructor ($target) { /* 생략 */ }
  setup () { /* 생략 */ }
  template () { /* 생략 */ }
  render () { /* 생략 */ }
  setEvent () { /* 생략 */ }
  setState (newState) { /* 생략 */ }

  addEvent (eventType, selector, callback) {
    const children = [ ...this.$target.querySelectorAll(selector) ];
    this.$target.addEventListener(eventType, event => {
      if (!event.target.closest(selector)) return false;
      callback(event);
    })
  }

}

이렇게 작성한 메서드는 아래와 같이 사용하면 된다.

 

 

src/components/Items.js

export default class Items extends Component {
  setup () { /* 생략 */ }
  template () {/* 생략 */ }
  setEvent () {
    this.addEvent('click', '.addBtn', ({ target }) => {
      const { items } = this.state;
      this.setState({ items: [ ...items, `item${items.length + 1}` ] });
    });
    this.addEvent('click', '.deleteBtn', ({ target }) => {
      const items = [ ...this.state.items ];
      items.splice(target.dataset.index, 1);
      this.setState({ items });
    });
  }
}

 


 

 

4. 컴포넌트 분할하기

 

(1) 기능 추가

 

현재까지의 코드는 컴포넌트를 분리할 이유가 없는 상태이다. 따라서 Item 컴포넌트에 toggle, filter 등의 기능을 추가했을 때 어떤 문제점이 있는가 알아야한다.

 

src/components/Items.js

export default class Items extends Component {
  get filteredItems () {
    const { isFilter, items } = this.state;
    return items.filter(({ active }) => (isFilter === 1 && active) ||
                                        (isFilter === 2 && !active) ||
                                        isFilter === 0);
  }

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

  template() {
    return `
      <header>
        <input type="text" class="appender" placeholder="아이템 내용 입력" />
      </header>
      <main>
        <ul>
          ${this.filteredItems.map(({contents, active, seq}) => `
            <li data-seq="${seq}">
              ${contents}
              <button class="toggleBtn" style="color: ${active ? '#09F' : '#F09'}">
                ${active ? '활성' : '비활성'}
              </button>
              <button class="deleteBtn">삭제</button>
            </li>
          `).join('')}
        </ul>
      </main>
      <footer>
        <button class="filterBtn" data-is-filter="0">전체 보기</button>
        <button class="filterBtn" data-is-filter="1">활성 보기</button>
        <button class="filterBtn" data-is-filter="2">비활성 보기</button>
      </footer>
    `
  }

  setEvent() {
    this.addEvent('keyup', '.appender', ({ key, target }) => {
      if (key !== 'Enter') return;
      const {items} = this.state;
      const seq = Math.max(0, ...items.map(v => v.seq)) + 1;
      const contents = target.value;
      const active = false;
      this.setState({
        items: [
          ...items,
          {seq, contents, active}
        ]
      });
    });

    this.addEvent('click', '.deleteBtn', ({target}) => {
      const items = [ ...this.state.items ];;
      const seq = Number(target.closest('[data-seq]').dataset.seq);
      items.splice(items.findIndex(v => v.seq === seq), 1);
      this.setState({items});
    });

    this.addEvent('click', '.toggleBtn', ({target}) => {
      const items = [ ...this.state.items ];
      const seq = Number(target.closest('[data-seq]').dataset.seq);
      const index = items.findIndex(v => v.seq === seq);
      items[index].active = !items[index].active;
      this.setState({items});
    });

    this.addEvent('click', '.filterBtn', ({ target }) => {
      this.setState({ isFilter: Number(target.dataset.isFilter) });
    });
  }
}

위와 같이 Items 컴포넌트가 많은 일을 하게 되었다. 이럴 경우 코드 관리가 어렵고, 컴포넌트 단위로 활용할 수가 없는 상태이다.

기본적으로 컴포넌트는 재활용이 목적이므로 하나의 컴포넌트가 최대한 작은 단위의 일을 하도록 만들어야 한다.

 

 

(2) 폴더 구조

폴더 구조

기존의 entry 가 app.js 에서 main.js 로 바뀌었다.

app Component 를 추가했다.

Items 에서 ItemAppender, ItemFilter 등을 분리하였다.

 

 

 

(3) Component Core 변경

Component.js 에 props 와 mount 를 추가한다.

(props : 위 컴포넌트가 하위 컴포넌트에 값을 전달할 때 사용하는 속성)

export default class Component {
  $target;
  props;
  state;
  constructor ($target, props) {
    this.$target = $target;
    this.props = props; // props 할당
    this.setup();
    this.setEvent();
    this.render();
  }
  setup () {};
  mounted () {};
  template () { return ''; }
  render () {
    this.$target.innerHTML = this.template();
    this.mounted(); // render 후에 mounted가 실행 된다.
  }
  setEvent () {}
  setState (newState) { /* 생략 */ }
  addEvent (eventType, selector, callback) { /* 생략 */ }
}

 

 

 

(4) Entry Point 변경

기존에 app.js 가 아닌 main.js 를 가져온다.

 

index.html

 <!doctype html>
 <html lang="ko">
 <head>
   <meta charset="UTF-8">
   <title>Simple Component 8</title>
 </head>
 <body>
 <h1>Example #8</h1>
 <div id="app"></div>
<script src="src/main.js" type="module"></script>
 </body>
 </html>

 

 

 

(5) 컴포넌트 분할

src/app.js

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

}

 

src/component/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);
    });
  }
}

 

 

src/components/Items.js

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

export default class Items extends Component {

  template() {
    const { filteredItems } = this.props;
    return `
      <ul>
        ${filteredItems.map(({contents, active, seq}) => `
          <li data-seq="${seq}">
            ${contents}
            <button class="toggleBtn" style="color: ${active ? '#09F' : '#F09'}">
              ${active ? '활성' : '비활성'}
            </button>
            <button class="deleteBtn">삭제</button>
          </li>
        `).join('')}
      </ul>
    `
  }

  setEvent() {
    const { deleteItem, toggleItem } = this.props;

    this.addEvent('click', '.deleteBtn', ({target}) => {
      deleteItem(Number(target.closest('[data-seq]').dataset.seq));
    });

    this.addEvent('click', '.toggleBtn', ({target}) => {
      toggleItem(Number(target.closest('[data-seq]').dataset.seq));
    });

  }

}

 

 

src/components/ItemFilter.js

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

export default class ItemFilter extends Component {

  template() {
    return `
      <button class="filterBtn" data-is-filter="0">전체 보기</button>
      <button class="filterBtn" data-is-filter="1">활성 보기</button>
      <button class="filterBtn" data-is-filter="2">비활성 보기</button>
    `
  }

  setEvent() {
    const { filterItem } = this.props;
    this.addEvent('click', '.filterBtn', ({ target }) => {
      filterItem(Number(target.dataset.isFilter));
    });
  }
}

 

 

 

실행한 모습

 

 

 


출처: https://junilhwang.github.io/TIL/Javascript/Design/Vanilla-JS-Component/#_5-%E1%84%8F%E1%85%A5%E1%86%B7%E1%84%91%E1%85%A9%E1%84%82%E1%85%A5%E1%86%AB%E1%84%90%E1%85%B3-%E1%84%87%E1%85%AE%E1%86%AB%E1%84%92%E1%85%A1%E1%86%AF

 

 

 

Vanilla Javascript로 웹 컴포넌트 만들기 | 개발자 황준일

Vanilla Javascript로 웹 컴포넌트 만들기 9월에 넥스트 스텝open in new window에서 진행하는 블랙커피 스터디open in new window에 참여했다. 이 포스트는 스터디 기간동안 계속 고민하며 만들었던 컴포넌트

junilhwang.github.io