연습장

02. 바닐라 자바스크립트로 SPA - 컴포넌트 1 본문

클론코딩해보기/환경 세팅

02. 바닐라 자바스크립트로 SPA - 컴포넌트 1

js0616 2024. 8. 9. 22:59

 

https://junilhwang.github.io/TIL/Javascript/Design/Vanilla-JS-Component/#_2-state-setstate-render

 

Vanilla Javascript로 웹 컴포넌트 만들기 | 개발자 황준일

Vanilla Javascript로 웹 컴포넌트 만들기 9월에 넥스트 스텝open in new window에서 진행하는 블랙커피 스터디open in new window에 참여했다. 이 포스트는 스터디 기간동안 계속 고민하며 만들었던 컴포넌트

junilhwang.github.io

 

ssr 에서 csr 로 넘어오면서 브라우저에서 렌더링하며 서버에서는 필요한 정보만 전달해주는 형태로 변화

DOM 을 직접 다루기 보다는 state 의 변화에 따라서 DOM 을 리렌더링한다. 

그래서 state 를 관리, 상태관리의 중요성이 높아지게 되었음. 

 

또한 컴포넌트 단위의 개발을 하므로,  컴포넌트를 렌더링할 때 필요한 상태를 관리하게 되었으며, Proxy 혹은 Observer Pattern 등을 이용하여 이를 구현한다.

 

(1) 기능 구현

먼저 간단한게 setState 라는 메소드를 통해서 state를 기반으로 render를 해주는 코드를 만들어보자.

 

  • state가 변경되면 render를 실행한다.
  • state는 setState로만 변경해야 한다.

브라우저 출력되는 내용은 무조건 state에 종속되는 것이다. 즉, DOM을 직접적으로 다룰 필요가 없어진다.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id="app"></div>
   
    <!-- script -->
    <script>
        const $app = document.querySelector('#app');

        let state = {
        items: ['item1', 'item2', 'item3', 'item4']
        }

        const render = () => {
        const { items } = state;
        $app.innerHTML = `
            <ul>
            ${items.map(item => `<li>${item}</li>`).join('')}
            </ul>
            <button id="append">추가</button>
        `;
        document.querySelector('#append').addEventListener('click', () => {
            setState({ items: [ ...items, `item${items.length + 1}` ] })
        })
        }

        const setState = (newState) => {
        state = { ...state, ...newState };
        render();
        }

        render();
    </script>
</body>
</html>

 

$는 유일한 변수 라는 걸 강조? 하는 관습적인 접두사

const { items } = state;는 state 객체에서 items 속성을 추출해 items라는 변수에 저장합니다. 구조분해할당

템플릿 리터럴 : `${변수명}`

map 을 쓰면 list 가 반환되어 , 가 불필요하게 노출 되므로 join 으로 string 으로 변환 

append 클릭시 setState 를 통해 state 변경하고

state 변경시 render 를 해야된다. 

 


state 변경 원리

setState({ items: [ ...items, `item${items.length + 1}` ] })

items 라는 state 에 대해서 , 기존 items + 새로운 items 를 추가하며

 

state = { ...state, ...newState };

전체 state 중에서 바뀐 state 에 대해서만 처리 

 

동일한 속성이 있다면, newState의 값이 state의 값보다 우선하여 덮어씌우는 방식

 

간단한 예시 

let state = { items: ['item1', 'item2'], count: 2 };
const newState = { items: ['item3', 'item4'] };

state = { ...state, ...newState };
 
console.log(state) // { items: ['item3', 'item4'], count: 2 }

 

 

뭔가 js 에서 html 코딩을 하는느낌.. 


(2) 추상화

