Vanilla JavaScript로 Component 구조 만들기

chaaerim·2023년 10월 13일
0

한 달간 프로그래머스에서 진행한 roto의 스터디에 참여했다.

React, Next.js와 같은 라이브러리와 프레임워크를 위주로 개발을 진행하다보니 JavaScript의 동작 원리를 글로만 파악하고 실제로 마주해보지 못한 것에 대한 아쉬움의 해소를 위해 시작했다.

다음 글은 vanilla JavaScript를 이용해서 Todo App을 만드는 작업을 진행한 일련의 과정에 대한 글이다.

초기 구현했던 Todo App의 구조는 아래와 같다.

App.js
TodoCounter.js
TodoList.js
TodoInput.js
main.js
index.html

  • React와 비슷한 구조를 가져가기 위해 App 컴포넌트 하단에서 todoList에 사용되는 컴포넌트들을 관리하고자 위와 같이 파일을 구분하여 개발을 진행했다.

컴포넌트를 어떻게 만들 것인가?

vanilla JavaScript로 컴포넌트 개발을 진행하면서 각 컴포넌트에서 신경쓰고자 했던 점은 아래와 같다.

  • 컴포넌트는 생성자 함수로 구현한다.
  • 각 컴포넌트의 렌더링은 render 함수를 통해 이뤄지도록 한다.
  • 각 컴포넌트의 state을 가지며 state의 업데이트는 setState 함수를 이용해서 이뤄지도록 한다.
  • setState을 이용하여 state 업데이트가 이뤄지면 해당 컴포넌트의 render 함수를 실행하도록 한다. (React에서도 state 변경이 이뤄지면 리렌더링이 발생하듯이 …! )
  • reflow를 고려하여 innerHTML을 최소한으로 호출한다.

구현하고자 했던 사항들

  • 기본적인 TodoApp을 만들되, input을 컴포넌트화 할 것.
  • todoList는 추가와 삭제가 가능할 것.
  • todoList의 개수를 알려주는 TodoCount 컴포넌트를 구현할 것.
  • localStorage를 이용하여 새로고침 시에도 todoList가 날아가지 않도록 할 것.

초기 구현 코드

index.html

<html>
  <head>
    <title>Mission 2</title>
    <meta charset="utf-8" />
  </head>

  <body>
    <form id="todo-form">
      <input type="text" name="todo-input" />
      <button type="submit">+</button>
    </form>
    <div id="todo-list"></div>
    <div id="todo-counter"></div>
    <script src="./TodoList.js" type="module"></script>
    <script src="./App.js" type="module"></script>
  </body>
</html>
  • js 파일을 각각 module로 관리하고 export, import를 이용하여 기능을 적절하게 분리하고 사용하기 위해 script 태그에 type을 지정해주었다.

App.js

import { TodoList } from './TodoList.js';
import { TodoInput } from './TodoInput.js';
import { TodoCounter } from './TodoCounter.js';

const todoList = document.querySelector('#todo-list');

function App() {
  this.state = JSON.parse(localStorage.getItem('todoData')) || '[]';
  this.setState = (nextState) => {
    this.state = JSON.parse(JSON.stringify(nextState));
    localStorage.setItem('todoData', JSON.stringify(this.state));
  };
}

try {
  const app = new App();
  const todoCounter = new TodoCounter(app);
  const todo = new TodoList(todoList, app, todoCounter);
  const todoInput = new TodoInput(todo, app, todoCounter);
} catch (e) {
  alert(e);
}
  • todoList를 렌더링 하는 컴포넌트와 개수를 알려주는 TodoCounter에서 같은 todoList 데이터의 참조가 필요했다. 또한 input을 컴포넌트화 하여 todoList data에 추가 작업이 필요했기 때문에 컴포넌트 간 state의 공유가 필요한 상황이었다.
  • 따라서 구조를 고민하여 App 컴포넌트로 state을 끌어 올리고 하위 컴포넌트에 App 컴포넌트를 props로 넘겨주어 App 컴포넌트의 상태를 하위 컴포넌트에서 사용가능한 구조로 App 컴포넌트를 구현했다.
  • 또한 새로고침 시에도 데이터 유지를 위해 localStorage를 이용하고자 했다. 따라서 App의 state을 localStorage에서 가져오고, setState을 이용하여 App 컴포넌트의 state에 변경이 이뤄질 때마다 localStorage에 해당 data를 set하여 데이터가 날아가지 않도록 구현했다.

