본문으로 바로가기

리액트를 배우다 보니 컴포넌트 기반으로 개발을 하는 것이 얼마나 효율적인지 알게됐고

과제 테스트로 자바스크립트로 컴포넌트를 만들어 구현하는 것을 자주 접하다 보니 확실하게 정립해놓고 가는게 나중에 시간을 덜 뺏기겠다 싶어서 공부한 내용

대부분의 내용이 여기를 참고했다

파일 구조

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div id="app"></div>

<script type="module" src="src/main.js"></script>
</body>
</html>
//main.js
import App from './App.js'

new App(document.querySelector('#app'))
// App.js
function App(){
}

export default App

index.html에서 main.js를 불러오면 main은 App을 불러오고

App에는 js로 만든 컴포넌트들을 다 불러와서 화면이 구성되어있다

components 디렉토리에는 그러한 컴포넌트들이 준비되어있는 저장소

컴포넌트

이제부터 우리는 화면에 그릴 때 컴포넌트로만 작성해서 붙여야한다

그렇다면 컴포넌트의 기능에 필요하다고 생각되는 것들을 뽑아서 추상화한 뒤에 상속해서 써주는 것이 여러모로 편하다

필요한 기능

리액트의 컴포넌트를 생각하면 무엇을 구현해야할지 쉽게 떠올릴 수 있다

  • 상태값 : 모든 컴포넌트는 상태값을 가지고 있고 그 상태값이 변하면 컴포넌트가 다시 그려지게된다
  • 렌더함수 : 컴포넌트를 상태값을 기반으로 다시 그리는 기능을 하는 함수
  • 상태값 변경함수 : 상태값은 불변성을 가지며 직접 건드릴 수 없고 setState함수를 이용해서 변경해야한다

위 기능들을 기반으로 작성한 코드는 다음과 같다

class Component {
    $target
    state
    constructor ($target) {
        this.$target = $target
        this.setup()
        this.render()
    }
    setup () {}
    template () { return ''}
    render () {
        this.$target.innerHTML = this.template()
    }
    setState (newState) {
        this.state = { ...this.state, ...newState }
        this.render()
    }
      setEvent(){}
}

export default Component

추가된 점은 $target 필드 클래스와 template, setEvent 함수가 있는데

컴포넌트를 생성하자마자 엘리먼트에 붙이기 위해서 받아오는 변수이고

template 함수는 render에서 화면을 그릴 때 필요한 html코드를 현재 state값을 기반으로 작성해주는 함수

setEvent는 말 그대로 이벤트 등록함수이다

사용해보기

추상화한 컴포넌트를 상속받아서 사용해보기 전에 간단한 테스트를 해보자

현재까지의 파일구조

상속받기

//Items.js
import Component from "../lib/Component.js";


class Items extends Component{
}


export default Items

테스트

//App.js
function App(){
    const items = new Items()

    items.test()
    items.setState({a:3})
    items.test()
}

만족.

이제 items를 상태값으로 갖는 Items 컴포넌트를 만들어보자

import Component from "../lib/Component.js"

class Items extends Component{
    setup() {
        this.state = {items:['item1','item2']}
    }

    template() {
        const {items} = this.state
        return `
        <ul>
            ${items.map(item=>`<li>${item}</li>`).join("")}
         </ul>
         <button>추가</button>`
    }
}
export default Items

잘 나온다

아이템 추가/삭제 이벤트

우선 추가해야할 것들은 아이템을 추가하는 이벤트를 가지는 추가 버튼

추가된 아이템에는 개별적으로 삭제 버튼이 달려서 생성된다

//Items.js
...
    template() {
        const {items} = this.state
        return `
        <ul>
            ${items.map((item,key)=>`
                <li>${item}</li> 
                <button class="remove" data-index="${key}">삭제</button>`).join("")}
         </ul>
         <button class="add">추가</button>
         `
    }
...

Items의 템플릿 부터 수정해주었다 html을 수정해서 추가 버튼을 달고

삭제할 때 엘리먼트를 직접 삭제하는게 아닌 상태값을 삭제하는 것이기 때문에 이를 위해서 삭제 버튼에 data-set으로 인덱스값을 주었다

이때 생성되는 아이템들의 버튼 하나하나에 이벤트를 주는것은 비효율적이다

실제로 이런 비효율을 줄이기 위해서 리스너의 등록을 줄이는 과제도 프로그래머스에 존재한다

