Vanilla JS로 컴포넌트 만들기

박찬욱·2023년 9월 18일
0

TIL

목록 보기
14/21

리액트처럼 사고하기

리액트에서 컴포넌트를 만드는 방법은 다음과 같은 과정을 거친다.

  • UI를 컴포넌트 계층구조로 나누기
  • 정적 UI 생성
  • 최소한의 완전한 UI state 찾기
    • 여기서 state는 최소한의 변화하는 데이터 집합
    • 기존의 state를 통해 구할 수 있는 값들은 state가 아님
  • state가 어디에 있어야할지 파악하기
    • state에 영향을 받는 모든 컴포넌트들의 상위컴포넌트
  • 역방향 데이터흐름 추가
    • 상위컴포넌트의 state를 변경할 수 있는 함수를 하위 컴포넌트에게 전달

구현

과정에서 어려웠던 점은 바로 역방향 데이터흐름 추가 파트였다. 리액트에서는 props를 사용해서 넘겨주었는데 바닐라에서도 가능한가 고민에 빠졌다. 물론 당연히 가능하다. 시도하기 전에 겁을 먹었을 뿐이다.

추상화

import validateData from '../utils/ValidateData.js';

export default class Component {
  state = [];
  target;
  props = {};

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

  setup() {}

  mounted() {}

  template() {
    return '';
  }

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

  setEvent() {}

  setState(newState) {
    const newData = validateData(newState);
    localStorage.setItem('todoList', JSON.stringify(newData));
    this.state = JSON.parse(localStorage.getItem('todoList'));
    this.render();
  }
}

컴포넌트를 추상화시켰다. 이를 통해 메서드를 강제화할 수 있고 역할과 구현에 관계가 명확해졌다.

App

import Component from '../../core/Component.js';
import TodoList from './TodoList.js';
import TodoInput from './TodoInput.js';
import TodoCount from './TodoCount.js';

export default class App extends Component {
  setup() {
    this.state = localStorage.getItem('todoList')
      ? JSON.parse(localStorage.getItem('todoList'))
      : [];
  }

  template() {
    return `
    <section class='todoList' data-component='TodoList'></section>
    <section data-component='TodoCount'></section>
    <footer data-component='TodoInput'></footer>
    <button class='allDeleteTodoList'>모두삭제</button> 
    `;
  }

  mounted() {
    const {
      deleteTodo,
      addTodo,
      clickTodoTitle,
      getTodoListCount,
      getCompletedTodos,
    } = this;
    const todoList = this.target.querySelector('[data-component="TodoList"]');
    const todoCount = this.target.querySelector('[data-component="TodoCount"]');
    const todoInput = this.target.querySelector('[data-component="TodoInput"]');

    new TodoList(todoList, {
      deleteTodo: deleteTodo.bind(this),
      clickTodoTitle: clickTodoTitle.bind(this),
      todo: this.state,
    });
    new TodoInput(todoInput, { addTodo: addTodo.bind(this) });
    new TodoCount(todoCount, {
      getTodoListCount,
      getCompletedTodos,
    });
  }

  setEvent() {
    const removeAll = new CustomEvent('removeAll');
    this.target.addEventListener('click', (e) => {
      if (e.target.classList.contains('allDeleteTodoList')) {
        this.target.dispatchEvent(removeAll);
      }
    });
    this.target.addEventListener('removeAll', () => {
      this.setState([]);
    });
  }

  deleteTodo(id) {
    const todos = [...this.state];
    todos.splice(id, 1);
    this.setState(todos);
  }

  addTodo(todo) {
    this.setState([...this.state, todo]);
  }

  clickTodoTitle(id) {
    const todos = [...this.state];
    todos[id].isCompleted
      ? (todos[id].isCompleted = false)
      : (todos[id].isCompleted = true);
    this.setState(todos);
  }

  get getTodoListCount() {
    return this.state.length;
  }

  get getCompletedTodos() {
    const todos = [...this.state];
    return todos.filter(({ text, isCompleted }) => isCompleted).length;
  }
}

App 컴포넌트는 모든 컴포넌트의 상위 컴포넌트이다. 나는 이곳에 state를 둘 것이고 이 state를 변경할 수 있는 메서드를 하위 컴포넌트에게 전달해야한다.

여기서 주의할 점은 state는 setState를 통해 변경되며 state가 변경되면 re-rendering이 된다. 그리고 render에서 DOM의 직접적인 조작이 이루어진다.

그렇기 때문에 state를 변경하는 모든 메서드들은 setState를 호출할 수밖에 없다.

mounted는 컴포넌트가 랜더링 된 후에 진행되어야할 것들을 적어둔 코드이다. 랜더링 후에 하위 컴포넌트를 생성할 것이고 props를 넘겨준다.
이 기능을 생각해내는 것이 가장 어려웠다. 그리고 가장 포인트가 되는 부분이라고 생각한다. 참고 블로그

TodoCount

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

export default class TodoCount extends Component {
  template() {
    return `
        <div>할일의 갯수 : ${this.props.getTodoListCount}</div>
        <div>완료한 갯수 : ${this.props.getCompletedTodos}</div>
        `;
  }
}

TodoInput

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

export default class TodoInput extends Component {
  constructor(target, props) {
    super(target, props);
    this.input = document.querySelector('input');
  }

  template() {
    return `
      <form>
        <input type='text' placeholder='오늘 할일을 입력하세요.'/>
        <button type='submit'>입력</button>
      </form>
    `;
  }

  setEvent() {
    this.target.querySelector('form').addEventListener('submit', (e) => {
      e.preventDefault();
      const inputValue = e.target[0].value;
      this.props.addTodo({ text: inputValue, isCompleted: false });
      this.input.value = '';
      this.input.focus();
    });
  }
}

TodoList

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

export default class TodoList extends Component {
  constructor(target, props) {
    super(target, props);
  }

  template() {
    const { todo } = this.props;
    return todo
      .map(({ text, isCompleted }, i) =>
        isCompleted
          ? `<div class='todoItem'><p class='todoTitle' data-id=${i}><s data-id=${i}>${text}</s></p><button class='deleteBtn' data-id=${i}>삭제</button></div>`
          : `<div class='todoItem'><p class='todoTitle' data-id=${i}>${text}</p><button class='deleteBtn' data-id=${i}>삭제</button></div>`
      )
      .join('');
  }

  setEvent() {
    this.target.addEventListener('click', (e) => {
      if (e.target.classList.contains('deleteBtn')) {
        this.props.deleteTodo(e.target.dataset.id);
      }
      if (
        e.target.classList.contains('todoTitle') ||
        e.target.nodeName === 'S'
      ) {
        this.props.clickTodoTitle(e.target.dataset.id);
      }
    });
  }
}

props를 전달받는 각각의 하위 컴포넌트이다.

개선점

  • TodoList 컴포넌트를 TodoItem으로 다시 컴포넌트를 분리할 수 있다.
  • localstorage에서 JSON.parse부분을 try...catch로 감싸주기

느낀점

리액트는 컴포넌트 단위로 개발하는거래.
위의 말의 원리는 모른채로 그냥 생각없이 코딩을 했었는데
어떤 사고방식으로 컴포넌트를 구성해야하며 그리고 그 원리에 대해 바닐라로 한땀한땀 공부해볼 수 있어서 매우 기쁘다.

profile
대체불가능한 사람이다

0개의 댓글

관련 채용 정보