TodoCounter.js

export function TodoCounter(app) {
  const todoCounter = document.querySelector('#todo-counter');

  this.render = () => {
    todoCounter.innerHTML = `<div>${app.state.length}</div>`;
  };

  this.render();
}
  • TodoCounter의 상태를 따로 두어야할지 고민을 하다가 해당 컴포넌트는 state의 변경이 발생되는 컴포넌트가 아니라고 판단하여 app.state값을 바로 가져와서 사용했다.

TodoList.js

export function TodoList(element, app, todoCounter) {
  // new State과 같은 값을 참조하지 않도록 깊은 복사를 통해 이전 state값 저장
  this.state = JSON.parse(JSON.stringify(app.state));

  // 이벤트 위임
  element.addEventListener('click', (event) => this.delete(event));
  element.addEventListener('click', (event) => this.changeState(event));

  this.validate = () => {
    // null and undefined 처리
    if (this.state == null) {
      throw new Error('data가 존재하지 않습니다!');
    }
    if (!new.target) {
      throw new Error('new 연산자를 사용하지 않았습니다!');
    }
    if (!Array.isArray(this.state)) {
      throw new Error('배열이 아닙니다!');
    }
    if (
      !this.state.every(
        (el) =>
          el.text &&
          el.isCompleted !== undefined &&
          typeof el.text === 'string' &&
          typeof el.isCompleted === 'boolean'
      )
    ) {
      throw new Error('data 형식이 올바르지 않습니다!');
    }
  };

  this.setState = (nextState) => {
    let isSame = false;
    // data length가 다르면 다른 data이므로
    if (this.state.length === nextState.length) {
      let flag = true;
      for (let i = 0; i < this.state.length; i++) {
        if (
          this.state[i].text !== nextState[i].text ||
          this.state[i].isCompleted !== nextState[i].isCompleted
        ) {
          flag = false;
        }
      }
      isSame = flag;
    }

    if (!isSame) {
      this.state = JSON.parse(JSON.stringify(nextState));
      app.setState(this.state);
      this.render();
      todoCounter.render();
    } else {
      throw new Error('data is same');
    }
  };

  this.render = () => {
    let index = -1;
    element.innerHTML = this.state
      .map((todo) => {
        const { text, isCompleted } = todo;
        // console.log(isCompleted);
        index++;
        return `<li id=${index}>
        <span id=${index} class='todo-text'>${
          isCompleted ? `<s>${text}</s>` : text
        }</span>
        <button class='delete-button'>-</button>
        </li>`;
      })
      .join('');
  };

  this.delete = (event) => {
    if (event.target.closest('button')) {
      const deletedData = this.state.filter(
        (el, idx) => idx !== Number(event.target.parentElement.id)
      );
      this.setState(deletedData);
    }
  };

  this.changeState = (event) => {
    const targetIndex = Number(event.target.parentElement.id);

    if (event.target.closest('span')) {
      const newState = app.state.map((el, idx) => {
        if (idx === targetIndex) {
          app.state[targetIndex].isCompleted =
            !app.state[targetIndex].isCompleted;
          return app.state[targetIndex];
        } else {
          return app.state[idx];
        }
      });

      this.setState(newState);
    }
  };

  this.addEvent = (elements, event, func) => {
    elements.forEach((el, idx) => el.addEventListener(event, () => func(idx)));
  };

  this.validate();
  this.render();
}
  • 하위 컴포넌트인 TodoList 컴포넌트에서 삭제와 토글 기능이 가능하도록 구현했다.
    • 따라서 TodoList 컴포넌트 내부에 delete 함수와 changeState 함수를 구현하여 click event가 발생했을 때 실행될 수 있도록 했다.
  • 따라서 TodoList에서 app의 state을 변경하게 되는 경우에는 app의 setState 함수를 실행하여 변경된 state이 App 컴포넌트의 state에 반영이 될 수 있도록 구현했다. (구조상 TodoList의 setState내부에서 app의 setState을 실행하게 됨. )
  • 초기 구현 시에는 이벤트 위임을 하지 않아 event handler 할당을 위한 addEvent 함수를 만들어 사용했다.
    • 이벤트 위임이란? 하위 요소마다 이벤트를 붙이는 것이 아니라 상위 요소에서 하위 요소의 이벤트를 제어하는 방식을 말한다. event bubbling의 특성을 이용한 것으로, 이벤트 위임을 사용하게 되는 경우 element마다 event handler를 할당하지 않고 부모 요소에만 event handler를 할당하면 된다는 편리함이 있다.
    • 그러나 후에 각각의 todo 요소에 event handler를 할당하는 것이 아니라 const todoList = document.querySelector('#todo-list'); element를 넘겨 받아 해당 element에 event를 위임하여 한 번의 event 할당으로 자식 요소들의 이벤트를 처리하였다.