추가/삭제 구현 코드

    setEvent() {
        this.$target.addEventListener('click',({target})=>{
            if(target.classList.contains('remove')){
                const items = [...this.state.items]
                items.splice(target.dataset.index,1)
                this.setState({items:items})
                return
            }
            if(target.classList.contains('add')){
                const items = [...this.state.items]
                items.push(`item${++this.index}`)
                this.setState({items:items})
                return
            }
        })
    }

    setup() {
        this.state = {items:['item1','item2']}
        this.index = 2
    }

    template() {
        const {items} = this.state
        return `
         <button class="add">추가</button>
        <ul>
            ${items.map((item,key)=>`
                <li>${item}
                <button class="remove" data-index="${key}">삭제</button>
                </li> 
                `).join("")}
         </ul>
         `
    }

우선 추가 버튼의 위치를 바꿨는데 테스트할 때 추가하는 버튼이 밑에 있으니 마우스를 움직이이가 귀찮아서..

Items 컴포넌트를 등록할 $target에 이벤트를 주고 event.target에 클래스로 삭제와 추가 버튼을 판별하고 setState로 값을 변경해준다

클래스 필드로 index를 초기화하려니 constructor 부분에서 에러가 발생해서 그냥 메서드로 초기화해줬다

이벤트 추상화

동적인 기능을 하는 컴포넌트라면 백이면 백 이벤트를 등록해야하고 그 중에서 이벤트 위임/버블링을 써서 관리하지 않아도 되는 컴포넌트도 있겠지만

분명 이러한 방식의 이벤트 관리는 자주 일어나는 패턴이기 때문에 추상화를 해주는 것이 가독성 및 여러 방면에서 도움이된다

//Component.js
...
  addEvent (eventType, selector, callback) {
    const children = [ ...this.$target.querySelectorAll(selector) ]; 

    const isTarget = (target) => children.includes(target) || target.closest(selector);

    this.$target.addEventListener(eventType, event => {
      if (!isTarget(event.target)) return false;
      callback(event);
    })
  }
...

등록할 이벤트 유형의 이벤트 타입, querySelector로 엘리먼트를 찾아오기 위한 셀렉터, 그리고 실행할 콜백 함수를 받는 addEvent를 만들어줬다

    const children = [ ...this.$target.querySelectorAll(selector) ]; 

컴포넌트가 등록되는 $target에서 이벤트를 등록해야하는 엘리먼트들을 querySelectorAll로 모두 찾아와서 배열로 만든다

(이전에는 node로 구성되어있는 유사 배열)

    this.$target.addEventListener(eventType, event => {
      if (!isTarget(event.target)) return false;
      callback(event);
    })

$target에 파라미터로 받은 이벤트 타입으로 이벤트를 등록하고 이벤트 타겟이 유효한지 확인하기 위해서 isTarget함수로 확인한다

값이 참이면 파라미터로 받은 callback에 현재 이벤트를 넘겨주고 실행시킨다 <--- 이벤트 발생

값이 거짓이면 함수 종료

    const isTarget = (target) => children.includes(target) || target.closest(selector);

현재 발생한 이벤트의 대상이 children 즉, selector로 찾아온 엘리먼트들 중에 있는지 확인하고 있다면 참을 반환

그렇지 않다면 본인부터 시작해서 자신의 부모를 타고 올라가며 가장 가까운 selector를 찾아서 존재한다면 참을 반환

그마저도 없다면 거짓을 반환


왜 isTarget에 children.includes(target) || target.closest(selector) 두 가지로 검사하는 것일까?

add이벤트는 Items가 생성될 때 처음 한 번 등록된다

그래서 children은 처음에 Items를 App에서 등록했을 때의 생성되는 button과 아이템 두 가지만가져온다

여기서 등록했을 때라는 의미란 추가나 삭제버튼을 눌러서 setState에 의해 바뀐 상태값으로 다시 렌더링하게되면

기존의 엘리먼트들은 없어지고 새로 생성되기 때문에 children은 더이상 제 역할을 하지 못한다

그래서 동적 상황 이후를 대비하기위해서 closest으로 selector를 다시 확인하는 것


궁금한점

추상화를 해서 코드는 정말 간결해졌지만 삭제와 추가 이벤트 두 가지가 등록되어서 target에 클릭할 때마다 두 번의 이벤트가 실행되고 있다 괜찮은 걸까?

어짜피 버튼 한 번만 누르면 쓸모가 없어지는 children인데 굳이 querySelectorAll을 사용해서 엘리먼트들을 가져와서 사용해야할까? 처음부터 closest만 사용하면 안되는 걸까?