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>
개인적인 의견이 섞여있을 수 있습니다.