TodoInput.js

export function TodoInput(todo, app, todoCounter) {
  const todoForm = document.querySelector('#todo-form');
  todoForm.addEventListener('submit', (event) => this.addTodo(event));

  this.addTodo = (event) => {
    // 새로고침 방지
    event.preventDefault();
    const newTodo = event.target['todo-input'];

    if (newTodo.value.trim() !== '') {
      //   데이터를 카피하지 않으면 TodoList도 같은 데이터를 참조하고 있기 때문에 데이터 변경을 감지하지 못함.
      app.state.push({
        text: newTodo.value,
        isCompleted: false,
      });

      //    submit이후 기존 데이터 날림
      newTodo.value = '';

      //   기존 todoData와 다른 data로 상태를 변경해주고 todoData를 업데이트 해주어야 set함수에서 데이터가 같다는 오류가 나지 않음.
      app.setState(app.state);
      todo.setState(app.state);
      todoCounter.render();
    }
  };
}
  • input을 컴포넌트화 하여 TodoInput 컴포넌트를 구현했다.
  • TodoInput 컴포넌트에서 추가된 데이터를 처리하기 위해 event handler 내부에서 app state에 접근하여 데이터를 업데이트 해주었다.
  • 또한 데이터가 추가되면 하위의 todoList를 렌더링 하는 컴포넌트도 새로운 state을 가지고 리렌더링이 되어야하기 때문에 app state을 업데이트 한 이후에 todo의 setState을 이용해 todo의 상태도 업데이트해주었다.

React스럽게 코드 변경하기 !

컴포넌트 간 의존성 낮추기

roto님과 리뷰를 주고 받으며 기존 작성했던 컴포넌트들 같은 경우는 컴포넌트 간의 의존도가 매우 높은 구조라는 사실을 알게 되었다. 컴포넌트가 컴포넌트를 넘겨받아 데이터를 처리하는 구조로 어떤 한 컴포넌트가 삭제되면 다른 컴포넌트의 사용이 불가능한 효율성과 확장성이 떨어지는 코드였다. 따라서 이를 개선해보고자 코드를 대폭 수정했다 ㅎ..


부모로부터 변경되는 State 흐름 만들기

따라서 App이 대부분의 상태를 가지고 있는 점을 활용하여 App 내부에도 setState 함수를 구현해 App의 state이 변경이 될 때 하위 컴포넌트의 setState함수를 같이 실행시켜 App 컴포넌트와 관련있는 컴포넌트의 state을 변경할 수 있도록 구조를 변경했다.


App.js

import { TodoList } from './TodoList.js';
import { TodoInput } from './Todoinput.js';
import { TodoCounter } from './TodoCounter.js';
import { setItem } from './localStorage.js';
import { localStorageKey } from './localStorage.js';

const todo = document.querySelector('#todo');

