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

03. 바닐라 자바스크립트로 SPA - 컴포넌트 2

js0616 2024. 8. 11. 03:43

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

 

이전글에 이어서

 


4. 컴포넌트 분할하기

이제 컴포넌트 단위로 구분하는 코드를 작성해보자.

(1) 기능 추가

현재 까지의 코드에서는 컴포넌트를 분리할 이유가 없는 상태이다.

그래서 Items 컴포넌트에 toggle, filter 등의 기능을 추가 했을 때 먼저 어떤 문제점이 있는지 알아야한다.

 

아래와 같이 만들기

 

1. add 를 input + enter key 입력으로 변경

2. 활성 비활성 토글 추가

3. 활성/비활성 에 따른 필터 기능 

 

 

1. add 를 input + enter key 입력으로 변경

    // appender
    this.addEvent('keyup','.appender',(event) =>{
      if (event.key == 'Enter'){
        const items = [...this.state.items];
        this.setState({items : [...items, `${event.target.value}`]});
       
        // 포커스가 왜안될까..
        event.target.focus();
      }
    });

 

 

2번을 하려고 보니 state 가 애매해서 참고

-> state의 items 를 세분화해서 진행

1개의 item 에 대해서 { seq, contents, active } 를 가지도록 함. 

    this.state = {
      items: [
        { seq:1 ,
          contents: 'item1',
          active : false
        },
        { seq:2 ,
          contents: 'item2',
          active : true
        }      
      ],
    };

 

addBtn 제거, 활성/비활성 button 생성

  template () {
    const { items } = this.state;
    return `
      <input class="appender"/>
      <ul>
        ${items.map((item,key) => `
            <li>
              ${item.contents}
              <button>${item.active ? '활성' : '비활성'} </button>              
              <button class ="deleteBtn" data-index="${key}">삭제</button>
            </li>
          `).join('')}
      </ul>
    `
  }

 

 

    // appender
    this.addEvent('keyup','.appender',(event) =>{
      if (event.key == 'Enter'){
        // console.log(event.target.value)
        const items = [...this.state.items];
        const seq = Math.max(0, ...items.map(v => v.seq)) + 1
        const contents = event.target.value;
        const active = false;

        this.setState({
          items : [
            ...items,
            {seq,contents,active}
            ]
          });        
      }
    });

 


활성/비활성 클릭시 토글 기능

 

map 함수에서 이제 key 대신 seq 사용할 수 있다. 

template () {
    const { items } = this.state;
    return `
      <input class="appender"/>
      <ul>
        ${items.map((item,key) => `
            <li data-seq="${item.seq}">
              ${item.contents}
              <button class="toggleBtn">${item.active ? '활성' : '비활성'} </button>              
              <button class ="deleteBtn" data-index="${key}">삭제</button>
            </li>
          `).join('')}
      </ul>
    `
  }

 

클릭한 item 에 대해서 seq 를 활용하여 해당 요소를 toggle 기능 

closest 은 특정 CSS 선택자와 일치하는 가장 가까운 조상 요소를 찾는 데 사용됩니다.

 

따라서 toggleBtn 클래스가 포함된 버튼을 클릭시 특정 seq 를 가지는 item 을 구별할 수 있음

    // toggle
    this.addEvent('click', '.toggleBtn', ({target})=>{
      const items = [...this.state.items];
      console.log(target.closest(['[data-seq']));

    })

 

 

HTML의 data- 속성은 JavaScript에서 dataset 속성으로 자동 변환됩니다. 속성 이름은 카멜 케이스로 변환됩니다.

// HTML
<div id="myElement" data-user-id="123" data-role="admin">Content</div>

// JS
const element = document.getElementById('myElement');
console.log(element.dataset.userId); // "123"
console.log(element.dataset.role);   // "admin"

 

따라서 data-seq 는 dataset.seq 로 가져올 수 있다.

 

findIndex는 배열에서 특정 조건을 만족하는 첫 번째 요소의 인덱스를 찾는 메서드입니다.

조건을 만족하는 요소가 없으면 -1을 반환합니다.

