바닐라 자바스크립트로 웹 컴포넌트 만들기 - 2. 완성

ChoiYongHyeun·2024년 1월 27일
0

프로그래밍 공부

목록 보기
3/15

Vanilla Javascript로 웹 컴포넌트 만들기
본 게시글은 해당 블로거의 글을 공부하며 만들어졌습니다

지난번 웹 컴포넌트에 대해서 공부했으니 이번에는 스스로 만들어보자


컴포넌트 나누기

다음과 같은 컴포넌트를 만들기 위해서 컴포넌트를 나눠야 한다.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Component-study</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <div class="app"></div>
  </body>
  <script src="main.js" type="module"></script>
</html>

HTML 파일에는 컴포넌트들을 모두 담을 .app 태그 하나만 생성해준다.

컴포넌트들은 각각 작은 단위의 일들을 해야하기 때문에 컴포넌트들을 최대한 나눠준다.

엔트리 파일은 main.js

import App from './app.js';

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

App 컴포넌트를 렌더링 시킨다.


디렉터리 구조

├─ app.js
├─ index.html
├─ main.js /* 엔트리 파일 */
├─ src
│  ├─ components
│  │  ├─ ItemAppender.js
│  │  ├─ ItemFilter.js
│  │  └─ Items.js
│  └─ core
│     └─ Component.js

컴포넌트 클래스 보기

export default class Component {
  constructor(target, props) {
    this.target = target;
    this.props = props;
    this.setup();
    this.render();
    this.setEvent();
  }

  setup() {
    /* state 프로퍼티 설정 */
  }

  templet() {
    /* target 노드 하위 요소들의 태그 내용 */
  }

  render() {
    this.target.innerHTML = this.templet();
    this.mounted();
  }

  mounted() {
    /* 렌더링 이후 실행되는 메소드 */
  }

  setState(newState) {
    /* state 를 변경하는 함수 */
    this.state = { ...this.state, ...newState };
    this.render();
  }

  setEvent() {
    /* 이벤트 핸들러들을 등록하는 메소드 */
  }

  addEvent(eventType, selector, callback) {
    /* 이벤트 위임을 통해 이벤트 핸들링을 등록하는 함수 */
    this.target.addEventListener(eventType, (event) => {
      if (!event.target.closest(selector)) return;
      callback(event);
    });
  }
}

컴포넌트들의 메소드를 보자

컴포넌트에서 내용이 정의되지 않은 메소드들은 모두 하위 컴포넌트들에서 메소드 오버라이딩 되거나 사용이 되지 않는 메소드들이다.

Constructor

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

target 을 인수로 받아 target 태그 내부에 렌더링 하도록 하며

props 를 인수로 받는다.

props 는 하위 컴포넌트가 상위 컴포넌트에서 호출되어 렌더링 될 때 상위 컴포넌트가 가지고 있는 정보들을 받을 수 있게 해준다.

이후 상태를 생성하는 setup 메소드 , 컴포넌트를 렌더링 하는 render 메소드 , 이후 컴포넌트 내부 태그들에게 이벤트 핸들러를 장착하는 setEvent 메소드가 호출된다.

templet() / render() / mounted()

  templet() {
    /* target 노드 하위 요소들의 태그 내용 */
  }

  render() {
    this.target.innerHTML = this.templet();
    this.mounted();
  }

  mounted() {
    /* 렌더링 이후 실행되는 메소드 */
  }

templet 메소드 안에서는 target 태그 내부에 들어갈 컴포넌트들의 양식이 적힌다.

render 메소드에서는 target 태그 내부에 컴포넌트를 적고, mounted 라는 메소드를 호출한다.

mounted 메소드는 다양한 컴포넌트들을 하나의 상위 컴포넌트에서 조합해서 사용하기 위해 존재한다.

예를 들어 해당 컴포넌트의 조합을 만들기 위해선 우선 app 컴포넌트를 렌더링 해주고 app 컴포넌트 내부에 존재하는 다양한 태그를 target 값으로 받아 3가지의 하위 컴포넌트들에게 전달해줘야 한다.

태그를 전달하는 것 뿐이 아니라 상위 컴포넌트의 상태를 인수로 받아 변경하기 위해서도 필요하다.