앞서 작성한 코드를 class 문법으로 추상화시켜보자.

 

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id="app"></div>
   
    <script>
        class Component {
            $target;
            state;
            constructor ($target) {
                this.$target = $target;
                this.setup();
                this.render();
            }
            setup () {};
            template () { return ''; }
            render () {
                this.$target.innerHTML = this.template();
                this.setEvent();
            }
            setEvent () {}
            setState (newState) {
                this.state = { ...this.state, ...newState };
                this.render();
            }
        }

        class App 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>
                `
            }
           
            setEvent () {
                this.$target.querySelector('button').addEventListener('click', () => {
                const { items } = this.state;
                this.setState({ items: [ ...items, `item${items.length + 1}` ] });
                });
            }
        }

        new App(document.querySelector('#app'));
</script>
</body>
</html>

 

 

  • Component 클래스:
    • 기본적인 구조를 제공하는 클래스입니다.
    • setup(), render(), setEvent(), setState() 메소드를 정의하여 공통적인 기능을 캡슐화합니다.
      • $target은 클래스의 인스턴스가 렌더링할 대상 DOM 요소를 가리킵니다. 위치, 부모
      • setup(): 초기 상태를 설정하거나 기본 설정을 수행합니다.
      • render(): 렌더링
      • setEvent(): 이벤트 관리
      • setState(newState): 상태(state) 를 업데이트하고, render()를 호출하여 변경 사항을 반영합니다.

 


(3) 모듈화

보통 한 파일안에 모든 기능을 작성하는 경우는 없을 것이므로 앞서 작성한 코드를 다음과 같이 분할해보자.

 

├── index.html
└── src
    ├── app.js                    # ES Module의 entry file
    ├── components          # Component 역할을하는 것들
    │  └── Items.js
    └── core                       # 구현에 필요한 코어들
        └── Component.js

 

 

// ./src/core/Component.js
export default class Component {
    $target;
    state;
    constructor ($target) {
        this.$target = $target;
        this.setup();
        this.render();
    }
    setup () {}; // 초기 상태를 설정
    template () { return ''; } // HTML 작성
    render () { // 렌더링
        this.$target.innerHTML = this.template();
        this.setEvent();
    }
    setEvent () {} // 이벤트 작성
    setState (newState) { // 상태 업데이트
        this.state = { ...this.state, ...newState };
        this.render();
    }
}

 

// ./src/components/Items.js
import Component from "../core/Component.js";

export default 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>
    `
  }

  setEvent () {
    this.$target.querySelector('button').addEventListener('click', () => {
      const { items } = this.state;
      this.setState({ items: [ ...items, `item${items.length + 1}` ] });
    });
  }
}
// ./src/app.js
// 기존 :  new App(document.querySelector('#app'));

import Items from "./components/Items.js";

class App {
    constructor(){

        // Component 클래스의 매개변수로 들어가는 $target 인 DOM 요소  
        const $app = document.querySelector("#app");

        // #app DOM 요소를 $target 으로 하여 인스턴스 Items 를 생성
        new Items($app);
    }
}

new App();

 

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id="app"></div>
   
    <script src="./src/app.js" type="module"></script>
</body>
</html>

 

html 이 app.js 를 참고하는데 -> app.js 에서 Items 로 컴포넌트(인스턴스)를 생성 한다 볼 수 있음 

Items 는 Component 를 상속 받은 생성자 ?

 

하나 더 추가하면 

// ./src/app.js
// 기존 :  new App(document.querySelector('#app'));

import Items from "./components/Items.js";

class App {
    constructor(){

        // Component 클래스의 매개변수로 들어가는 $target 인 DOM 요소  
        const $app = document.querySelector("#app");
        const $app2 = document.querySelector("#app2");

        // #app DOM 요소를 $target 으로 하여 인스턴스 Items 를 생성
        new Items($app);
        new Items($app2);
    }
}

new App();

 

서로 다른 상태를 가질 수 있는 인스턴스를 만들 수 있음 .. ?

 


3. 이벤트 처리

(1) 불편함을 감지하기

 

 

삭제 기능을 추가

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

 

삭제 버튼을 만들고 key 를 활용하여 삭제할 항목을 구분하며

기존에 button 으로 동작하던 코드에 오류가 발생하므로 addBtn 도 추가하여 '추가 기능' 또한 수정한다.

 

setEvent 에 삭제 기능을 추가 해준다.

클릭한 '삭제 버튼'에 따라 다음과 같이 정보를 얻을 수 있다.

    this.$target.querySelectorAll('.deleteBtn').forEach(deleteBtn =>
      deleteBtn.addEventListener('click', (event) => {
        console.log(event.target)
        console.log(event.target.dataset.index)

    }));

 

 

 

얻어낸 인덱스를 활용하여 기존의 state 를 수정하고 렌더링해준다. 

    this.$target.querySelectorAll('.deleteBtn').forEach(deleteBtn =>
      deleteBtn.addEventListener('click', (event) => {
        // console.log(event.target.dataset.index)
       
        const {items} = this.state;
        // const items = [ ...this.state.items ];
       
        items.splice(event.target.dataset.index, 1)
       
        this.setState({items})
        // this.setState({items:items})
       
        console.log(this.state)

    }));

