이번 글에서는 JavaScript의 Web Component 개념을 간단하게 살펴보고, moonbucks 애플리케이션을 통해 어떻게 프로젝트를 세팅했는지에 대해 말해보려고 합니다.
⚠️ 이 글은 컴포넌트에 대한 이해가 필요한 글 입니다!
그 기능을 나머지 코드로부터 캡슐화하여 재사용 가능한 커스텀 엘리먼트를 생성하고 웹 앱에서 활용할 수 있도록 해주는 다양한 기술들의 모음 - mdn -
web component 개념을 적용하면 마치 react에서 component 다루듯이 바닐라 JS에서도 사용할 수 있으며, 위와 같은 컴포넌트를 만들어놓으면 관심사를 분리할 수 있고 재 사용 가능하기 때문에 간편하게 유지 보수가 가능합니다.
이제 web component의 주요 기술에 대해 살펴보겠습니다.
mdn
에서 설명하고 있는 web component의 주요 기술
은 총 3가지 이며 하나씩 살펴보겠습니다.
사용자 인터페이스에서 원하는 대로 사용할 수 있는 사용자 정의 요소 및 해당 동작을 정의 할 수 있는 JavaScript API
class CustomInput extends HTMLInputElement {
constructor() {
super();
}
}
customElements.define('custom-input', CustomInput)
<custom-input></custom-input>
custom elements
는 class를 통해 생성하며, window.customElemetns.define
메서드를 통해 html에서 사용이 가능합니다.
define
의 첫 번째 인수는 html에 사용할 tagName
으로, 주로 cabab-case로 정의합니다.
두 번째 인수는 생성한 class
입니다.
customElements.define("word-count", WordCount, { extends: "p" });
세 번째 인수로는 optional로 객체를 추가할 수 있는데, 위 코드는 word-count라는 요소가 p태그를 확장한다는 것을 의미합니다.
<word-count></word-count>
<p is="word-count"></p>
세 번째 인수를 추가할 경우 정의했던 tagName을 사용할 수도 있지만 기존 p 태그의 is
attribute를 사용해서 정의할 수도 있습니다.
const $customInput = document.querySelector('custom-input');
$customInput.classList.add('hi');
이렇게 만든 custom elements
는 다른 DOM들과 동일하게 취득, 조작이 가능하며, 클래스 내 다양한 생명 주기 함수 들을 통해 요소의 생명 주기내 특정한 시점에서 실행할 수 있습니다.
생명 주기 함수의 종류는 아래와 같습니다.
사용자 정의 요소가 문서에 연결된 요소에 추가될 때마다 호출되는 메서드
connectedCallback
은 노드가 이동될 때마다 발생하며 요소의 내용이 완전히 해석되기 전에 발생할 수도 있기 때문에, 메서드 내부에 Node.isConnected
를 사용하여 확실하게 정의할 수도 있습니다.
<body>
<welcome-message></welcome-message>
<script>
class WelcomeMessage extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
if (this.isConnected) {
this.innerHTML = `<h1>Welcome, User!</h1>`;
}
}
}
customElements.define('welcome-message', WelcomeMessage);
</script>
</body>
위 예시로 살펴보면 connectedCallback
내 isConnected
를 통해 document에 해당 요소가 연결될 때 innerHTML로 h1 element를 할당함으로써 렌더링 되는 것을 알 수 있습니다.
사용자 정의 요소가 document의 DOM에서 연결 해제되었을 때마다 호출되는 메서드
disconnectedCallback
을 통해선 요소가 사라졌을 때 실행할 로직을 정의할 수 있습니다.
<body>
<welcome-message></welcome-message>
<script>
class WelcomeMessage extends HTMLElement {
// ...
disconnectedCallback() {
alert('welcome message 사라짐!');
}
}
// ...
setTimeout(() => {
const element = document.querySelector('welcome-message');
if (element) {
element.remove();
}
}, 1000);
</script>
</body>
예제 코드를 살펴보면 1초 뒤에 welcome message 컴포넌트가 사라지면서 alert 메시지가 나타나는 것을 확인할 수 있습니다.
이는 disconnectedCallback
이 요소가 사라졌을 때 실행되었다는 것을 확인할 수 있습니다.
사용자 정의 요소가 새로운 document로 이동되었을 때마다 호출되는 메서드
<body>
<div>
<welcome-message></welcome-message>
<iframe id="target-iframe" style="height: 200px; width: 100%"></iframe>
</div>
<script>
class WelcomeMessage extends HTMLElement {
// ...
adoptedCallback() {
alert('hi');
}
}
// ...
window.onload = () => {
const element = document.querySelector('welcome-message');
const iframe = document.getElementById('target-iframe');
if (element && iframe && iframe.contentDocument) {
iframe.contentDocument.body.appendChild(element);
}
};
</script>
</body>
위 예시 코드는 브라우저가 load 되면 welcome-message
태그가 iframe 내부로 추가되는 로직입니다.
위 로직은 iframe 내부로 추가 될 때 새로운 document로 이동하기 때문에 adoptedCallback의 alert가 호출되는 것을 확인할 수 있습니다.
사용자 정의 요소의 특성들 중 하나가 추가되거나, 제거되거나, 변경될 때마다 호출되는 메서드
어떤 특성이 변경에 대해 알릴지는 static get observedAttributes
메서드에서 명시할 수 있습니다.
<body>
<attribute-watcher data-message="나는 누구"></attribute-watcher>
<script>
class AttributeWatcherElement extends HTMLElement {
static get observedAttributes() {
return ['data-message'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'data-message') {
this.innerHTML = `<h1>${newValue}</h1>`;
if (oldValue !== null) {
alert(`${oldValue}에서 ${newValue}로 변경되었습니다.`);
}
}
}
}
customElements.define('attribute-watcher', AttributeWatcherElement);
</script>
</body>
위 예제 코드를 살펴보면, AttributeWatcherElement
내 observedAttributes
메서드를 통해 data-message
를 감지하도록 설정한 것을 알 수 있습니다.
그 후, attributeChangedCallback
을 통해 data-message
속성이 변경되면 if 문 내부의 로직을 실행하여 새로운 newValue를 렌더링 후 alert를 실행할 것을 예상할 수 있습니다.
setTimeout(() => {
const element = document.querySelector('attribute-watcher');
if (element) {
element.setAttribute('data-message', '나는 jiny');
}
}, 3000);
3초 후 setAttribute
를 통해 data-message
속성을 나는 jiny
로 바꾸게 되면 attributeChangedCallback
가 실행되어 나는 jiny
가 렌더링 된 후 alert가 호출된 것을 확인할 수 있습니다.
캡슐화된 Shadow DOM 트리를 엘리먼트를 추가하고 연관된 기능을 제어하기 위한 JavaScript API의 집합
Shadow DOM
은 DOM
과는 독립적인 요소로 엘리먼트의 기능을 프라이빗하게 유지할 수 있어, 문서의 다른 부분과의 충돌에 대한 걱정 없이 스크립트와 스타일을 작성할 수 있습니다.
또한, Shadow DOM
이 제공하는 API들은 캡슐화의 핵심 파트이며, 숨겨진 분리된 DOM을 요소에 부착하는 방법을 제공 합니다.
Shadow host
- shadow DOM이 부착되는 통상적인 DOM 노드.
Shadow tree
- shadow DOM 내부의 DOM 트리.
Shadow boundary
- shadow DOM이 끝나고, 통상적인 DOM이 시작되는 장소.
Shadow root
- shadow 트리의 root 노드.
구조를 살펴보면 DOM Tree
내부의 Shadow DOM
을 사용하는 HTML Element의 경우 shadow host
가 존재하는 것을 알 수 있습니다.
shadow host
는 shadow tree
와 연결되며 시작 노드는 shadow root
인 것을 알 수 있습니다.
즉, Shadow DOM 안에 있는 노드에도, non-shadow node와 같은 방식으로 영향을 줄 수 있으며 children을 붙이거나, 속성을 설정하거나, element.style로 스타일을 설정하는 방식을 쓸 수 있습니다.
이렇게 shadow host
내부엔 shadow tree
가 dom tree
와 별개로 존재하기 때문에, 스타일 및 요소 충돌을 방지할 수 있습니다.
class MyCustomElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
.text {
color: green;
font-size: 18px;
}
</style>
<div class="text">Shadow DOM 내부의 텍스트</div>
`;
}
}
attachShadow()
를 통해 shadow root
를 부착할 수 있으며, 객체 내 mode
프로퍼티를 통해 외부에서 shadow root
접근 여부를 설정할 수 있습니다.
open
- 외부에서 shadow root 접근 가능closed
- 외부에서 shadow root 접근 불가능
개발자 도구에서 확인해보면 #shadow-root
라는 별도의 공간 안에 style
과 div
태그가 추가되어있는 것을 확인할 수 있습니다.
<body>
<div class="text">외부 DOM의 텍스트</div>
<my-custom-element></my-custom-element>
<script>
class MyCustomElement extends HTMLElement {
constructor() {
super();
this.innerHTML = `
<style>
.text {
color: green;
font-size: 18px;
}
</style>
<div class="text">Shadow DOM 내부의 텍스트</div>
`;
}
}
customElements.define('my-custom-element', MyCustomElement);
</script>
<style>
.text {
color: red;
font-size: 24px;
}
</style>
</body>
예제 코드를 살펴보면 MyCustomElement
내 style
태그로 .text
에 대해 color를 green으로 설정한 것을 알 수 있습니다.
하지만, script
아래의 style
태그와 충돌하여 css 규칙(top-down 형태로 실행)에 의해 두 텍스트의 컬러 모두 red로 나오는 것을 알 수 있습니다.
하지만 MyCustomElement
내 shadow root
를 설정하여 해결할 수 있습니다.
class MyCustomElement extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
.text {
color: green;
font-size: 18px;
}
</style>
<div class="text">Shadow DOM 내부의 텍스트</div>
`;
}
}
shadow dom
을 사용하게 되면 MyCustomElement의 범위 내에서 스타일을 정할 수 있기 때문에 외부 스타일과 충돌이 발생하지 않는 것을 확인할 수 있습니다.
웹 컴포넌트의 Shadow DOM을 채우는 데 사용할 수 있는 유연한 템플릿을 만드는 태그들
HTML에서 클라이언트 측의 콘텐츠를 보관하기 위해 사용되는 메커니즘
이 태그 안에 있는 내용은 페이지가 로드될 때 렌더링되지 않으며, JavaScript를 사용하여 페이지의 다른 부분으로 복제하고 삽입할 수 있습니다.
이를 통해 동일한 구조를 반복적으로 사용할 때 유용하게 사용할 수 있습니다.
<template id="my-template">
<style>
.my-component {
border: 1px solid #ddd;
padding: 10px;
border-radius: 8px;
}
</style>
<div class="my-component">
<h2>My Web Component</h2>
<p>This is a paragraph inside the component.</p>
</div>
</template>
class MyComponent extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
const template = document.getElementById('my-template');
const content = template.content.cloneNode(true);
this.shadowRoot.appendChild(content);
}
}
customElements.define('my-component', MyComponent);
template
태그로 컴포넌트에 대한 style
태그와 element
를 정의한 후 custom element
를 정의하는 단계에서 DOM 취득 API
를 통해 template을 가져와 cloneNode
로 복사 후 shadowRoot
에 추가할 수 있습니다.
이 때, 주의해야 할 점은 template를 가져올 때 cloneNode(true)
로 깊은 복사의 형태로 가져와야 합니다.
웹 컴포넌트의 Shadow DOM 내에서 사용자가 정의한 콘텐츠를 배치할 수 있는 방법을 제공할 수 있는 tag
<script>
class MyComponentWithSlot extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `
<style>
.container {
border: 1px solid #333;
padding: 10px;
border-radius: 8px;
}
::slotted(span) {
color: blue;
}
</style>
<div class="container">
<slot name="my-slot"></slot>
</div>
`;
}
}
customElements.define('my-component-with-slot', MyComponentWithSlot);
</script>
<my-component-with-slot>
<span slot="my-slot">안녕 나는 슬롯 안에 있어</span>
</my-component-with-slot>
custom element
에는 slot
태그로 정의했지만, html 내 my-component-with-slot
내부에 span
태그를 살펴보면 slot attribute
로 my-slot
을 추가한 것을 확인할 수 있습니다.
이 경우 브라우저에서 렌더링 될 때 slot
태그가 span
태그로 변환되어 렌더링 되기 때문에 다음과 같이 화면이 보이는 것을 알 수 있습니다.
이 처럼 slot
은 동적으로 요소를 렌더링 할 때 유용하게 사용이 가능합니다.
이제 부턴 학습한 내용을 바탕으로 실제 moonbucks(todo list app)앱에 어떻게 적용했는가에 대한 이야기를 풀어내보려고 합니다.
컴포넌트를 분리할 때 가장 중요하게 생각했던 건 아래와 같습니다.
- 스스로 일을 처리할 수 있는가?
- 변경 사항이 생겼을 때 빠르게 확인 후 변경 할 수 있는가?
- 재 사용이 가능한가?
이러한 기준을 바탕으로 아래와 같이 컴포넌트를 구조화 하였습니다.
class BaseComponent extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
this.render();
this.setEvent();
}
disconnectedCallback() {}
render() {}
setEvent() {}
emit(eventType, detail) {
const customEvent = new CustomEvent(eventType, {
bubbles: true,
detail,
});
this.dispatchEvent(customEvent);
}
on(target, eventName, eventHandler) {
target.addEventListener(eventName, eventHandler);
}
off(target, eventName, eventHandler) {
target.removeEventListener(eventName, eventHandler);
}
}
export default BaseComponent;
custom element
를 만들며 중복되는 로직들을 중앙화 시키기 위해 BaseComponent
를 만든 후 extends
하는 형태로 사용하고자 하였습니다. (추가적으로 hide나 open을 만들어 컴포넌트를 조건부로 렌더링도 가능합니다!)
각 메서드들의 기능은 다음과 같습니다.
connectedCallback
- 컴포넌트가 렌더링 될 때 실행하는 메서드
disconnectedCallback
- 컴포넌트가 사라질 때 실행하는 메서드
render
- 컴포넌트 렌더링을 위한 메서드
setEvent
- 이벤트 핸들러를 정의 하기 위한 메서드
emit
- 커스텀 이벤트를 dispatch 하는 메서드
on
- addEventListener를 간략화 한 메서드
off
- removeEventListener를 간략화 한 메서드
잘 살펴보면, attributeChangedCallback
를 사용하지 않는 것을 알 수 있습니다.
해당 메서드의 경우 데이터 바인딩을 목적으로 사용해보려 했으나 데이터 바인딩 할 수 있는 방법은 아래와 같습니다.
- 커스텀 이벤트와 이벤트 위임 활용
data-*
attribute와 attributeChangedCallback, observedAttributes 활용- 전역 store(createStore) 활용
이렇게 3가지 방법이 있지만, 프로젝트 규모가 크지 않다는 점과 보안 상의 이유로 1번의 방법을 채택 했고, 다른 목적이 없었기 때문에 따로 정의하지 않았습니다.
<!DOCTYPE html>
<html lang="kr">
<head>
<meta charset="UTF-8" />
<title>문벅스 메뉴</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="shortcut icon" href="src/images/favicon.ico" />
<link rel="icon" href="src/images/favicon.png" />
<link rel="stylesheet" href="src/css/index.css" />
</head>
<body>
<moonbucks-app id="app" class="px-4"></moonbucks-app>
<script type="module" src="./components/moonbucksApp/component.js"></script>
<script type="module" src="./components/menuHeader/component.js"></script>
<!-- 수 많은 script 태그들... -->
</body>
</html>
web component
를 사용하면서 많은 고민을 했던 부분 중 하나가 컴포넌트를 나누게 되면 나누는 만큼 index.html에 script 태그로 추가해야 한다는 점 이었습니다.
그래서 index.js에 사용하는 컴포넌트를 한꺼번에 import 하는 방법을 택했습니다.
<!DOCTYPE html>
<html lang="kr">
<head>
<meta charset="UTF-8" />
<title>문벅스 메뉴</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="shortcut icon" href="src/images/favicon.ico" />
<link rel="icon" href="src/images/favicon.png" />
<link rel="stylesheet" href="src/css/index.css" />
</head>
<body>
<moonbucks-app id="app" class="px-4"></moonbucks-app>
<script type="module" src="./src/js/index.js"></script>
</body>
</html>
import './components/moonbucksApp/component.js';
import './components/menuHeader/component.js';
import './components/menuSection/component.js';
import './components/menuInputForm/component.js';
import './components/menuList/component.js';
import './components/menuListItem/component.js';
import './components/menuCount/component.js';
이렇게 하면 script
태그를 번거롭게 만들지 않아도 되며, 컴포넌트를 추가하면 index.js에 import만 추가하여 간편하게 사용이 가능했습니다.
web component로 실습한 moonbucks app 소스 코드
자세한 세부 로직은 위 링크를 통해 확인이 가능합니다!