그렇기에 mounted 메소드가 필요하다.

setup() / setState()


 setup() {
    /* state 프로퍼티 설정 */
  }

  setState(newState) {
    /* state 를 변경하는 함수 */
    this.state = { ...this.state, ...newState };
    this.render();
  }

setup 은 정의되지 않았지만 상태를 관리해야 하는 컴포넌트의 경우 setup 메소드를 오버라이딩 하여 상태를 생성한다.

모든 컴포넌트들이 상태를 가질 필요가 없다. 위 예시에서도 app 컴포넌트가 관리하는 상태를 하위 컴포넌트들이 전달받아 작동 하도록 한다.

다양한 컴포넌트들이 동일한 상태를 완벽하게 공유하고 있으면 (두 컴포넌트 모두 동일한 상태를 가지고 있다면) 두 컴포넌트의 결합성 및 종속성이 올라간다. 이는 컴포넌트를 수정하거나 교체하기 어려워지고 시스템의 유연성과 유지관리 가능성이 내려간다. 최대한 컴포넌트들은 독립적인 상태를 유지하도록 해야한다.
그렇게 하기 위해 상태를 관리하는 컴포넌트에서 하위 컴포넌트들에게 상태를 전달해주는 형태로 사용한다.

setState 메소드는 현재 정의된 state 프로퍼티를 newState 로 오버라이딩 한 후 새롭게 렌더링 한다.

렌더링은 상태 변화에 종속되어 있는 상태로 , 상태가 변화되면 필수적으로 재렌더링이 일어난다.


App.js

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

export default class App extends Component {
  setup() {
    /* 컴포넌트에서 관리해야 하는 상태 정의 */
    this.state = {
      filterVersion: 0,
      items: [
        { seq: 1, content: 'item1', active: true },
        { seq: 2, content: 'item2', active: false },
      ],
    };
  }

  templet() {
    return `<header class = "appender-fild"></header>
    <main class = "item-fild"></main>
    <footer class = "filter-fild"></footer>`;
  }

  mounted() {
    /* 하위 컴포넌트들에 전달 해줄 영역 선택 */
    const $appenderFild = this.target.querySelector('.appender-fild');
    const $itemFild = this.target.querySelector('.item-fild');
    const $filterFild = this.target.querySelector('.filter-fild');

    /* 하위 컴포넌트들에 전달 해준 메소드 생성 */
    /* itemAppender 컴포넌트에게 전달해줄 메소드 */
    const appendItem = (content) => {
      const { items } = this.state;
      const seq = Math.max(...items.map((v) => v.seq)) + 1;
      const active = false;
      this.setState({ items: [...items, { seq, content, active }] });
    };
    /* Items 컴포넌트에게 전달해줄 메소드 */
    const toggleFunc = ({ target }) => {
      const { items } = this.state;
      const targetSeq = target.closest('[data-seq]').dataset.seq;
      const targetIdx = items.findIndex((v) => v.seq === Number(targetSeq));

      items[targetIdx].active = !items[targetIdx].active;

      this.setState({ items });
    };
    const deleteFunc = ({ target }) => {
      const { items } = this.state;
      const targetSeq = target.closest('[data-seq]').dataset.seq;
      const targetIdx = items.findIndex((v) => v.seq === targetSeq);
      items.splice(targetIdx, 1);

      this.setState({ items });
    };

    const getFilteredItems = () => {
      const { items, filterVersion } = this.state;
      return items.filter(({ active }) => {
        return (
          filterVersion === 0 ||
          (filterVersion === 1 && active) ||
          (filterVersion === 2 && !active)
        );
      });
    };

    /* itemFilter에 전달해줄 메소드 */
    const filterItems = (filterVersion) => {
      this.setState({ filterVersion });
    };

    /* 렌더링 이후 하위 컴포넌트들 생성 */
    /* this 를 binding 해주는 이유는 메소드 내에서 상태를 관리할 state가 
    App 컴포넌트의 state 이기 때문 */

    new ItemAppender($appenderFild, {
      appendItem: appendItem.bind(this),
    });
    new Items($itemFild, {
      getFilteredItems: getFilteredItems.bind(this),
      toggleFunc: toggleFunc.bind(this),
      deleteFunc: deleteFunc.bind(this),
    });
    new ItemFilter($filterFild, {
      filterItems: filterItems.bind(this),
    });
  }
}