주석처리한 코드는 같은 기능을 하는 코드.. 구조분해할당이 아직도 눈에 잘안들어온다. 

 

// ./src/components/Items.js
import Component from "../core/Component.js";

export default class Items extends Component {
  setup () {
    this.state = {
      items: ['item1', 'item2'],
      number: [1,2,3]
    };
  }
  template () {
    const { items } = this.state;
    return `
      <ul>
        ${items.map((item,key) => `
            <li>
              ${item}
              <button class ="deleteBtn" data-index="${key}">삭제</button>
            </li>
          `).join('')}
      </ul>
      <button class="addBtn">추가</button>
    `
  }

  setEvent () {
    // add
    this.$target.querySelector('.addBtn').addEventListener('click', () => {
      const { items } = this.state;
      this.setState({ items: [ ...items, `item${items.length + 1}` ] });
    });

    // delete
    this.$target.querySelectorAll('.deleteBtn').forEach(deleteBtn =>
      deleteBtn.addEventListener('click', (event) => {
        const {items} = this.state;
        items.splice(event.target.dataset.index, 1)
        this.setState({items})
    }));
  }
}

 


문제점 : 렌더링 할 때마다 setEvent 가 동작하여 매번 이벤트가 등록된다. 

// ./src/core/Component.js
render () { // 렌더링
        this.$target.innerHTML = this.template();
        this.setEvent();
    }

 

 

이벤트 버블링 사용

 

이벤트 버블링은 하위 요소에서 상위요소로 진행되는건데.. 이해는 잘 안되는데.. 

 

기존에는 각각의 버튼을 클릭시 입력된 이벤트가 동작하였다면..

변경 내용은 해당 컴포넌트의 상위요소에서 이벤트를 관리한다고 생각 할 수 있음.

 

// ./src/components/Items.js 
setEvent () {
    this.$target.addEventListener('click',({target}) =>{
      // 클릭시 변경될 items 의 state 를 가져오고
      const items = [...this.state.items];

      // add
      if(target.classList.contains('addBtn')){
        this.setState({ items: [ ...items, `item${items.length + 1}` ] });
      }

      // delete
      if(target.classList.contains('deleteBtn')){
        items.splice(target.dataset.index, 1)
        this.setState({items})
      }
    })
  }

 

  • event를 각각의 하위 요소가 아니라 component의 target 자체에 등록하는 것이다.
  • 따라서 component가 생성되는 시점에만 이벤트 등록을 해놓으면 추가로 등록할 필요가 없어진다.
// ./src/core/Component.js
export default class Component {
    $target;
    state;
    constructor ($target) {
        this.$target = $target; // 부모, 특정 DOM 위치
        this.setup();
        this.render();
        this.setEvent();
    }
    setup () {}; // 초기 상태를 설정
    template () { return ''; } // HTML 작성
    render () { // 렌더링
        this.$target.innerHTML = this.template();
        // this.setEvent();
    }
    setEvent () {} // 이벤트 작성
    setState (newState) { // 상태 업데이트
        this.state = { ...this.state, ...newState };
        this.render();
    }
}

 

다음과 같이 render 에서 제거 후 컴포넌트(Items.js)가 생성될 때 (constructor)  setEvent를 1번만 등록 하여 중복 등록 문제를 해결한다. 


(3) 이벤트 버블링 추상화

그리고 이벤트 버블링을 통한 등록 과정을 메소드로 만들어서 사용하면 코드가 더 깔끔해진다.

 

gpt : addEvent 메서드는 이벤트 위임(Event Delegation)을 사용하여 이벤트를 등록하는 방식입니다. 이 방식은 DOM 요소의 부모 요소에 이벤트 리스너를 붙이고, 해당 부모 요소가 자식 요소에서 발생하는 이벤트를 처리할 수 있게 합니다.

 

-> 기존의 addEventListener를 상위의 Component Class 에서 add Event 메서드로 정의하여 관리 ? 

-> setEvent 안에 개별 이벤트인 addEvent 를 넣어서 전체 이벤트 관리 ....?

-> Click 뿐만아닌 다른 타입의 이벤트에 대해서도 추가 할 수 있을듯하다.. 

 

 
// ./src/core/Component.js
export default class Component {
     (생략)

