[JS] Vanilla JS로 컴포넌트 분리 및 재사용하기

uuranus·2025년 1월 28일
0
post-thumbnail

컴포넌트

재사용 가능한 의미 있는 기능을 가진 UI 모음

여러 군데에서 동일하게 쓰이는 UI들을 묶어서 하나의 컴포넌트로 분리하면 코드의 중복도 줄이고 재사용성을 높여 전체 서비스의 일관성을 부여할 수 있다.

컴포넌트 클래스

  • js는 함수 단위로 구현하기는 하지만 관련 상태와 메서드의 집합은 클래스인 게 더 익숙하기 때문에 클래스로 구성해보기로 했다.
export default class Component {

    events = [];

    constructor(){
    }
    
    template(){
			return '';
    }

    render(){
				this.setEvents();
    }

    addEvent(){
				this.events.push({ listenerName, callback });
    }

}
  • template
    • 뷰 구조를 가지고 있다. html 문자열을 가지고 있다.
  • render
    • 실제로 화면에 보여줄 때 호출된다.
  • addEvent
    • 해당 컴포넌트가 감지할 이벤트들을 등록한다.
    • addEvent 때는 이벤트를 내부적으로 저장만 하고 render 호출 시 가지고 있는 이벤트들을 등록한다.
    • 이벤트 위임 대신 객체지향적으로 각 컴포넌트가 이벤트를 담당하도록 수어하였다.

재귀적으로 호출하기

  • component 내부에서도 다른 component를 사용할 수도 있다.
    • ex. card를 component로 만들었는데 내부 버튼도 component일 수 있다.
  • 부모 컴포넌트가 내부에 하위 컴포넌트들을 알고 있고 이들을 재귀적으로 render한 후 부모 컴포넌트가 구성되는 방식으로 구현하였다.
  • 재귀적으로 각자 컴포넌트은 알아서 render하도록 하여 컴포넌트를 유연하게 관리할 수 있다.

export class Component {

    children = {
				//name: {
				//		object: new Object(),
				//		parentSelector: ""
				//} 와 같은 방식으로 정보를 가지고 있다.
		};
    events = [];

    constructor() {
        super();
    }

    template() {
        return `
            <div id = "header"> </div>
            <div id = "column"> </div>
        `;
    }

    render(parent) {
				
			 this.parent = parent;
			 parent.innerHTML += this.template();

			 for (const key in this.children) {
            const childParent = root.querySelector(this.children[key].parentSelector) || root;
            this.children[key].object.render(childParent);
        }

    }

    addEvent(listenerName, callback) {
        this.events.push({ listenerName, callback });

    }
}
  • children
    • 하위 컴포넌트 요소들
    • 부모 컴포넌트는 하위 컴포넌트가 어디에 추가될 지 알고 있다.
  • parent의 template을 일단 DOM에 붙이고 하위 컴포넌트에 DOM parent를 전달해준다.
  • 하위 컴포넌트에 부모를 전달해줄 때 root, 즉 현재 컴포넌트를 넘겨주는데 이 이유는 parent로 할 경우 같은 부모내 동일 컴포넌트 인스턴스가 여러개면 selector가 동일하기에 두번째 컴포넌트가 첫번째 컴포넌트로 찾아가서 덮어씌우기 때문이다.
    • (이 사실을 알기 전에는 그냥 컴포넌트별로 난수를 생성해서 공유한 아이디를 가지도록 했는데 사실 root으로 넘겨주면 굳이 필요없다)

컴포넌트 전체에 이벤트 달기

  • 재귀적으로 children들을 render한 후 현재 컴포넌트에 대한 이벤트 리스너를 달아주려고 했다.
  • 그러려면 컴포넌트 전체를 감싸는 element가 하나 있어야 한다.
  • template에 태그로 하나로 묶도록 구성하였다.
    • 그러나, 발생한 문제점
      - template는 그냥 문자열이라서 여기에 붙일 수 없음
      - 그래서 template으로 부모 DOM을 붙인 후 그 DOM을 자식 노드에게 전달해주기로 함

Event Listener 초기화 문제

  • 하나의 루트로 묶인 template을 부모를 기준으로 += 로 누적하고 있다.
  • 그러나, 이러면 부모 태그 밑에 Button 컴포넌트가 여러개 붙이는 경우 첫번째 Button이 추가되고 두번째 Button이 추가할 때 첫번째 Button의 html 까지 합친 새로운 문자열을 다시 부모 태그 밑에 붙이기 때문에 첫번째 Button에 달아놓은 리스너가 초기화되는 문제가 발생하였다.

→ 해결방안

  • 루트 태그는 createElement로 동적으로 생성해서 parent.appendChild하여 기존 DOM이 영향받지 않도록 하였다.

export class Component {

    children = {
				//name: {
				//		object: new Object(),
				//		parentSelector: ""
				//} 와 같은 방식으로 정보를 가지고 있다.
		};
    events = [];

		rootId;
    rootClass = [];
    rootSelectorClassName = "";

    constructor() {
        super();
    }

    template() {
        return `
            <div id = "header"> </div>
            <div id = "column"> </div>
        `;
    }

    render(parent) {
				
			 this.parent = parent;
			 
			 this.createDOM();	
		   parent.appendChild(this.current);
			 this.setEvents(this.current);
    }

