기본사용법

VanillaFront 튜토리얼 — 2. 컴포넌트

VanillaFront 2026. 9. 11. 09:09

https://vanillafront.com 참조

VanillaFront 튜토리얼 — 2. 컴포넌트

첫 편에서 "화면은 JSON 트리로 선언한다"고 이야기했습니다. 이번엔 그 트리의 벽돌 하나 — 컴포넌트가 어떻게 정의되고 어떻게 조립되는지 파고듭니다.


컴포넌트란

VanillaFront에서 화면을 구성하는 모든 UI 단위입니다. 단순한 <div> 하나부터 그리드·다이어그램 같은 복잡한 위젯까지 모두 "컴포넌트"라는 같은 개념으로 다룹니다.

특징을 나열하면:

  1. 하나의 클래스로 정의된 객체입니다. React·Vue가 태그 중심이라면 VanillaFront는 객체 중심입니다.
  2. 1개 또는 여러 개의 element로 구성됩니다. 그리드처럼 내부에 수십 개 DOM 요소를 가진 것도 하나의 컴포넌트로 취급합니다.
  3. 누구나 새로 만들거나 상속받아 확장할 수 있습니다.
  4. JSON 태그 형태로 선언하거나 new로 동적 생성할 수 있습니다.
  5. 자체 elements 외에 다른 컴포넌트를 안에 품는 innerComponents 개념을 갖습니다.
  6. elements, properties, events, innerComponents를 공개함으로써 외부에서 자유롭게 제어할 수 있습니다. (그리드처럼 복잡한 컴포넌트는 일부만 공개하기도 합니다.)
  7. 트리 구조로 부모-자식 관계를 갖습니다.
  8. 자식은 여러 개, 부모는 하나만 가질 수 있습니다.

HTML 태그가 아니라 JSON을 쓰는 이유

일반 HTML로 빨간 div를 쓰면:

<div style="color:red">
    div영역
</div>

VanillaFront에서는:

{
    tagName: 'div',
    innerHTML: 'DIV 영역',
    style: 'color:red'
}

이 방식으로 표현하는 이유는 두 가지입니다.

  • JavaScript는 XML(HTML) 태그보다 JSON을 다루는 게 훨씬 편합니다. 값 대입, 조건 분기, 변수 참조가 자연스럽게 됩니다.
  • JSX 같은 비표준 문법을 도입하지 않아도 됩니다. 표준 JSON 객체이므로 컴파일러·트랜스파일러가 필요 없습니다.

HTML의 태그명 div는 그대로 tagName: 'div'가 되고, 태그 속성은 객체 속성이 됩니다. 매우 직관적입니다.


부모-자식 관계

컴포넌트는 다른 컴포넌트를 포함하거나 포함될 수 있습니다.

{
    tagName: 'div',              // 부모
    tags: [
        { tagName: 'h1', innerHTML: '제목' },      // 자식 1
        { tagName: 'p',  innerHTML: '본문' },      // 자식 2
        {
            tagName: 'div',                        // 자식 3 (내부에 또 자식)
            tags: [
                { tagName: 'button', text: '확인' }
            ]
        }
    ]
}
  • 부모 컴포넌트는 자식 목록을 tags로 선언합니다.
  • 자식 컴포넌트는 부모 하나만 가질 수 있습니다.
  • 결과적으로 화면은 하나의 최상위 컴포넌트에서 시작해 트리 형태로 펼쳐집니다.

컴포넌트의 6가지 구성 요소

컴포넌트 클래스는 자기 자신을 이렇게 소개합니다.

구성 요소뜻

elements 자기가 직접 만든 DOM 요소 목록 (예: element, inner, text, icon)
properties 지원하는 옵션 속성 목록 (예: text, icon, size, disabled)
events 발생시키는 커스텀 이벤트 목록 (예: click, focus, blur)
innerComponents 안에 품고 있는 다른 컴포넌트 목록 (예: DatePicker의 menuButton)
methods 외부에서 호출 가능한 메서드 (예: setText, focus)
CSS 파일 컴포넌트별로 이미 스타일이 정의되어 있음

Va.Button으로 예를 들어 보면 대략 이렇습니다:

Va.Button = class extends Va.Component {
    constructor(option) {
        super('button', option);
        this.tagName = 'button';
        this.elements   = [...this.elements,   'element', 'inner', 'icon', 'text'];
        this.properties = [...this.properties, 'text', 'icon', 'iconPosition', 'size', 'disabled'];
        this.events     = [...this.events,     'click', 'focus', 'blur', 'contextmenu'];
        this.setOption();

        // DOM 생성, 이벤트 바인딩...
    }
    update() { /* 상태 → DOM 반영 */ }
};
Va.registerComponent('button', Va.Button);
  • Va.Component를 상속받고
  • 이 클래스의 elements / properties / events를 배열에 등록하고
  • Va.registerComponent('button', ...) 로 등록하면 이제 다른 곳에서 tagName: 'button'으로 사용할 수 있습니다.

이벤트 — on 접두어 하나로 받는다

VanillaFront의 이벤트는 DOM 이벤트가 아니라 컴포넌트 이벤트입니다. 이유는 간단해요 — 하나의 컴포넌트가 여러 element로 이루어져 있을 수 있어서, 그 컴포넌트를 대표하는 사용자 이벤트가 필요하기 때문입니다.

규칙

  • 컴포넌트는 자신이 발생시키는 이벤트를 events에 등록합니다.
  • View에서 on + 이벤트명(대문자 시작) 형태의 문자열로 콜백 메서드를 연결합니다.
class App extends Va.View {
    onButtonClick(btn, element, evt) {
        new Va.Alert({
            title: '확인',
            message: '버튼을 클릭했습니다.'
        }).show(this);
    }
    config() {
        return {
            tagName: 'div',
            tags: [{
                tagName: 'button',
                text: '버튼',
                ref: 'button',
                onClick: 'onButtonClick'    // ← 문자열로 메서드명 연결
            }]
        };
    }
}

onClick: 'onButtonClick' — 이렇게 문자열로 연결하는 게 특징입니다. onClick은 click 이벤트의 콜백 자리입니다.

코드에서 직접 받고 싶다면

mounted() {
    this.getRef('button').addEventListener('click', (btn, element, evt) => {
        new Va.Alert({ title: '확인', message: '버튼을 클릭했습니다.' }).show(this);
    });
}

이 addEventListener는 DOM의 것이 아니라 컴포넌트의 것입니다. 따라서 파라미터도 컴포넌트가 정의한 btn, element, evt 순서 그대로 들어옵니다.

지원 안 하는 이벤트를 받고 싶다면

컴포넌트가 노출하지 않은 DOM 표준 이벤트(예: mouseenter)를 받으려면:

this.getRef('button').element.addEventListener('mouseenter', (evt) => {
    // ...
});

.element를 거쳐야 표준 DOM 이벤트 시스템으로 접근할 수 있습니다.


innerComponents — 자식과는 다른 개념

자식 컴포넌트(tags:[...])와 헷갈리기 쉬운 개념입니다.

  • 자식 컴포넌트 — 사용자가 배치한 별개의 컴포넌트
  • 내부 컴포넌트(innerComponent) — 컴포넌트 자기 자신의 일부로 미리 만들어져 있는 하위 컴포넌트

예 — DatePicker

Va.DatePicker는 안에 캘린더 팝업을 여는 버튼을 갖고 있는데, 이 버튼은 이미 존재하는 Va.MenuButton을 재활용합니다. 매번 element로 다시 짤 필요 없이 컴포넌트를 조립하는 셈이죠.

Va.DatePicker = class extends Va.PureField {
    constructor(option) {
        super('input', option);
        this.innerComponents = [...this.innerComponents,
            'menuButton', 'buttonLeft', 'buttonRight', 'buttonToday'];
        // ...
        this.menuButtonComponent = new Va.MenuButton({ innerComponent: true, ... });
    }
};

접근 규칙

내부 컴포넌트에는 이름 + Component 형태로 접근합니다.

this.getRef('myDate').menuButtonComponent   // ← DatePicker 안 MenuButton 인스턴스

특징

  1. tags가 아니라 옵션 객체 형태로 재정의합니다.
  2. 자식 컴포넌트가 아니므로 부모-자식 관계에 잡히지 않습니다.
  3. 자신의 이벤트를 기본적으로는 View에 전달하지 않습니다. 필요하면 부모 컴포넌트가 재발화해 줍니다.
  4. 옵션으로 재정의해서 세부 스타일/동작을 커스터마이즈할 수 있습니다.
{
    tagName: 'datePicker',
    menuButton: {         // ← 내부 컴포넌트 옵션 재정의
        appearance: 'primary',
        popWidth: 320
    }
}