이 메서드는 배열의 각 요소에 대해 제공된 함수(callback)를 호출하고, 그 함수가 true를 반환하는 첫 번째 요소의 인덱스를 반환합니다.

 
array.findIndex(callback(element[, index[, array]])[, thisArg])
 

 

 

    // toggle
    this.addEvent('click', '.toggleBtn', ({target})=>{
      const items = [...this.state.items];
      const seq = Number(target.closest(['[data-seq']).dataset.seq);
      const index = items.findIndex(v => v.seq === seq)
      items[index].active = !items[index].active;
      this.setState({items:items});
    })

 


key를 사용하던 삭제 기능도 data-seq  를 이용해서 바꿔보자.

 

삭제 button 에 있던 data-index="${key}" 를 제거하고

  template () {
    const { items } = this.state;
    return `
      <input class="appender"/>
      <ul>
        ${items.map((item,key) => `
            <li data-seq="${item.seq}">
              ${item.contents}
              <button class="toggleBtn">${item.active ? '활성' : '비활성'} </button>              
              <button class ="deleteBtn">삭제</button>
            </li>
          `).join('')}
      </ul>
    `
  }

 

토글에서 한것처럼 seq 를 찾은다음 index 를 찾아서 해당 splice 함수로 제거 한 뒤 setState 로 상태관리를 해준다.

    // delete
    this.addEvent('click','.deleteBtn', ({target})=>{
      const items = [...this.state.items];
      const seq = Number(target.closest(['[data-seq']).dataset.seq);
      const index = items.findIndex(v => v.seq === seq)
      items.splice(index,1)
      this.setState({items: items})
    });

 

3. 활성/비활성 에 따른 필터 기능 

 

isFilter 는 1가지의 상태를 가진다.  전체(0) / 활성(1) / 비활성(2) 

이를 state 로 만들어 버튼을 클릭할때마다 값을 바꿔서 사용할 수 있다.

해당 값에 따라서 렌더링 되는 부분을 조절하여 필터 기능을 구현 한다. 

setup () {
    this.state = {
      isFilter : 0,
      items: [
        { seq:1 ,
          contents: 'item1',
          active : false
        },
        { seq:2 ,
          contents: 'item2',
          active : true
        }      
      ],
    };
  }

 

전체 / 활성 / 비활성 보기 버튼을 만들어준다. 

 template () {
    const { items } = this.state;
    return `
      <input class="appender"/>
      <ul>
        ${items.map((item,key) => `
            <li data-seq="${item.seq}">
              ${item.contents}
              <button class="toggleBtn">${item.active ? '활성' : '비활성'} </button>              
              <button class ="deleteBtn">삭제</button>
            </li>
          `).join('')}
      </ul>
      <button class="filterBtn" data-is-filter="0"> 전체보기 </button>
      <button class="filterBtn" data-is-filter="1"> 활성보기 </button>
      <button class="filterBtn" data-is-filter="2"> 비활성보기 </button>
    `
  }

 

data-is-filter 의 경우 dataset.isFilter 로 가져올 수 있다. (카멜 케이스가 됨) 

해당 버튼 클릭시 Filter state 를 해당 값으로 바뀌게 된다.

 

    //filter
    this.addEvent('click', '.filterBtn', ({target}) =>{
      const isFilter = Number(target.dataset.isFilter)
      this.setState({isFilter : isFilter})
    });
  }

 

 

바뀐 isFilter 에 따라서 items 를 filter 하여 filteredItems 에 저장한다. 

  get filteredItems () {
    const { isFilter, items } = this.state;
    return items.filter(({ active }) =>
      (isFilter === 1 && active)  ||
      (isFilter === 2 && !active) ||
      isFilter === 0
    );
  }

 

기존 items.map 에서 this.filteredItems.map 으로 변경, key 제거 , 

template () {
    // const { items } = this.state;
    return `
      <input class="appender"/>
      <ul>
        ${this.filteredItems.map(item => `
            <li data-seq="${item.seq}">
              ${item.contents}
              <button class="toggleBtn">${item.active ? '활성' : '비활성'} </button>              
              <button class ="deleteBtn">삭제</button>
            </li>
          `).join('')}
      </ul>
      <button class="filterBtn" data-is-filter="0"> 전체보기 </button>
      <button class="filterBtn" data-is-filter="1"> 활성보기 </button>
      <button class="filterBtn" data-is-filter="2"> 비활성보기 </button>
    `
  }

 

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