하나씩 짤라서 보자

setup() / templet()

  setup() {
    /* 컴포넌트에서 관리해야 하는 상태 정의 */
    this.state = {
      filterVersion: 0,
      items: [
        { seq: 1, content: 'item1', active: true },
        { seq: 2, content: 'item2', active: false },
      ],
    };
  }

  templet() {
    return `<header class = "appender-fild"></header>
    <main class = "item-fild"></main>
    <footer class = "filter-fild"></footer>`;
  }

상태를 관리할 App 컴포넌트에서 state 를 지정해준다.

관리해야 하는 상태는 현재 어떤 보기인지 알 수 있도록 하는 filterVersion 프로퍼티가 필요하며

현재 어떤 아이템들이 존재하는지 보기 위한 items 배열이 필요하다.

templet() 메소드를 살펴보면 하위 컴포넌트들이 존재 할 수 있도록 하위 컴포넌트에게 전달해줄 태그들을 생성해준다.

세 가지 기능을 하는 각 컴포넌트들은 appender-fild , item-fild , filter-fild 내부에서 렌더링 되며 App 컴포넌트의 상태를 전달받는다.

mounted()

mounted()templet() 메소드 내부에 존재하는 내용이 렌더링 된 후

하위 컴포넌트들을 상위 컴포넌트 내부에서 렌더링 시키는 메소드이다.

 mounted() {
    /* 하위 컴포넌트들에 전달 해줄 영역 선택 */
    const $appenderFild = this.target.querySelector('.appender-fild');
    const $itemFild = this.target.querySelector('.item-fild');
    const $filterFild = this.target.querySelector('.filter-fild');
	...
    };

하위 컴포넌트들의 target 이 될 태그들을 선택해주고 각 하위 컴포넌트들에서

사용할 메소드들을 정의해주자

왜 메소드들을 App 컴포넌트에서 정의하는지는 하단에서 설명하도록 하겠다.

appendItem()

 mounted() {
   ...
     /* 하위 컴포넌트들에 전달 해준 메소드 생성 */
    /* itemAppender 컴포넌트에게 전달해줄 메소드 */
    const appendItem = (content) => {
      const { items } = this.state;
      const seq = Math.max(...items.map((v) => v.seq)) + 1;
      const active = false;
      this.setState({ items: [...items, { seq, content, active }] });
    };
   ...
       };

해당 메소드는 itemAppender 에게 넘겨주기 위한 메소드이다.

추가된 아이템을 하위 Items 컨테이너 내용을 변경시켜야 한다.

appendItem() 은 새로운 아이템의 content 값이 들어왔을 때 해당 아이템의 seq , active 상태를 정의한 후 상태를 변화시킨다.

새로운 아이템이 추가 되었을 때 해당 아이템을 상태 값에 추가시키는 것으로

새로운 아이템이 추가되면 렌더링이 새롭게 일어난다.

toggleFunc() / deleteFunc() / getFilteredItems()

mounted(){
  ...
      /* Items 컴포넌트에게 전달해줄 메소드 */
    const toggleFunc = ({ target }) => {
      const { items } = this.state;
      const targetSeq = target.closest('[data-seq]').dataset.seq;
      const targetIdx = items.findIndex((v) => v.seq === Number(targetSeq));

      items[targetIdx].active = !items[targetIdx].active;

      this.setState({ items });
    };
  
    const deleteFunc = ({ target }) => {
      const { items } = this.state;
      const targetSeq = target.closest('[data-seq]').dataset.seq;
      const targetIdx = items.findIndex((v) => v.seq === targetSeq);
      items.splice(targetIdx, 1);

      this.setState({ items });
    };

    const getFilteredItems = () => {
      const { items, filterVersion } = this.state;
      return items.filter(({ active }) => {
        return (
          filterVersion === 0 ||
          (filterVersion === 1 && active) ||
          (filterVersion === 2 && !active)
        );
      });
    };
  ...
}

해당 메소스들은 Items 컴포넌트에게 전달해주기 위한 메소드들이다.