		createDOM() {
        const wrapper = document.createElement("div");

        if (this.rootId) {
            wrapper.id = this.rootId;
        }

        this.rootClass.forEach((className) => {
            wrapper.classList.add(className);
        });

        wrapper.innerHTML = this.template();

        this.renderTree(wrapper);
        this.current = wrapper;

        return wrapper;
	    }

    renderTree(root) {
        for (const key in this.children) {
            const childParent = root.querySelector(this.children[key].parentSelector) || root;
            this.children[key].object.render(childParent);
        }
    }

		setEvents(root) {
        if (root) {
            this.events.forEach(({ listenerName, callback }) => {
                root.addEventListener(listenerName, (event) => callback(event));
            });
        }
    }

    addEvent(listenerName, callback) {
        this.events.push({ listenerName, callback });

    }
}

리렌더링하기

  • 중간에 데이터가 바뀌면서 UI도 그에 맞춰 업데이트해야할 경우가 있다.
  • 현재 render만 있는 경우 제일 처음에 DOM을 전달해주었던 app까지 올라가야 하는데 사실 중간에 변경된 데이터를 관여하는 경우만 업데이트 해도 되는 경우에는 과하게 업데이트를 해주고 있는 것이다.
  • 그래서, 현재 데이터 변경을 관여하는 최상위 컴포넌트에서 부터 다시 렌더링을 하라는 rerender함수를 만들었다.
    • ex. app → a → b,c→ (b 자식) d,e 이렇게 구성되어 있는데 e에서 데이터가 바뀌었고 이 데이터는 d,e만 쓰고 c는 쓰지 않는다면 b에서부터만 다시 DOM 을 그려주면 된다.
export default class Component {

		//...

		rerender() {
        this.clear();
        this.renderTree(this.current);
        this.setEvents(this.current);
    }

    clear() {
        this.current.innerHTML = this.template();
    }

		//...
}
  • 위와 같이 현재 컴포넌트 기준 자식들을 다 비우고 현재 루트 element부터 다시 render를 호출해 재귀적으로 다시 만들도록 구현하였다.
  • rerender를 위해서 this.current로 루트 element도 기억하고 있는 것

최종 코드

export default class Component {
    children = {};
    events = [];

    rootId;
    rootClass = [];
    rootSelectorClassName = "";

    constructor() {
        this.rootSelectorClassName = this.generateRandomString(16);
        this.rootClass.push(this.rootSelectorClassName);
        this.children = {};
        this.events = [];
    }

    generateRandomString(length) {
        const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
        let result = '';
        const charactersLength = characters.length;

        for (let i = 0; i < length; i++) {
            result += characters.charAt(Math.floor(Math.random() * charactersLength));
        }

        return result;
    }

    addRootclass(className) {
        this.rootClass.push(className);
    }

    template() {
        return '';
    }

    render(parent) {
        this.parent = parent;

        this.current = this.createDOM();

        this.parent.appendChild(this.current);

        this.setEvents(this.current);
    }

    createDOM() {
        const wrapper = document.createElement("div");

        if (this.rootId) {
            wrapper.id = this.rootId;
        }

        this.rootClass.forEach((className) => {
            wrapper.classList.add(className);
        });

        wrapper.innerHTML = this.template();

        this.renderTree(wrapper);
        this.current = wrapper;

        return wrapper;
    }

    renderTree(root) {
        for (const key in this.children) {
            const childParent = root.querySelector(this.children[key].parentSelector) || root;
            this.children[key].object.render(childParent);
        }
    }

    setEvents(root) {
        if (root) {
            this.events.forEach(({ listenerName, callback }) => {
                root.addEventListener(listenerName, (event) => callback(event));
            });
        }
    }

    rerender() {
        this.clear();
        this.renderTree(this.current);
        this.setEvents(this.current);
    }

    clear() {
        this.current.innerHTML = this.template();
    }

    addEvent(listenerName, callback) {
        this.events.push({ listenerName, callback });
    }

}

사용 예시

import { Button } from "./Button/button.js";
import Component from "./component.js";

export class AlertDialog extends Component {

    rootId = "alertDialog"

    children = {
        dismiss: {
            object: new Button("취소", null, "dismiss-button"),
            parentSelector: "#alertButtons"
        },
        confirm: {
            object: new Button("삭제", null, "alert-button"),
            parentSelector: "#alertButtons"
        },
    };

    constructor(text, onConfirmClck = () => { }, onDismissClick = () => { }) {
        super();
        this.text = text;

        this.children.confirm.object.addEvent("click", onConfirmClck);
        this.children.dismiss.object.addEvent("click", onDismissClick);
    }

    template() {
        return `
            <div id="alertText" class = "display-medium16"> 
                ${this.text}
            </div>
            <div id = "alertButtons">
            </div>
         `;
    }

}

번외

  • template 재귀호출
 template() {
        return `
            <div id = "header"> ${this.children.header.object.template()}</div>
            <div class = "div">  ${this.children.column.object.template()}</div>
        `;
    }
  • 원래는 이렇게 ${}로 하위 요소들을 추가해놓았는데 이게 이해하기는 편하지만 문제가 있었다.
  • 이미 상위 컴포넌트 단계에서 DOM에 붙일 때 재귀호출로 하위 컴포넌트의 html tag까지 다 만들어져서 붙여지는 문제 발생
  • appendChild를 하기 때문에 재귀 호출하면서 html이 중복되는 문제가 생겼다.
  • 그래서 그냥 껍데기(?)만 남겨놓도록 구현하였다.
profile
Frontend Developer

0개의 댓글