export default class Items extends Component {

  get filteredItems () {
    const { isFilter, items } = this.state;
    return items.filter(({ active }) =>
      (isFilter === 1 && active)  ||
      (isFilter === 2 && !active) ||
      isFilter === 0
    );
  }

  setup () {
    this.state = {
      isFilter: 0,
      items: [
        { seq:1 ,
          contents: 'item1',
          active : false
        },
        { seq:2 ,
          contents: 'item2',
          active : true
        }      
      ],
    };
  }
  template () {
    // const { items } = this.state;
    return `
      <input class="appender"/>
      <ul>
        ${this.filteredItems.map(item => `
            <li data-seq="${item.seq}">
              ${item.contents}
              <button class="toggleBtn">${item.active ? '활성' : '비활성'} </button>              
              <button class ="deleteBtn">삭제</button>
            </li>
          `).join('')}
      </ul>
      <button class="filterBtn" data-is-filter="0"> 전체보기 </button>
      <button class="filterBtn" data-is-filter="1"> 활성보기 </button>
      <button class="filterBtn" data-is-filter="2"> 비활성보기 </button>
    `
  }

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

    // appender
    this.addEvent('keyup','.appender',(event) =>{
      if (event.key == 'Enter'){
        // console.log(event.target.value)
        const items = [...this.state.items];
        const seq = Math.max(0, ...items.map(v => v.seq)) + 1
        const contents = event.target.value;
        const active = false;

        this.setState({
          items : [
            ...items,
            {seq,contents,active}
            ]
          });        
      }
    });

    // delete
    this.addEvent('click','.deleteBtn', ({target})=>{
      const items = [...this.state.items];
      const seq = Number(target.closest(['[data-seq']).dataset.seq);
      const index = items.findIndex(v => v.seq === seq)
      items.splice(index,1)
      this.setState({items: items})
    });

    // toggle
    this.addEvent('click', '.toggleBtn', ({target})=>{
      const items = [...this.state.items];
      const seq = Number(target.closest(['[data-seq']).dataset.seq);
      const index = items.findIndex(v => v.seq === seq)
      items[index].active = !items[index].active;
      this.setState({items:items});
    });

    //filter
    this.addEvent('click', '.filterBtn', ({target}) =>{
      const isFilter = Number(target.dataset.isFilter)
      this.setState({isFilter : isFilter})
    });
  }
}




 

요구한 기능을 모두 만든 Items 는 이제 여러가지 기능을 가지게 되어 컴포넌트 단위로 활용하기 어려워졌다.

따라서, 다시 기능별로 나누어 정리하도록 한다. 

 

├── index.html
└── src
    ├── App.js               # main에서 App 컴포넌트를 마운트한다.
    ├── main.js              # js의 entry 포인트
    ├── components
    │   ├── ItemAppender.js
    │   ├── ItemFilter.js
    │   └── Items.js
    └── core
        └── Component.js

 

  • 기존의 entry point가 app.js에서 main.js가 되었다
  • App Component를 추가했다.
  • Items에서 ItemAppender, ItemFilter 등을 분리했다.

 


(3) Component Core 변경

그리고 src/core/Component.js에 다음과 같이 props와 mounted를 추가해야 한다.

  • mounted를 추가한 이유는 render 이후에 추가적인 기능을 수행하기 위해서이다.
  • props는 부모 컴포넌트가 자식 컴포넌트에게 상태 혹은 메소드를 넘겨주기 위해서이다.
// ./src/core/Component.js
export default class Component {
    $target;
    props;
    state;
    constructor ($target, props) {
        this.$target = $target; // 부모, 특정 DOM 위치
        this.props = props;
        this.setup();
        this.render();
        this.setEvent();

    }
    setup () {}; // 초기 상태를 설정
    mounted() {};
    template () { return ''; } // HTML 작성
    render () { // 렌더링
        this.$target.innerHTML = this.template();
        this.mounted() ; //  render 후에 mounted가 실행 된다.
    }
    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);
        })
      }
}

 