아이템들이 담긴 컴포넌트들은 세 가지 기능이 필요하다.

  1. 현재 보기 상태 (전체보기 , 활성화보기 , 비활성화보기) 에 따라서 Items 내부에서 렌더링 할 내용들이 변경되어야 한다. 그렇기에 현재 보기 상태에 따라 렌더링 될 아이템들을 필터링 할 getFilteredItems 메소드를 생성한다.

    해당 메소드는 this.state 내부에 있는 filterVersion 값과 items 배열 아이템들의 active 값에 따라 필터링 된 배열을 생성한다.

  2. filterVersion 에 따라 필터링 될 아이템의 활성화 , 비활성화 여부를 결정 지을 메소드가 필요하다. 이를 위해 togleFunc 메소드를 만들어준다. 해당 메소드는 this.state 내부에 있는 items 배열에서 active 프로퍼티 값을 변경시킨다.

  3. 아이템들을 삭제 시킬 메소드가 필요하다. 이를 위해 deleteFunc 메소드를 만들어준다. 이는 this.state 내부에 있는 items 배열에서 삭제 버튼이 눌린 아이템을 삭제한다.

HTMLElement.closest(selector) 를 자주 사용하였는데 이는 다음 글에서 다루도록 하겠다.
간단히 말하면 DOM TREE에서 선택한 HTMLElement부모 요소 방향으로 가장 인접한 selector 를 가진 태그를 선택한다.
그러니 코드들에서 target.closest('someSelector ..') 는 이벤트가 발생한 태그의 부모 요소 중 해당 셀렉터를 가진 태그를 선택하는 것이다.

filterItems()

mounted(){
  ...
      /* itemFilter에 전달해줄 메소드 */
    const filterItems = (filterVersion) => {
      this.setState({ filterVersion });
    };

    /* 렌더링 이후 하위 컴포넌트들 생성 */
    /* this 를 binding 해주는 이유는 메소드 내에서 상태를 관리할 state가 
    App 컴포넌트의 state 이기 때문 */
	...
}

이제 전체보기, 활성화 보기, 비활성화보기 버튼이 눌렸을 때 설정된 filterVersion 으로 상태를 변경시켜주는 메소드들이다.

하위 컴포넌트들 생성하기

mounted(){
	...
        /* 렌더링 이후 하위 컴포넌트들 생성 */
    /* this 를 binding 해주는 이유는 메소드 내에서 상태를 관리할 state가 
    App 컴포넌트의 state 이기 때문 */

    new ItemAppender($appenderFild, {
      appendItem: appendItem.bind(this),
    });
    new Items($itemFild, {
      getFilteredItems: getFilteredItems.bind(this),
      toggleFunc: toggleFunc.bind(this),
      deleteFunc: deleteFunc.bind(this),
    });
    new ItemFilter($filterFild, {
      filterItems: filterItems.bind(this),
    });
  ...
}

이후 메소드들을 하위 컴포넌트에 전달해준다.

위에서 의문이였던 왜 메소드들을 상위 컴포넌트에서 정의할까 ? 하위 컴포넌트에서 메소드로 정의하면 안될까? 하는 질문에 대해 생각해보자

컴포넌트의 가장 큰 포인트는 상태가 변경되었을 때 재렌더링 하는 것이다.

위의 메소드들의 대부분은 상태를 변경시키는 메소드들이며, 메소드 내에서 this.setState 메소드를 호출한다.

this.setStatethis.state 의 값을 오버라이딩 한 후 재렌더링하는 메소드이다.

그럼 this.state 는 누구에게 있을까 ?

그러니 state 를 관리하는 this 는 누구를 가리키고 있을까 ?