ref로 컴포넌트 참조하기

컴포넌트에 ref: '이름'을 붙이면 View 내부에서 getRef('이름')으로 그 컴포넌트 인스턴스를 가져올 수 있습니다.

config() {
    return {
        tagName: 'div',
        tags: [
            { tagName: 'input',  ref: 'name' },
            { tagName: 'button', text: '전송', onClick: 'onSubmit' }
        ]
    };
},
onSubmit(btn, el, evt) {
    const name = this.getRef('name').getValue();
    console.log(name);
}

ref는 컴포넌트에 이름표를 붙이는 것과 같습니다. View의 콜백들이 이 이름으로 컴포넌트를 찾아 조작합니다.


커스텀 컴포넌트 만들기

기본 제공 컴포넌트로 부족하면 직접 정의하면 됩니다.

Va.MyBadgeButton = class extends Va.Component {
    constructor(option) {
        super('button', option);
        this.tagName    = 'myBadgeButton';
        this.elements   = [...this.elements,   'element', 'label', 'badge'];
        this.properties = [...this.properties, 'label', 'count'];
        this.events     = [...this.events,     'click'];
        this.setOption();

        this.labelElement = document.createElement('span');
        this.labelElement.setAttribute('elname', 'label');
        this.badgeElement = document.createElement('span');
        this.badgeElement.setAttribute('elname', 'badge');
        this.element.append(this.labelElement, this.badgeElement);

        this._bindEvent(this.element, 'click', (evt) => {
            this.dispatchEvent('click', this, this.element, evt);
        });
    }
    update() {
        this.labelElement.textContent = this.label ?? '';
        this.badgeElement.textContent = this.count ?? '';
        super.update();
    }
};
Va.registerComponent('myBadgeButton', Va.MyBadgeButton);

이제 어디에서든:

{
    tagName: 'myBadgeButton',
    label: '알림',
    count: 5,
    onClick: 'onOpen'
}

등록 한 번이면 나머지 프로젝트 전체에서 표준 컴포넌트처럼 씁니다.


컴포넌트에 CSS 적용하기

컴포넌트는 각자 CSS가 이미 붙어 있어 대부분 추가 스타일링이 필요 없지만, 커스텀이 필요할 때는 두 가지 방법이 있습니다.

1) style 속성 — 인라인 스타일

{ tagName: 'div', style: 'color:red;padding:10px' }
// 또는
{ tagName: 'div', style: { color: 'red', padding: '10px' } }

작은 조정에 적합합니다.

2) class / addClass 속성

{ tagName: 'div', innerHTML: '내용', class: 'myclass', addClass: 'myaddclass' }
  • class — 기존 클래스를 대체
  • addClass  element.classList.add()와 동일하게 추가

3) 내부 element에 개별 클래스 부여

mounted() {
    this.getRef('button').textElement.classList.add('myclass');
    this.getRef('button').iconElement.classList.add('myclass2');
}

추천은 여전히 CSS 파일 직접 수정입니다. 퍼블리셔가 프로젝트 CSS를 관리하는 게 유지보수에 유리합니다.


요약 — 컴포넌트 4대 규칙

  1. 컴포넌트는 클래스로 정의된 객체다. 태그가 아니다.
  2. 화면에는 JSON 트리로 선언된다. { tagName, tags, on..., style, ref, ... }
  3. 이벤트는 on 접두어로 받는다. View의 메서드 이름을 문자열로 지정.
  4. ref로 어디서든 참조할 수 있다.

이 네 가지만 손에 익으면 VanillaFront로 화면 짜는 감이 잡힙니다.


다음 편 예고

다음 편은 뷰(View) — 컴포넌트가 벽돌이면 뷰는 방(room). 화면 단위인 Va.View가 어떻게 라이프사이클을 관리하고, 자식 뷰를 어떻게 연결하며, mounted가 뭐고 getRef가 언제 유효한지까지 다루겠습니다.


질문·수정 사항 있으면 댓글 남겨주세요. 다음 편에서 다시 봅니다.


이 정도 톤과 분량으로 이어갈까요? 다음 편(뷰) 준비하기 전에 이 편에 손볼 부분 있으면 알려주세요.