logo
menu

Vanilla 로 구현해보는 컴포넌트

2022. 07. 13.

  • #자바스크립트

블랙커피 LV1 스터디 와 황준일 개발자님 포스트 를 보고 이전부터 하고자 했던 내용을 주제로 구현해봤습니다. 해당 내용의 코드는 github 에 있습니다. 해당 내용을 노션에서 보고 싶다면 클릭
블랙커피 LV1 스터디를 하면서(바닐라 자바스크립트로 미션을 해결하면서) 리액트 스럽게 바닐라 자바스크립트로 구현할 수 없을까? 를 고민했고 미션을 하면서 어느 정도 틀이 잡혔습니다. 이후에 황준일 개발자님의 포스트를 보고 저도 저만의 스타일로 만들고자 해당 내용을 구상했습니다.

github 구조

notion image
github 구조를 보시면 다양한 폴더들로 구성되어 있는데 간단하게 설명하자면
  • cypress 는 테스트를 위한 것이므로 참고하지 않으셔도 됩니다.
  • v1—vanilla-* 은 버전1 의 컴포넌트를 의미합니다.
여기서는 웹 컴포넌트를 지원하기 위한 구조라고 생각하시면 됩니다. v2—vanilla-* 는 웹 컴포넌트 + 리덕스를 적용한 구조입니다.
  • v1-* 으로 시작하는 부분은 v1—vanilla-* 로 구현한 각종 프로젝트(counter, todo-list, async-user) 구조 입니다.
이때 app 과 components 로 구분했는데, app은 하나의 컴포넌트(루트 컴포넌트-app) 로 구현한 프로젝트이고, components 는 동일한 기능을 다양한 컴포넌트로 구현한 프로젝트 입니다.

들어가기에 앞서

해당 내용은
  1. 바닐라 자바스크립트로 웹 컴포넌트 만들기
  1. 웹 컴포넌트 만들기 + 리덕스 패턴 구현하기
으로 구성될 것이며, 추후에 리덕스 미들웨어 (리덕스 thunk 또는 saga) 를 구현할 예정입니다.( 미들웨어는 미정)
또한, 해당 내용을 이해하기 위한 사전 지식으로 아래 내용이 있으면 좋습니다.
  1. 리액트 클래스 컴포넌트
  1. 옵저버 패턴 (추후 업데이트)
이제 "바닐라 자바스크립트 웹 컴포넌트" 를 알아봅시다.

웹 컴포넌트 만들기 - 기본 구조

리액트 라이프 사이클

해당 구조를 구현할 때 최대한 리액트 라이프 사이클을 참고했다.
리액트 라이프 사이클에는 다양한 라이프 사이클이 존재하는데 필자의 경우 필요한 것만 가져와서 사용했다.
참고한 라이프사이클은 아래와 같다.
  1. constructor()
      • 초기 state 값 선언
  1. render()
      • 컴포넌트 모양새 정의
  1. componentDidMount()
      • 컴포넌트 생성 후, 첫 렌더링을 다 마친 후 실행
      • event 등록, setTimeout, 네트워크 요청 등
  1. setState()
      • 컴포넌트의 state 변경

웹 컴포넌트 기본 구조

위 4개의 메서드를 그대로(용도 및 정의) 사용하고자 했으나 약간의 차이가 존재한다. 하지만 그래도 최대한 유사하게 구현하고자 했기 때문에 그렇게 어렵지 않을 것이다.
component 기본 구조
// /src/core/Component.js / export default class Component { $target; props; state; constructor($target, props) { this.$target = $target; this.props = props; this.initialState(); } async initialState() { // 초기 state 초기화 영역// 기존에는 constructor 에서 처리하고자 했으나, constructor 에서 비동기를 따로 처리해야 하므로// 해당 메소드 생성 this.render(); } setState(newState) { // 컴포넌트의 상태를 변경하는 기능 this.state = newState; this.render(); console.log('setState', this.state); } template() { // JSX 와 같이 해당 컴포넌트의 UI 를 정의하는 부분 return ``; } render() { // 실제 브라우저에 뿌려주는 기능 this.$target.innerHTML = this.template(); this.componentDidMount(); } componentDidMount() { // 이벤트 등록 및 관련(하위) 컴포넌트 생성? } }
  • 웹 컴포넌트를 추상화한 클래스이다.
  • constructor 메서드
컴포넌트의 target($target) 정보와 props 를 전달 받을 수 있다. constructor 메서드에 초기화를 하고자 했지만, constructor 에 비동기 처리하기가 애매해서 initialState 메서드를 정의하면 자동으로 호출하게 했다.
// bad constructor($target, props) { this.$target = $target; this.props = props; this.setState({ testState: 0 }); }
// recommend constructor($target, props) { this.$target = $target; this.props = props; this.initialState(); } initialState() { this.setState({ testState: 0 }) }
  • initialState 메서드