export function App(initialState) {
  this.state = initialState;
  this.setState = (nextState) => {
    this.state = nextState;
    todoList.setState(nextState);
    todoCounter.setState(nextState);
    setItem(localStorageKey, nextState);
  };

  const todoInput = new TodoInput({
    target: todo,
    onAddTodo: (text) => {
      this.setState([
        ...this.state,
        {
          text: text,
          isCompleted: false,
        },
      ]);
    },
  });
  const todoList = new TodoList({
    target: todo,
    initialState: this.state,
    onDelete: (index) => {
      const nextState = [...this.state];
      const deletedData = nextState.filter((el, idx) => idx !== Number(index));
      this.setState(deletedData);
    },
    onToggle: (index) => {
      const nextState = [...this.state];
      nextState[index].isCompleted = !nextState[index].isCompleted;
      this.setState(nextState);
    },
  });
  const todoCounter = new TodoCounter({
    target: todo,
    todoCount: this.state.length,
  });

  window.addEventListener('removeAll', () => {
    this.setState([]);
  });
}
  • App 컴포넌트를 넘겨 받아 하단 컴포넌트에서 App의 state을 수정하는 것이 아닌, App 컴포넌트에서 state의 변경이 일어났을 때 자식 컴포넌트의 state도 변경해주는 방식으로 변경했다. (비로소 React스러운 흐름을 가지게 되었다.)
  • 또한 컴포넌트 간의 의존성을 최대한 낮추기 위해 컴포넌트를 사용하는 쪽에서 동작을 제어할 수 있도록 코드를 수정했다.
    • 즉, 각 컴포넌트에 동작에 해당하는 함수를 props로 넘겨주어 처리할 수 있도록 수정했다. 이 또한 함수 그 자체로 event handler를 전달하는 React스러운 변경 방법이라고 할 수 있다.
    • App 컴포넌트를 직접 넘기지 않고 event handler를 정의한 함수를 넘겨 해당 컴포넌트는 이 컴포넌트가 이벤트에 대해 어떻게 처리할 지에 대해 알 필요가 없고, 각 컴포넌트의 역할에 집중할 수 있도록 수정했다.

TodoList.js

export function TodoList({ target, initialState, onDelete, onToggle }) {
  this.state = initialState;
  this.lastState = JSON.parse(JSON.stringify(initialState));

  this.element = document.createElement('ul');
  target.appendChild(this.element);

  // 이벤트 위임
  // ul에 이벤트를 달아서 자식 요소에서 클릭이 되었을 때 상위 요소로 전파되어서 상위요소에서 조건에 따라 처리할 수 있도록 함.

  this.element.addEventListener('click', (event) => {
    event.stopPropagation();

    const index = event.target.closest('li').dataset.index;

    if (event.target.closest('button')) {
      onDelete(index);
    } else if (event.target.closest('s') || event.target.closest('span'))
      onToggle(index);
  });

  this.validate = () => {
    // null and undefined 처리
    if (this.state == null) {
      throw new Error('data가 존재하지 않습니다!');
    }
    if (!new.target) {
      throw new Error('new 연산자를 사용하지 않았습니다!');
    }
    if (!Array.isArray(this.state)) {
      throw new Error('배열이 아닙니다!');
    }
    if (
      !this.state.every(
        (el) =>
          el.text &&
          el.isCompleted !== undefined &&
          typeof el.text === 'string' &&
          typeof el.isCompleted === 'boolean'
      )
    ) {
      throw new Error('data 형식이 올바르지 않습니다!');
    }
  };

  this.setState = (nextState) => {
    let isSame = false;
    // data length가 다르면 다른 data이므로
    if (this.lastState.length === nextState.length) {
      let flag = true;
      for (let i = 0; i < this.state.length; i++) {
        if (
          this.lastState[i].text !== nextState[i].text ||
          this.lastState[i].isCompleted !== nextState[i].isCompleted
        ) {
          flag = false;
        }
      }
      isSame = flag;
    }

    if (!isSame) {
      this.state = nextState;
      this.lastState = JSON.parse(JSON.stringify(nextState));
      this.render();
    } else {
      console.log(nextState);
      throw new Error('data is same');
    }
  };

  this.render = () => {
    let index = -1;
    this.element.innerHTML = this.state
      .map((todo) => {
        const { text, isCompleted } = todo;
        // console.log(isCompleted);
        index++;
        return `<li data-index=${index}>
        <span class='todo-text'>${isCompleted ? `<s>${text}</s>` : text}</span>
        <button class='delete-button'>-</button>
        </li>`;
      })
      .join('');
  };

  this.validate();
  this.render();
}
  • 따라서 TodoList에서는 event 처리에 대해서는 신경 쓸 필요가 없고, props로 받아온 handler를 통해 처리할 수 있게 되었다.
    • TodoList 컴포넌트 코드가 훨씬 간결해졌고 TodoList를 보여주는 역할에 집중해서 수행하는 코드가 된 것 같은 느낌이다.