export default class App extends Component {
 setup() {
    /* 컴포넌트에서 관리해야 하는 상태 정의 */
    this.state = {
      filterVersion: 0,
      items: [
        { seq: 1, content: 'item1', active: true },
        { seq: 2, content: 'item2', active: false },
      ],
    };
  }

바로 나지롱

그것은 App 컴포넌트이다. 상태를 변경시키기 위해서는 App 내부에서 정의되어 App 을 가리키고 있는 this 를 사용해야 할 필요가 있다.

그렇기 때문에 App 컴포넌트에서 메소드들을 정의해주고 하위 컴포넌트의 props 인수에 .bind(this) 을 이용해 App 컴포넌트의 this 와 바인딩 시킨 메소드들을 넘겨준다.

만약 해당 메소드들을 하위 컴포넌트들에서 정의했다면 this.state 를 찾을 수 없기 때문에 this.setState() 메소드 자체가 호출이 되지 않을 것이다.

또 만약 이를 해결하기 위해서 하위 컴포넌트에서 상위 컴포넌트와 모두 동일한 state 들을 setup 해준 후 하위 컴포넌트에서 메소드들을 정의 할 수 있겠지만
이는 컴포넌트간 연결성을 높혀버리는 단점이 존재한다. (동일한 state 들을 가지니가)

하지만 this 를 바인딩 후 넘겨줘 state 를 공유하는 현재 사용한 방법도 단점이 존재하긴 한다.


ItemAppender.js

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

export default class ItemAppender extends Component {
  setEvent() {
    const { appendItem } = this.props;

    this.addEvent('keyup', '.appender', ({ key, target }) => {
      if (key !== 'Enter') return;

      appendItem(target.value);
    });
  }

  templet() {
    return '<input class = "appender" type = "text" placeholder = "아이템을 입력해주세요"}></input>';
  }
}

props 를 통해 상위 컴포넌트인 App 에서 바인딩된 메소드 appendItem 메소드를 이용해 이벤트 핸들러를 등록시켜준다.

appendItem 메소드가 호출되면 App.state.Items 배열이 변경된 후 재렌더링 된다.

Items.js

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

export default class Items extends Component {
  templet() {
    const { getFilteredItems } = this.props;
    const filteredItems = getFilteredItems();

    const toggleBtn = (active) => {
      return `<button class = 'toggleBtn' style = "color : ${
        active ? 'blue' : 'red'
      }">
      ${active ? '활성화' : '비활성화'}
      </button>`;
    };
    const deleteBtn = '<button class = "deleteBtn">삭제</button>';

    return `<ul>
    ${filteredItems
      .map(
        ({ seq, content, active }) =>
          `<li data-seq = "${seq}">${content}${toggleBtn(
            active,
          )}${deleteBtn}</li>`,
      )
      .join('')}
    </ul>`;
  }

  setEvent() {
    const { toggleFunc, deleteFunc } = this.props;

    this.addEvent('click', '.toggleBtn', (event) => {
      toggleFunc(event);
    });

    this.addEvent('click', '.deleteBtn', (event) => {
      deleteFunc(event);
    });
  }
}

Items 컴포넌트의 templet 을 살펴보면 fileterVersion , active 상태에 따라 필터링 된 아이템들만 담긴 배열을 받아

li 태그를 생성해내며, li 태그의 자식 태그로 button 태그들을 만들어준 모습을 볼 수 있다.


실행되는 실행 컨텍스트


마치며

하지만 이는 완벽한 방법은 아니다

오로지 활성화 비활성화 버튼의 텍스트만 재렌더링 하더라도

Apprender() 메소드가 재실행되며 render() 내부의 mounted() 메소드도 항상 재실행되며

전체 컴포넌트들이 모두 재렌더링되는 문제가 존재하긴 한다.

이를 해결하기 위해 리액트는 Virtual DOM 을 이용한 diff Algorithm 을 사용한다고 한다.

추가로 더 공부해봐야겠다.

완벽하게 리액트스럽게 컴포넌트를 만들어본것은 아니지만 그래도

상태관리를 통한 렌더링이나, 컴포넌트 간 정보 교환을 이용하는 것에 대해서 더 알 수 있었다.

CustomElements 라는 API 를 사용하면 마치 JXL 처럼 사용 할 수 있다고 한 것을 보기도 했는데 추가로 더 공부해봐야지

더 공부해봐야 겠다고 느낀 것들
1. DOM 조작
2. Virtual DOM
3. bind 개념 숙지
4. closest 개념 숙지
5. 웹 컴포넌트 지향 개발의 철학

profile
빨리 가는 유일한 방법은 제대로 가는 것이다

0개의 댓글