초기 상태를 초기화하는 기능이다.
async initialState() { // 초기 state 초기화 영역// 기존에는 constructor 에서 처리하고자 했으나, constructor 에서 비동기를 따로 처리해야 하므로// 해당 메소드 생성 this.render(); }
  • 초기 상태가 업데이트 되면 해당 컴포넌트를 렌더링해야해서 해당 메서드에서 render 를 호출한다.
  • setState 메서드
상태를 업데이트 할 때 사용하는 기능이다.
setState(newState) { // 컴포넌트의 상태를 변경하는 기능 this.state = newState; this.render(); console.log('setState', this.state); }
상태가 변동되면 UI 부분도 업데이트 되어야 하므로 this.render() 를 호출한다.
  • template 메서드
UI 를 정의하는 메서드 이다.
template() { // JSX 와 같이 해당 컴포넌트의 UI 를 정의하는 부분 return ``; }
리액트에서는 render() 에서 정의하고 UI 도 생성하지만, 필자의 경우 template 에서 UI 를 정의하고 render() 메서드에서 실제 DOM 을 생성하도록 하였다.
  • render 메서드
render() { // 실제 브라우저에 뿌려주는 기능 this.$target.innerHTML = this.template(); this.componentDidMount(); }
  • 실제 DOM 을 생성하는 작업
  • componentDidMount 메서드
componentDidMount() { // 이벤트 등록 및 관련(하위) 컴포넌트 생성? }
  • 실제 DOM 이 생성되고 난 후 (render() 호출 후) 동작하는 기능으로
이벤트 등록 및 하위 컴포넌트를 생성하도록 하는 기능이다. 여기까지 core 컴포넌트에 대해 알아봤다면 간단한 Counter 를 직접 구현하면서 알아보자!

카운터 구현하기

구현하고자 하는 Counter 는 아래와 같다.
notion image
간단하게 보면 diff 값을 설정하는 부분과 증가/감소 하는 부분으로 구현되었다. 내용을 작성하면서 보니깐, +1 보다는 + 또는 +diff값 으로 하는게 더 적합한 거 같다.

App 컴포넌트로 구현

  • App 은 하나의 컴포넌트(App) 에서 모든 것을 처리하도록 구현했다.
구조
notion image
공통 기능
// utils/util.js export const $ = (selector) => document.querySelector(selector);
  • DOM 을 셀렉트 하는 기능을 함수로 따로 빼서 관리하였다.
// index.js import { $ } from './utils/util.js'; import App from './App.js'; new App($('#app'));
  • entry point 부분이다.
export default class Component { $target; state; props; constructor($target, props) { this.$target = $target; this.props = props; this.initialState(); } async initialState() { // 초기 state 초기화 영역// 기존에는 constructor 에서 처리하고자 했으나, constructor 에서 비동기를 따로 처리해야 하므로// 해당 메소드 생성 this.render(); } setState(newState) { // 컴포넌트의 상태를 변경하는 기능 this.state = newState; this.render(); console.log('setState', this.state); } template() { // JSX 와 같이 해당 컴포넌트의 UI 를 정의하는 부분 return ``; } render() { // 실제 브라우저에 뿌려주는 기능 this.$target.innerHTML = this.template(); this.componentDidMount(); } componentDidMount() { // 이벤트 등록 및 관련(하위) 컴포넌트 생성? } }
  • 위에서 설명한 컴포넌트의 기본이 되는 영역이다.
이제 진짜 시작!
App 컴포넌트
// App.js import { $ } from './utils/util.js'; import Component from './core/Component.js'; export default class App extends Component { constructor(...rest) { super(...rest);// 추후에 하위로 컴포넌트를 렌더링할 때 필요한 부분 this.initialState();// 초기값 설정 } async initialState() { this.setState({ count: 0, diff: 1, }); } template() {// 해당 카운터 컴포넌트의 UI 를 정의 return ` <div class="container"> <h1>Counter</h1> <form class="setDiffForm"> <input class="diffInput" type="number" placeholder="1" value="${this.state.diff || 1}"/> <button class="diffSubmit" type="submit">diff 설정</button> </form> <h2 class="counter">${this.state.count}</h2> <button class="increaseBtn">+1</button> <button class="decreaseBtn">-1</button> </div> `; } componentDidMount() {// 컴포넌트가 렌더링 된 후, 관련된 이벤트를 등록하였다. const { handleIncrease, handleDecrease, handleSubmit } = this; $('.increaseBtn').addEventListener('click', handleIncrease.bind(this)); $('.decreaseBtn').addEventListener('click', handleDecrease.bind(this)); $('.setDiffForm').addEventListener('submit', handleSubmit.bind(this)); } // custom handler // 프로젝트에 필요한 기능을 정의했다. handleIncrease() { this.setState({ ...this.state, count: this.state.count + this.state.diff, }); } handleDecrease() { this.setState({ ...this.state, count: this.state.count - this.state.diff, }); } handleSubmit(event) { event.preventDefault(); const diff = parseInt($('.diffInput').value, 10); this.setState({ ...this.state, diff: diff, }); } }
이렇게 간단하게 App 컴포넌트에서 Counter 컴포넌트를 구현했다.
자세한 코드는 Github Repo 를 참고하면 된다.

Counter 컴포넌트를 컴포넌트로 분리하여 구현하기

위 공통 영역은 같고 App.js 부분과 components 디렉터리에 각각 필요한 컴포넌트를 생성하였다.
구조
notion image
자세한 설명은 App.js 파일과 Counter.js, DiffInput.js 파일에 대해서만 기술하겠다. (나머지는 위 내용과 동일)
App 컴포넌트
// App.js import { $ } from './utils/util.js'; import Component from './core/Component.js'; import Counter from './components/Counter.js'; import DiffInput from './components/DiffInput.js'; export default class App extends Component { constructor(...rest) { super(...rest); this.initialState() } async initialState() { this.setState({ count: 0, diff: 1, }); } componentDidMount() { const { handleIncrease, handleDecrease, handleSetDiff } = this; new Counter($('.counter-component'), { count: this.state && this.state.count, onIncrease: handleIncrease.bind(this), onDecrease: handleDecrease.bind(this), }); new DiffInput($('.diff-form-component'), { diff: this.state && this.state.diff, onSetDiff: handleSetDiff.bind(this), }); } template() { return ` <h1>Counter</h1> <section class="diff-form-component"></section> <section class="counter-component"></section> `; } // custom handler handleIncrease() { this.setState({ ...this.state, count: this.state.count + this.state.diff, }); } handleDecrease() { this.setState({ ...this.state, count: this.state.count - this.state.diff, }); } handleSetDiff(diff) { this.setState({ ...this.state, diff, }); } }
  • 자세하게 볼 내용은 template() 과 componentDidMount 를 보면 된다.
  • template 을 각 컴포넌트를 root DOM 을 지정해줘야한다.
왜냐하면 하위 컴포넌트에서 해당 root DOM 사이에 해당(하위) 컴포넌트에 맞는 template 을 설정할 수 있기 때문이다.
  • componentDidMount 메서드에는 해당 컴포넌트에 필요한 이벤트 또는 하위 컴포넌트를 생성해줘야한다.
App.js 에는 해당 이벤트가 없기 때문에 하위 컴포넌트를 생성해줬다. 하위 컴포넌트에는 해당 컴포넌트의 (root DOM, props) 를 전달해준다.
// 코어 컴포넌트의 생성자를 보면된다. $target; props; state;// 코드도 수정해야함. constructor($target, props) { this.$target = $target; this.props = props; this.initialState(); }
이제 Counter 컴포넌트와 DiffInput 컴포넌트를 보자
Counter 컴포넌트
import { $ } from '../utils/util.js'; import Component from '../core/Component.js'; export default class Counter extends Component { constructor(...rest) { super(...rest); } componentDidMount() { const { onIncrease, onDecrease } = this.props; $('.increaseBtn').addEventListener('click', onIncrease); $('.decreaseBtn').addEventListener('click', onDecrease); } template() { const { count } = this.props; return ` <h2 class="counter">${count}</h2> <button class="increaseBtn">+1</button> <button class="decreaseBtn">-1</button> `; } }
  • 관련된 기능들을 구현한다.
  • 중요한 점은 App 컴포넌트에서 props로 count 값과 onIncrease, onDecrease 기능을 넘겨준 점이다.
DiffInput 컴포넌트
import { $ } from '../utils/util.js'; import Component from '../core/Component.js'; export default class DiffInput extends Component { constructor(...rest) { super(...rest); } componentDidMount() { $('form').addEventListener('submit', this.handleSubmit.bind(this)); } template() { const { diff } = this.props; return ` <form> <input class="diffInput" type="number" value="${diff}" /> <button class="diffSubmit" type="submit">diff 설정</button> </form> `; } // custom handleSubmit(event) { event.preventDefault(); const { onSetDiff } = this.props; const diff = $('input')?.value; onSetDiff(+diff); } }
  • DiffInput 컴포넌트도 props 로 전달된 것과 custom 핸들러를 만들어서 props 로 전달된 기능을 구현했다.
이렇게 바닐라 자바스크립트로 간단하게 웹 컴포넌트를 구현했다.
자세한 코드는 Github Repo 를 참고하면 된다.
다음 포스트는 웹 컴포넌트 + 리덕스 에 대해 기술하겠다. !!다음에 봐요