Vanilla 로 구현해보는 컴포넌트
2022. 07. 13.
#자바스크립트
블랙커피 LV1 스터디 와 황준일 개발자님 포스트 를 보고 이전부터 하고자 했던 내용을 주제로 구현해봤습니다. 해당 내용의 코드는 github 에 있습니다. 해당 내용을 노션에서 보고 싶다면 클릭
블랙커피 LV1 스터디를 하면서(바닐라 자바스크립트로 미션을 해결하면서) 리액트 스럽게 바닐라 자바스크립트로 구현할 수 없을까? 를 고민했고 미션을 하면서 어느 정도 틀이 잡혔습니다. 이후에 황준일 개발자님의 포스트를 보고 저도 저만의 스타일로 만들고자 해당 내용을 구상했습니다.
github 구조
github 구조를 보시면 다양한 폴더들로 구성되어 있는데 간단하게 설명하자면
- cypress 는 테스트를 위한 것이므로 참고하지 않으셔도 됩니다.
- v1—vanilla-* 은 버전1 의 컴포넌트를 의미합니다.
여기서는 웹 컴포넌트를 지원하기 위한 구조라고 생각하시면 됩니다.
v2—vanilla-* 는 웹 컴포넌트 + 리덕스를 적용한 구조입니다.
- v1-* 으로 시작하는 부분은 v1—vanilla-* 로 구현한 각종 프로젝트(counter, todo-list, async-user) 구조 입니다.
이때 app 과 components 로 구분했는데,
app은 하나의 컴포넌트(루트 컴포넌트-app) 로 구현한 프로젝트이고,
components 는 동일한 기능을 다양한 컴포넌트로 구현한 프로젝트 입니다.
들어가기에 앞서
해당 내용은
- 바닐라 자바스크립트로 웹 컴포넌트 만들기
- 웹 컴포넌트 만들기 + 리덕스 패턴 구현하기
또한, 해당 내용을 이해하기 위한 사전 지식으로 아래 내용이 있으면 좋습니다.
- 옵저버 패턴 (추후 업데이트)
이제 "바닐라 자바스크립트 웹 컴포넌트" 를 알아봅시다.
웹 컴포넌트 만들기 - 기본 구조
리액트 라이프 사이클
해당 구조를 구현할 때 최대한 리액트 라이프 사이클을 참고했다.
리액트 라이프 사이클에는 다양한 라이프 사이클이 존재하는데 필자의 경우 필요한 것만 가져와서 사용했다.
참고한 라이프사이클은 아래와 같다.
- constructor()
- 초기 state 값 선언
- render()
- 컴포넌트 모양새 정의
- componentDidMount()
- 컴포넌트 생성 후, 첫 렌더링을 다 마친 후 실행
- event 등록, setTimeout, 네트워크 요청 등
- 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 는 아래와 같다.
간단하게 보면 diff 값을 설정하는 부분과 증가/감소 하는 부분으로 구현되었다.
내용을 작성하면서 보니깐, +1 보다는 + 또는 +diff값 으로 하는게 더 적합한 거 같다.
App 컴포넌트로 구현
- App 은 하나의 컴포넌트(App) 에서 모든 것을 처리하도록 구현했다.
구조
공통 기능
// 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 디렉터리에 각각 필요한 컴포넌트를 생성하였다.
구조
자세한 설명은 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 를 참고하면 된다.