TodoInput.js

export function TodoInput({ target, onAddTodo }) {
  this.element = document.createElement('form');
  this.input = document.createElement('input');
  this.deleteButton = document.createElement('button');
  this.removeAllButton = document.createElement('button');

  target.appendChild(this.element);
  this.element.appendChild(this.input);
  this.element.appendChild(this.deleteButton);
  this.element.appendChild(this.removeAllButton);

  this.render = () => {
    this.deleteButton.innerHTML = '추가';
    this.input.placeholder = '할 일을 입력하세요.';
    this.removeAllButton.innerHTML = '전체 삭제';
  };
  const todoForm = document.querySelector('#todo-form');
  // todoForm.addEventListener('submit', (event) => this.addTodo(event));

  this.element.addEventListener('submit', (event) => {
    event.preventDefault();
    if (this.input.value.trim() !== '') {
      onAddTodo(this.input.value);
    }
    this.input.value = '';
    this.input.focus();
  });

  this.removeAllButton.addEventListener('click', () => {
    window.dispatchEvent(new CustomEvent('removeAll'));
  });

  this.render();
}
  • 마찬가지로 기존 TodoInput은 App, TodoList, TodoCounter 컴포넌트에 모두 의존성을 가지고 있었지만 input 컴포넌트 입장에서는 데이터가 입력이 된 것까지막 파악하고 이를 어디에 추가할 건지는 App에서 넘겨받은 handler로 처리한다.
  • 또한 부모 요소에서 동작과 state을 제어하면서 App 컴포넌트에서 상태의 변경이 일어났을 때 자식 컴포넌트의 state도 변경이 일어나기 때문에 TodoList와 TodoCounter와의 의존성도 쉽게 제거할 수 있었다.

TodoCounter.js

export function TodoCounter({ target, todoCount }) {
  this.element = document.createElement('div');
  target.appendChild(this.element);

  this.state = todoCount;
  this.setState = (nextState) => {
    this.state = nextState.length;
    this.render();
  };

  this.render = () => {
    this.element.innerHTML = `<span>Count: ${this.state}</span>`;
  };
  const todoCounter = document.querySelector('#todo-counter');

  this.render();
}
  • 기존 TodoCounter 컴포넌트에는 state값을 두지 않고 바로 App컴포넌트의 state에 접근하여 length를 활용했는데 App컴포넌트와의 의존성을 낮추기 위해 TodoCounter의 상태를 따로 관리하도록 변경했다.

발생 가능한 에러 처리 제대로 하기

localStorage.js

export const localStorageKey = 'todos';

export const getItem = (key, defaultValue) => {
  try {
    const todo = window.localStorage.getItem(key);
    return todo ? JSON.parse(todo) : defaultValue;
  } catch (e) {
    return defaultValue;
  }
};

export const setItem = (key, value) => {
  try {
    window.localStorage.setItem(key, JSON.stringify(value));
  } catch (e) {
    console.log(e);
  }
};
  • 기존 코드에서는 localStorage에 접근하는 코드를 wrapping하지 않고 바로 localStorage에서 가져온 값을 state에 세팅했다.
  • 그러나 localStorage는 브라우저에서 유저가 직접 조작이 가능하므로 데이터에 변경이 일어났을 때를 대비하여 위와 같이 localStorage에 접근하는 함수를 wrapping하여 발생 가능한 에러에 대응하고자 했다.

마무리

항상 Javascript에 대한 갈증이 있었는데 React의 구조에 대해 어느정도 익숙해지고 나니 JS로 React스럽게 코드를 짜는 것이 재미있게 느껴졌다. 먼저 구조에 대해 충분히 고민하고 리뷰를 주고 받으며 수정하는 과정을 통해 JS로 어떻게 코드를 짜는 것이 재사용성을 높이고 의존성을 낮추는 것인지 조금은 감을 잡은 것 같다.

앞으로도 종종 JS로만 미니 프로젝트를 진행해봐야겠다고 다짐 😊

0개의 댓글

관련 채용 정보