    addEvent (eventType, selector, callback) {
        const children = [ ...this.$target.querySelectorAll(selector) ];
        this.$target.addEventListener(eventType, event => {
          if (!event.target.closest(selector)) return false;
          callback(event);
        })
      }
}

 

 

  • eventType: 이벤트의 타입(예: 'click', 'mouseover')을 나타냅니다.
  • selector: 이벤트를 처리할 특정 자식 요소를 선택하기 위한 CSS 선택자 (.addBtn , .deleteBtn)
  • callback: 이벤트가 발생했을 때 실행할 함수입니다.

 

 

this.$target.addEventListener(eventType, event => { ... }) 구문

  • this.$target 요소에 eventType 이벤트 리스너를 추가합니다.
  • 이벤트를 부모요소에서 처리하겠다는 뜻

 

event.target

  • 이벤트가 실제로 발생한 요소

 

 

if (!event.target.closest(selector)) return false;

  • event.target이 selector와 일치하는지 확인 
  • 클릭한 부분과 addEvent에 지정한 selector 가 일치하는지
  • 일치하는 경우 callback(event) 로 event 호출

 


 

추가 버튼 클릭

-> Items.js 의 this.addEvent 에서 addEvent 가 부모에 존재

-> Component.js 의 addEvent 로 이동

-> this.$target 문 ~  console.log(event.target) 문 확인 

-> 이상 없을시 callback(event)

-> Items.js 의 console.log('items-addEvent') 문 확인

-> this.setState 의 console.log('setState') 확인 

-> 여기서 왜 this.$target 문 ~  console.log(event.target) 문이 다시 나오는걸까?

 


    addEvent (eventType, selector, callback) {
        const children = [ ...this.$target.querySelectorAll(selector) ];
        this.$target.addEventListener(eventType, event => {
            console.log('event target: ',event.target) // 2번씩 나오는 이유가 뭘까
            if (!event.target.closest(selector)) return false;
            callback(event);
        })
      }

 

 


 

여기까지 코드

// ./src/components/Items.js
import Component from "../core/Component.js";

export default class Items extends Component {
  setup () {
    this.state = {
      items: ['item1', 'item2'],
      // number: [1,2,3]
    };
  }
  template () {
    const { items } = this.state;
    return `
      <button class="addBtn">추가</button>
      <ul>
        ${items.map((item,key) => `
            <li>
              ${item}
              <button class ="deleteBtn" data-index="${key}">삭제</button>
            </li>
          `).join('')}
      </ul>
    `
  }

  setEvent () {
    // add
    this.addEvent('click','.addBtn', ({target}) =>{
      const items = [...this.state.items];
      this.setState({items : [...items, `item${items.length + 1}`]});
    });
    // delete
    this.addEvent('click','.deleteBtn', ({target})=>{
      const items = [...this.state.items];
      items.splice(target.dataset.index, 1);
      this.setState({items: items})
    })

  }
}

 

// ./src/core/Component.js
export default class Component {
    $target;
    state;
    constructor ($target) {
        this.$target = $target; // 부모, 특정 DOM 위치
        this.setup();
        this.render();
        this.setEvent();

    }
    setup () {}; // 초기 상태를 설정
    template () { return ''; } // HTML 작성
    render () { // 렌더링
        this.$target.innerHTML = this.template();
    }
    setEvent () {} // 이벤트 작성
    setState (newState) { // 상태 업데이트

        this.state = { ...this.state, ...newState };
        this.render();
    }

    addEvent (eventType, selector, callback) {
        const children = [ ...this.$target.querySelectorAll(selector) ];
        this.$target.addEventListener(eventType, event => {
       
            if (!event.target.closest(selector)) return false;
            callback(event);
        })
      }
}
// ./src/app.js
// 기존 :  new App(document.querySelector('#app'));

import Items from "./components/Items.js";

class App {
    constructor(){

        // Component 클래스의 매개변수로 들어가는 $target 인 DOM 요소  
        const $app = document.querySelector("#app");


        // #app DOM 요소를 $target 으로 하여 인스턴스 Items 를 생성
        new Items($app);
    }
}

new App();
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <div id="app"></div>
    <script src="./src/app.js" type="module"></script>
</body>
</html>

 

개인적인 의견이 섞여있을 수 있습니다.