Table of Contents
이 글은 MDN 웹 컴포넌트 튜토리얼을 많은 부분 참고해 작성한 글이다. 바로 실행할 수 있는 예제와 함께 조금 더 친절하고 이해하기 쉽도록 설명하고자 했다.
TL;DR
Permalink to “TL;DR”- 웹 컴포넌트는 재사용할 수 있는 사용자 인터페이스 요소를 만드는 기술이다.
- 웹 컴포넌트는 커스텀 엘리먼트와 Shadow DOM을 이용해 구현된다.
- 커스텀 엘리먼트는 HTML 태그처럼 사용할 수 있는 커스텀 DOM 엘리먼트이다.
- Shadow DOM은 DOM과 CSS를 캡슐화하는 기술이다.
커스텀 엘리먼트 만들어 보기
Permalink to “커스텀 엘리먼트 만들어 보기”웹 컴포넌트는 재사용할 수 있는 커스텀 엘리먼트를 만들 수 있게 해준다. 커스텀 엘리먼트는 일반적인 HTML 태그처럼, 또는 JavaScript을 이용해 사용할 수 있다. 이는 CustomElementRegistry.define
메서드를 이용하는데, window.customElements
객체를 통해 접근할 수 있다.
// customElements.define() 으로도 접근이 가능
window.customElements.define(/* ... */);
이 메서드는 인자 세 개를 전달받아 커스텀 엘리먼트를 등록(Registry)한다:
name
: 커스텀 엘리먼트 이름이며, 커스텀 엘리먼트 이름 규칙을 따라야 한다. 가령 반드시 하이픈(-
)을 포함해야 한다.constructor
: 커스텀 엘리먼트 행동을 정의하는 클래스. 이 클래스는HTMLElement
를 상속받아야 한다.options
: 커스텀 엘리먼트 기능을 확장하는 객체. 현재(2024.03)는extends
옵션만을 지원한다. 커스텀 엘리먼트가 상속받을 빌트인 HTML 엘리먼트를 확장할 수 있다.
가령 다음과 같이 WordCount
라는 커스텀 엘리먼트 정의가 가능하다.
class WordCount extends HTMLParagraphElement {
constructor() {
super(); // 반드시 호출
// 커스텀 엘리먼트 기능
}
}
customElements.define('word-count', WordCount, { extends: 'p' });
등록 이후에는 WordCount
커스텀 엘리먼트를 사용할 수 있다.
<word-count></word-count>
<!-- or -->
<p is="word-count"></p>
document.createElement('word-count');
// or
document.createElement('p', { is: 'word-count' });
이 외에도 커스텀 엘리먼트 생성, 제거, 애트리뷰트 변경 등 이벤트를 감지할 수 있는 커스텀 엘리먼트 라이프사이클 콜백이 있다. 자세한 내용은 아래에서 다루도록 한다.
예제를 통해 알아보기
Permalink to “예제를 통해 알아보기”여기서는 다음 기능을 수행하는 커스텀 엘리먼트를 만들어 보도록 한다. 이름은 popup-info
로 하자.
- 이미지 아이콘과 텍스트로 구성
- 텍스트는 기본적으로 숨겨져 있고, 아이콘은 항상 보임
- 아이콘에 마우스를 올리면 텍스트가 보임
어렵지 않다. HTMLElement
를 상속받는 PopupInfo
클래스를 만들고, constructor
에 필요한 기능을 구현하면 된다. 항상 super()
를 호출해야 한다는 점을 잊지 말자.
class PopupInfo extends HTMLElement {
constructor() {
super();
// Shadow DOM 생성
this.attachShadow({ mode: 'open' });
// 아이콘 및 텍스트 엘리먼트 생성
const wrapper = document.createElement('div');
wrapper.classList.add('wrapper');
const icon = wrapper.appendChild(document.createElement('span'));
icon.classList.add('icon');
icon.addEventListener('click', () => wrapper.classList.toggle('popup'));
const img = icon.appendChild(document.createElement('img'));
img.src = this.getAttribute('img') ?? 'https://placeholder.com/100x100';
const info = wrapper.appendChild(document.createElement('span'));
info.classList.add('info');
info.textContent = this.getAttribute('data-info') ?? '';
const style = document.createElement('style');
style.textContent = `
.wrapper.popup .info { display: block; }
.info { display: none; }
`;
// 엘리먼트를 Shadow DOM에 추가
this.shadowRoot.append(style, wrapper);
}
}
// 커스텀 엘리먼트 등록
customElements.define('popup-info', PopupInfo);
💡 Shadow DOM(Shadow Root)은 CSS와 DOM을 캡슐화할 수 있는 독립된 DOM 트리라고 생각하면 된다. 여기서는
attachShadow
메서드를 통해 Shadow DOM을 생성하고,shadowRoot
프로퍼티를 통해 Shadow DOM에 접근할 수 있다는 점만 알아두자. 자세한 내용은 아래에서 다루도록 한다.
constructor
내부에서 this.getAttribute
를 통해 애트리뷰트 값을 가져올 수 있다. this.hasAttribute
를 통해 애트리뷰트 존재 여부를 확인할 수도 있다. 이를 통해 img
애트리뷰트가 존재하지 않을 경우 기본 이미지를 사용하도록 했다.
스타일은 style
엘리먼트를 생성해 textContent
에 CSS 문자열을 넣어준 뒤 Shadow DOM에 추가하는 방식을 이용했다. 스타일은 Shadow DOM 내부에서만 적용되며, 외부에는 영향을 주지 않는다.
이제 popup-info
커스텀 엘리먼트를 사용할 수 있다.
<popup-info
img="https://placeholder.com/300x300"
data-info="Popup info 1"
></popup-info>
extends
옵션을 이용한 빌트인 엘리먼트 확장
Permalink to “extends 옵션을 이용한 빌트인 엘리먼트 확장” extends
옵션을 이용하면 빌트인 HTML 엘리먼트를 확장할 수 있다. 가령 extends: 'ul'
옵션을 사용해 만들어진 커스텀 엘리먼트는 <ul>
엘리먼트 기능을 상속받는다.
// <ul> 엘리먼트를 확장하는 클래스는 HTMLUListElement를 상속받아야 한다.
class ExpandingList extends HTMLUListElement {
constructor() {
super();
// nothing
}
}
customElements.define('expanding-list', ExpandingList, { extends: 'ul' });
ExpandingList
커스텀 엘리먼트에 대한 상세 기능을 작성하지 않았지만, <ul>
엘리먼트와 동일하게 동작한다.
<expanding-list>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</expanding-list>
라이프사이클 콜백
Permalink to “라이프사이클 콜백”커스텀 엘리먼트는 라이프사이클 콜백을 이용해 엘리먼트 생성, 제거, 애트리뷰트 변경 등에 대한 이벤트를 감지할 수 있다:
connectedCallback
: 커스텀 엘리먼트가 DOM에 추가(연결)될 때 호출disconnectedCallback
: 커스텀 엘리먼트가 DOM에서 제거(해제)될 때 호출adoptedCallback
: 커스텀 엘리먼트가 다른 DOM으로 이동될 때 호출attributeChangedCallback
: 커스텀 엘리먼트 애트리뷰트가 추가, 제거, 변경될 때 호출
connectedCallback
은 몇 가지 추가 사항이 존재한다:
- 모든 DOM이 파싱되기 전에 호출될 수 있음
- DOM 노드가 이동될 때도 호출됨
- DOM에서 제거될 때도 호출될 수 있으며, 이는
isConnected
프로퍼티를 이용해 판별이 가능
attributeChangedCallback
을 위해서는 문자열 배열을 반환하는 static get observedAttributes()
메서드를 통해 감지할 애트리뷰트를 지정해야 한다.
라이프사이클 콜백을 구현해 보면 다음과 같다.
class CustomSquare extends HTMLElement {
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
const div = document.createElement('div');
this.shadow.appendChild(div);
console.log('Create Custom square element');
}
// DOM에 추가
connectedCallback() {
console.log(
'Custom square element added to page',
this.parentElement.tagName,
);
}
// DOM에서 제거
disconnectedCallback() {
console.log('Custom square element removed from page', {
isConnected: this.isConnected,
});
}
// DOM 이동
adoptedCallback() {
console.log(
'Custom square element moved to new page',
this.parentElement.tagName,
);
}
// 애트리뷰트 변경
attributeChangedCallback(name, oldValue, newValue) {
console.log('Custom square element attributes changed', name);
}
static get observedAttributes() {
// 애트리뷰트 이름을 담은 배열을 반환
return ['c', 'l']; // 'c'와 'l' 애트리뷰트 변경을 감지
}
}
Shadow DOM
Permalink to “Shadow DOM”중요한 웹 컴포넌트 개념 중 하나는 캡슐화(Encapsulation)다. Shadow DOM API는 이에 초점을 맞추어 DOM과 CSS를 캡슐화하는 방법을 제공한다. 여기서는 Shadow DOM 기본 개념과 사용법을 알아보도록 한다.
Shadow DOM이란?
Permalink to “Shadow DOM이란?”<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Simple DOM</title>
</head>
<body>
<section>
<img src="dinosaur.png" alt="T-Rex" />
<p>
Here we will add a link to the
<a href="https://www.mozilla.org/">Mozilla</a>
</p>
</section>
</body>
</html>
위 HTML 코드는 다음과 같이 트리 형태로 나타낼 수 있다.
HTML DOM 트리 예시 (출처: MDN)
Shadow DOM 역시 다르지 않다. 그저 하위 DOM 트리에 불과하다. 다만 Shadow DOM은 외부에서 접근할 수 없는 독립된 트리라는 점이 다르다. 즉, DOM과 CSS가 캡슐화된다.
그림으로 표현한 Shadow DOM (출처: MDN)
- Shadow host: 일반적인 DOM 노드처럼 보이는, Shadow DOM 연결 지점
- Shadow tree: Shadow DOM 내부 DOM 트리
- Shadow root: Shadow DOM 루트 노드
- Shadow boundary: Shadow DOM 시작 노드부터 끝 노드까지 경계
Shadow DOM 사용해 보기
Permalink to “Shadow DOM 사용해 보기”앞서 attachShadow
메서드를 통해 Shadow DOM을 생성해 보았다. 이 메서드는 mode
옵션을 통해 Shadow DOM 접근 가능 여부를 제어할 수 있다:
open
: Shadow DOM 참조를 외부에서 접근 가능closed
: Shadow DOM 참조는 외부에서 접근 불가능
Shadow DOM 참조를 얻을 때는 shadowRoot
프로퍼티를 이용하는데, 모드에 따라 서로 다른 값을 갖는다.
elementOpen.attachShadow({ mode: 'open' });
console.log(elementOpen.shadowRoot); // #shadow-root (open)
elementClosed.attachShadow({ mode: 'closed' });
console.log(elementClosed.shadowRoot); // null
이러한 특성으로 인해 PopupInfo
커스텀 엘리먼트에서 attachShadow
메서드 호출 시 mode
옵션을 open
으로 설정했었다. 만약 closed
로 설정했다면, shadowRoot
프로퍼티를 통해 Shadow DOM에 접근할 수 없어 에러가 발생한다.
class PopupInfo extends HTMLElement {
constructor() {
super();
// closed 모드로 Shadow DOM 생성
this.attachShadow({ mode: 'closed' });
// ...
this.shadowRoot.append(style, wrapper);
}
}
customElements.define('popup-info', PopupInfo);
document.createElement('popup-info'); // TypeError: Cannot read properties of null (reading 'append')
closed
모드 동작에 유의하자. shadowRoot
프로퍼티를 이용해 Shadow DOM 접근이 불가능할 뿐이다. 자세한 내용은 이어지는 예제를 통해 확인해보자.
Shadow DOM을 이용해 캡슐화된 컴포넌트 만들어 보기
Permalink to “Shadow DOM을 이용해 캡슐화된 컴포넌트 만들어 보기”innerHTML
프로퍼티를 이용해 Shadow DOM에 HTML을 추가할 수 있다. 이를 이용해 PopupInfo
커스텀 엘리먼트를 다음과 같이 구현할 수 있다:
class PopupInfo extends HTMLElement {
#html = `
<div class="wrapper">
<div class="icon">
<img src="${this.img}" alt="info icon">
</div>
<span class="info">
${this.info}
</span>
</div>
<style>
.wrapper.popup .info { display: block; }
.info { display: none; }
</style>
`;
constructor() {
super();
const shadow = this.attachShadow({ mode: 'closed' });
shadow.innerHTML = this.#html;
const wrapper = shadow.querySelector('.wrapper');
wrapper.addEventListener('click', () => wrapper.classList.toggle('popup'));
}
get img() {
return this.getAttribute('img') ?? 'https://placeholder.com/100x100';
}
get info() {
return this.getAttribute('data-info') ?? '';
}
}
여기서는 closed
모드로 생성했으나 innerHTML
프로퍼티를 이용해 Shadow DOM에 HTML을 추가했다. 어떻게 가능했을까? 앞서 언급했듯이 closed
모드는 shadowRoot
프로퍼티를 이용해 Shadow DOM에 접근할 수 없을 뿐이지, Shadow DOM 접근 자체가 불가능하지는 않기 때문이다. 따라서 Shadow DOM 내부 HTML을 외부에서 수정할 수 있었다.
Template 그리고 Slot 태그
Permalink to “Template 그리고 Slot 태그”재사용할 수 있는 컴포넌트를 만드는 방법은 여러 가지가 있다. 여기서는 그들 중 <template>
과 <slot>
을 소개한다.
Template 태그
Permalink to “Template 태그”<template>
은 HTML 요소 일부를 정의해두고 재사용할 수 있게 해준다.
<template id="my-paragraph">
<p>My Paragraph</p>
<style>
p {
font-size: 30px;
}
</style>
</template>
💡 Template 내부에서 작성된 스타일은 전역 스타일로 적용됨을 유의하자.
템플릿은 content
프로퍼티를 통해 접근할 수 있다. 이 프로퍼티는 DocumentFragment
를 반환하는데, 이를 통해 템플릿 내부 DOM에 접근할 수 있다.
const template = document.querySelector('#my-paragraph');
const templateContent = template.content;
console.log(templateContent); // #document-fragment
document.body.appendChild(templateContent);
웹 컴포넌트와 함께 사용할 때는 content
프로퍼티를 이용해 템플릿 내부 DOM에 접근한 뒤, 이를 Shadow DOM에 추가하면 된다.
class MyParagraph extends HTMLElement {
constructor() {
super();
const template = document.getElementById('my-paragraph');
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(template.content.cloneNode(true));
}
}
customElements.define('my-paragraph', MyParagraph);
참고로 template
태그를 여러 곳에서 사용할 때는 cloneNode
메서드를 이용해야 하는데, 이는 appendChild
메서드가 DOM을 이동시키는 동작을 수행하기 때문이다. 자세한 내용은 MDN appendChild
문서를 참고.
Slot 태그
Permalink to “Slot 태그”<slot>
은 템플릿 내 특정 위치에 콘텐츠를 삽입할 수 있게 해준다.
<template id="my-paragraph">
<p><slot></slot></p>
<style>
p {
font-size: 30px;
}
</style>
</template>
my-paragraph
템플릿은 다음과 같이 사용할 수 있다.
<my-paragraph>Hello from slots!</my-paragraph>
슬롯 여럿 필요하다면 name
애트리뷰트를 이용하자.
<template id="my-paragraph">
<p class="title"><slot name="title"></slot></p>
<p><slot></slot></p>
<style>
p {
font-size: 30px;
}
p.title {
font-size: 50px;
}
</style>
</template>
<my-paragraph>
<span slot="title">Title</span>
Hello from slots!
</my-paragraph>
슬롯에는 기본값을 지정할 수도 있다. <slot>
태그 내부에 기본값을 작성하면 된다.
<template>
<p><slot>default slot contents</slot></p>
</template>
이 글에서는 웹 컴포넌트 기본 개념과 사용법을 알아보았다. 이를 통해 재사용할 수 있는 컴포넌트를 만들 수 있게 되었고, 코드 재사용성과 유지보수성을 높일 수 있었다. 또한 Shadow DOM은 DOM과 CSS를 캡슐화를 통해 컴포넌트 독립성을 보장해주는데, 이를 통해 컴포넌트 간 충돌을 방지할 수 있다.
개인적으로는 Vue나 React를 통해 무의식적으로 사용하던 컴포넌트를 외부 라이브러리 없이 JavaScript 만으로도 구현이 가능하다는 점이 놀라웠다. Shadow DOM 특성 또한 흥미로웠다. Lit이나 Stencil 같은 라이브러리를 이용하면 더욱 쉽게 웹 컴포넌트를 구현할 수 있다고 한다. 기회가 된다면 한 번 사용해보고 싶다.