(4) Entry Point 변경

  • index.html : 기존에 app.js가 아닌 main.js를 가져온다.
// src/main.js
import App from './app.js';

new App(document.querySelector('#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> -->
    <script src="./src/main.js" type="module"></script>
</body>
</html>

 

(5) 컴포넌트 분할

기존의 Items에 존재하던 로직을 App.js에 넘겨주고, Items, ItemAppender, ItemFilter 등은 App.js에서 넘겨주는 로직을 사용하도록 만들어야 한다.

-> main.js 가 App.js 를 불러오며 App.js 에서 Items, ItemAppender, ItemFilter 을 모아서 로직을 구성? 

 

 

부모 컴포넌트

App.js : state / setState  / 자식 컴포넌트 template() / mount / 함수 정의  

// ./src/app.js
// 기존의 Items 에서 하던 역할
// 초기 state / setState  / 자식 컴포넌트 모아서 template() / mount / 함수 정의  

import Component from "./core/Component.js";
import Items from "./components/Items.js";
import ItemAppender from "./components/ItemAppender.js";
import ItemFilter from "./components/ItemFilter.js";

export default class App extends Component {
    setup () {
        this.state = {
          isFilter: 0,
          items: [
            { seq:1 ,
              contents: 'item1',
              active : false
            },
            { seq:2 ,
              contents: 'item2',
              active : true
            }      
          ],
        };
      }

    template(){
        return `
            <div data-component="item-appender"></div>
            <div data-component="items"></div>
            <div data-component="item-filter"></div>
        `
    }

    // mounted에서 자식 컴포넌트를 마운트 해줘야 한다.
    mounted () {
        const { filteredItems, addItem, deleteItem, toggleItem, filterItem } = this;
        const $itemAppender = this.$target.querySelector('[data-component="item-appender"]');
        const $items = this.$target.querySelector('[data-component="items"]');
        const $itemFilter = this.$target.querySelector('[data-component="item-filter"]');

        // 하나의 객체에서 사용하는 메소드를 넘겨줄 bind를 사용하여 this를 변경하거나,
        // 다음과 같이 새로운 함수를 만들어줘야 한다.
        // ex) { addItem: contents => addItem(contents) }
        new ItemAppender($itemAppender, {
            addItem: addItem.bind(this)
        });
        new Items($items, {
            filteredItems,
            deleteItem: deleteItem.bind(this),
            toggleItem: toggleItem.bind(this),
        });
        new ItemFilter($itemFilter, {
            filterItem: filterItem.bind(this)
        });
    }


    get filteredItems () {
        const { isFilter, items } = this.state;
        return items.filter(({ active }) =>
            (isFilter === 1 && active)  ||
            (isFilter === 2 && !active) ||
           isFilter === 0
        );
    }

    // appender 코드와 유사
    addItem (contents){
        const items = [...this.state.items];
        const seq = Math.max(0, ...items.map(v => v.seq)) + 1
        const active = false;
           
        this.setState({
            items : [
                ...items,
                {seq,contents,active}
                ]
            });      
    }

    // delete 와 유사
    deleteItem(seq){
        const items = [...this.state.items];
        const index = items.findIndex(v => v.seq === seq)
        items.splice(index,1)
        this.setState({items: items})
    }

    // toggle 과 유사
    toggleItem(seq){
        const items = [...this.state.items];
        const index = items.findIndex(v => v.seq === seq)
        items[index].active = !items[index].active;
        this.setState({items:items});
    }

    //
    filterItem(isFilter){
        this.setState({isFilter:isFilter})
    }
}

 

 

자식컴포넌트

template() 와 setEvent() 로 구성되며, 로직에 필요한 데이터는 this.props 로 전달 받고

부모(App.js) 에 정의된 함수 addItem, filterItem 등을 사용하여 이벤트를 등록한다.

 

  • Items.js : 기본 item 구조 / delete , toggle 기능
  • ItemAppender.js : 입력창 / add 기능
  • ItemFilter.js : 필터 버튼 / filter 기능..? 

 

// src/components/ItemAppender.js
// input 창에 입력한 정보를 props 로 넘겨주는 역할?

import Component from "../core/Component.js";

export default class ItemAppender extends Component{
    template(){
        return `<input type="text" name="addInput" class="appender"/>`;
    }

    setEvent() {
        const { addItem } = this.props;
        this.addEvent('keyup', '.appender', ({ key, target }) => {
          if (key !== 'Enter') return;
          addItem(target.value);
        });
      }
}

 

// src/components/ItemFilter.js
// filter 기능

import Component from "../core/Component.js";

export default class ItemFilter extends Component{
    template() {
        return `
            <button class="filterBtn" data-is-filter="0"> 전체보기 </button>
            <button class="filterBtn" data-is-filter="1"> 활성보기 </button>
            <button class="filterBtn" data-is-filter="2"> 비활성보기 </button>
        `
    }

    setEvent(){
        const {filterItem} = this.props
        this.addEvent('click', '.filterBtn', ({target}) =>{
            const isFilter = Number(target.dataset.isFilter)
            filterItem(isFilter)
          });

    }
}
// ./src/components/Items.js
//  Items 기본 template 와 delete 와 toggle 기능

import Component from "../core/Component.js";

export default class Items extends Component {
 
  template () {
    const {filteredItems} = this.props;
    return `
      <ul>
        ${filteredItems.map(item => `
            <li data-seq="${item.seq}">
              ${item.contents}
              <button class="toggleBtn">${item.active ? '활성' : '비활성'} </button>              
              <button class ="deleteBtn">삭제</button>
            </li>
          `).join('')}
      </ul>
    `
  }

  setEvent () {
    const {deleteItem, toggleItem} = this.props;

    this.addEvent('click', '.deleteBtn', ({target}) =>{
      deleteItem(Number(target.closest('[data-seq]').dataset.seq));
    })
    this.addEvent('click', '.toggleBtn', ({target}) =>{
      toggleItem(Number(target.closest('[data-seq]').dataset.seq));
    })

  }
}

 


App.js 에서 filteredItems , addItem, deleteItem, toggleItem, filterItem 은 기존에 작성한 코드와 유사하다.

template 와 mount 에 대해서만 조금 더 보면 

 

  • mounted를 추가한 이유는 render 이후에 추가적인 기능을 수행하기 위해서이다.
  • props는 부모 컴포넌트가 자식 컴포넌트에게 상태 혹은 메소드를 넘겨주기 위해서이다.

 

 

1. 상태와 메서드 추출

const { filteredItems, addItem, deleteItem, toggleItem, filterItem } = this;
console.log('mounted', this)

 

Component 의 Prototype 에 해당 기능들이 들어있다.

 

2. 자식 컴포넌트의 DOM 요소 선택

    template(){
        return `
            <div data-component="item-appender"></div>
            <div data-component="items"></div>
            <div data-component="item-filter"></div>
        `
    }
 
 
const $itemAppender = this.$target.querySelector('[data-component="item-appender"]');
const $items = this.$target.querySelector('[data-component="items"]');
const $itemFilter = this.$target.querySelector('[data-component="item-filter"]');

 

3. 자식 컴포넌트 초기화

       
        // 하나의 객체에서 사용하는 메소드를 넘겨줄 bind를 사용하여 this를 변경하거나,
        // 다음과 같이 새로운 함수를 만들어줘야 한다.
        // ex) { addItem: contents => addItem(contents) }
        new ItemAppender($itemAppender, {
            addItem: addItem.bind(this)
        });
        new Items($items, {
            filteredItems,
            deleteItem: deleteItem.bind(this),
            toggleItem: toggleItem.bind(this),
        });
        new ItemFilter($itemFilter, {
            filterItem: filterItem.bind(this)
        });

 

ItemAppender 를 생성자로 사용해서 새로운 인스턴스를 만드는데

$itemAppender는 해당 인스턴스의 dom 위치 또는 부모요소 가 되고

새로 생긴 인스턴스에서 addItem 메서드나 state 를 넘겨줄때 bind(this) 를 사용한다..?

 

filteredItems 는 메서드가 아닌 state 에 가까운 특정 값 

 


좋은글 감사합니다. 어렵네...

 

 

https://chatgpt.com/