🌌

[JS] 웹 컴포넌트


컴포넌트 아키텍처

웹 사이트의 사용자 인터페이스는 자연스럽게 컴포넌트들로 나눌 수 있다.

일반적으로 컴포넌트는 '기능'과 '페이지의 상호 작용 방식'으로 설명할 수 있는 독립적인 개체다. 각 컴포넌트는 다른 컴포넌트와는 별개로 페이지에서 고유한 위치를 차지하고 자세히 설명된 작업을 수행할 수 있다.

컴포넌트가 되기 위해서는 아래 사항들을 갖춰야 한다.

  • 고유한 자바스크립트 클래스
  • 외부코드가 접근할 수 없으며 해당 클래스에서만 관리되는 DOM 구조
  • 구성요소에 적용되는 CSS 스타일
  • 다른 구성요소와 상호작용하기 위한 이벤트, 클래스 메서드 등을 일컫는 API

위 기능들을 지원해 컴포넌트를 구축하기 위한 많은 프레임워크와 개발 방법론이 존재한다. 하지만, 프레임워크의 무거운 덩치는 앱을 무겁게 만들고 리소스를 사용자에게 전가시키며 프레임워크 종속적인 코드를 생산하게 한다.

위 문제를 해결하기 위해 프레임워크 대신 브라우저 내장 기능을 제공하는 '웹 컴포넌트'를 사용할 수 있다.

  • Custom elements: 사용자 정의 HTML 요소를 정의하는 데 사용된다.
  • Shadow DOM: 다른 컴포넌트에 대해 숨겨져 있는 내부 DOM을 생성하는 데 사용된다.
  • CSS Scoping: 컴포넌트의 Shadow DOM 내부에만 적용되는 스타일을 선언하는 데 사용된다.

물론 웹 컴포넌트가 프레임워크를 대체할 수 있다는 의미는 아니다. 대신 컴포넌트 계층을 하나로 통일된 네이티브 솔루션(웹 컴포넌트)으로 교체함으로써 기존 프레임워크를 보완하는 역할을 한다. 예를 들어, 프레임워크를 교체할 때 기존에 사용하던 웹 컴포넌트는 그대로 사용할 수 있다.

Custom Elements

우리가 정의한 클래스, 메서드, 프로퍼티와 이벤트 등으로 이루어진 커스텀 HTML 요소를 생성할 수 있다.

커스텀 요소는 두 종류가 있다.

  • Autonomous custom elements(자율형 커스텀 요소): HTMLElement 클래스를 상속받는 '완전히 새로운' 요소다.
  • Customized built-in elements(맞춤형 내장 요소): 내장 요소를 상속받아 원하는 대로 개조한 요소다.

자율형 커스텀 요소

커스텀 요소를 만들기 위해선 브라우저에게 몇가지 세부 정보를 알려줘야 한다. 세부 정보는 클래스에 특별한 메서드를 만들어 제공한다.

1class MyElement extends HTMLElement {
2 constructor() {
3 super();
4 // 요소가 성생됨
5 }
6
7 connectedCallback() {
8 // 요소가 document에 추가되면 브라우저가 이 메서드를 호출한다.
9 }
10
11 disconnectedCallback() {
12 // 요소가 document에서 제거되면 브라우저가 이 메서드를 호출한다.
13 }
14
15 static get observedAttributes() {
16 return [/* 변화를 모니터링할 속성 이름들의 배열이다 */];
17 }
18
19 attributeChangedCallback(name, oldValue, newValue) {
20 // 위의 속성들 중 하나가 수정되면 호출된다.
21 }
22
23 adoptedCallback() {
24 // 요소가 새 document로 이동되면 호출된다.
25 // (document.adoptNode에서 발생하는데, 거의 쓰이지 않는다)
26 }
27
28 // 다른 메서드나 프로퍼티를 추가할 수 있다.
29}

적절한 내용을 채워넣은 후 요소를 등록하면 된다.

1// 브라우저에게 <my-element> 가 우리의 새로운 클래스에 의해 제공된다는 것을 알린다.
2customElements.define("my-element", MyElement);

이제부터는 <my-element> 요소를 사용하면, MyElement의 인스턴스가 생성되고 전술한 메서드들이 호출된다.

커스텀 요소의 이름은 무조건 하이픈-을 가져야 한다. 내장 요소와 충돌을 방지하기 위함이다.

요소 안의 내용은 connectedCallback에서 해야한다. constructor에서 하면 안된다. 이유는 간단하다. constructor가 호출되는 시기는 너무 이르기 때문이다. 요소가 생성됐지만 DOM에 아직 추가되지 않았기 떄문에 브라우저는 아직 속성을 처리/할당하지 않았기 때문에 뭔가 조작을 할 수 없다.

커스텀 내장 요소

자울형 커스텀 요소는 아무 내장된 의미를 가지지 않기 때문에 검색 엔진이나 접근성 장치에서 처리할 수 없다.

따라서 외부 세상에 어느정도 정보를 제공할 필요가 있다면 기존의 내장요소를 상속받아 재사용하는 것이 좋다. 예를 들어, 어떤 특별한 기능을 하는 버튼을 만들 때는 HTMLButtonElement 요소를 상속받아 만들면 된다.

1<script>
2class HelloButton extends HTMLButtonElement {
3 constructor() {
4 super();
5 this.addEventListener('click', () => alert("Hello!"));
6 }
7}
8
9customElements.define('hello-button', HelloButton, {extends: 'button'});
10</script>
11<button is="hello-button">Click me</button>
12
13<button is="hello-button" disabled>Disabled</button>

새로운 버튼은 내장 객체를 상속받기 떄문에 disabled와 같은 표준 기능을 지원한다.

Shadow DOM

Shadow DOM은 캡슐화(encapsulation) 역할을 한다. Shadow DOM을 통해 컴포넌트는 자신만의 '쉐도우' DOM 트리를 가질 수 있다. 이 DOM 트리는 기본 도큐먼트에 의해 접근될 수 없고, 자신만의 지역적인 스타일 규칙 등을 가질 수 있다.

Shadow tree

DOM 요소는 두 가지 타입의 하위 트리를 가질 수 있다.

  • Light tree: 일반적인 DOM 하위 트리.
  • Shadow tree: 숨겨진 DOM 하위 트리. HTML에 반영되지 않는다.

요소가 둘 다 가진다면, 브라우저는 쉐도우 트리만 렌더한다.

쉐도우 트리를 사용해서 커스텀 요소는 자신의 내부를 숨길 수 있고 컴포넌트에 한정된 스타일을 적용할 수 있다.

1<script>
2customElements.define('show-hello', class extends HTMLElement {
3 connectedCallback() {
4 const shadow = this.attachShadow({mode: 'open'});
5 shadow.innerHTML = `<p>
6 Hello, ${this.getAttribute('name')}
7 </p>`;
8 }
9});
10</script>
11
12<show-hello name="Woong"></show-hello>

쉐도우 트리를 만들기 위해서는 elem.attachShadow({mode: ...})를 호출하면 된다. 그러면 요소 아래에 #shadow-root가 생기고 그 아래 모든 내용이 들어간다.

요소 하나당 하나의 쉐도우 루트를 가질 수 있다. 또한 모든 요소가 쉐도우 트리를 가질 수 있는 것은 아니다. 커스텀 요소이거나, 다음에 해댱하는 요소만 가질 수 있다: “article”, “aside”, “blockquote”, “body”, “div”, “footer”, “h1…h6”, “header”, “main” “nav”, “p”, “section”, “span”.

mode 옵션은 캡슐화 레벨을 세팅한다. "open"이면 어느 코드에서든 elem.shadowRoot로 쉐도우 루트를 접근할 수 있게 된다. "closed"elem.shadowRoot는 항상 null 값을 가진다. 이때 쉐도우 DOM은 attachShadow의 반환값으로 받은 참조로만 접근 할 수 있다.

캡슐화

쉐도우 DOM은 기본 문서와 구분된다.

쉐도우 DOM의 요소는 light DOM의 querySelector로 찾을 수 없다. 특히 쉐도우 DOM 요소와 충돌하는 light DOM 요소의 id가 있어도 된다. id는 각자의 트리 내에서만 고유하면 된다.

또한 쉐도우 DOM은 자신만의 스타일 시트를 갖는다. 외부 DOM의 스타일 규칙이 적용되지 않는다.

이벤트

쉐도우 DOM에서 발생한 이벤트는 컴포넌트 밖에서 잡혔을 때 호스트 요소를 타겟으로 갖는다. 즉, 이벤트가 요소 내부에 있을 때는 쉐도우 DOM 내부 요소를 target으로 가지다가 밖으로 나가는 순간 타겟은 호스트 요소가 되는 것이다.

쉐도우 DOM 경계를 지나는 이벤트는 composed 플래그가 true다. 대부분의 내장 이벤트는 true를 값으로 가진다.

slots

Slot(슬롯)을 사용하면 light DOM의 요소들을 쉐도우 DOM의 특정한 위치에 보여줄 수 있다.

1<script>
2customElements.define('user-card', class extends HTMLElement {
3 connectedCallback() {
4 this.attachShadow({mode: 'open'});
5 this.shadowRoot.innerHTML = `
6 <div>Name:
7 <slot name="username"></slot>
8 </div>
9 <div>Birthday:
10 <slot name="birthday"></slot>
11 </div>
12 `;
13 }
14});
15</script>
16
17<user-card>
18 <span slot="username">John Smith</span>
19 <span slot="birthday">01.01.2001</span>
20</user-card>

쉐도우 DOM에서 <slot name="X"> 태그는 '삽입 위치'를 나타낸다. ligth DOM에서 slot="X"을 속성으로 가지는 요소는 해당 위치에 렌더링된다. 브라우저는 'composition'을 수행해서 light DOM의 요소를 쉐도우 DOM의 해당하는 위치에 렌더링한다.

이런 식으로 슬롯에 이름을 주는 방식을 'named slots'라고 한다.

렌더링 결과를 'flattened' DOM이라고 한다. 아래와 같다.

1<user-card>
2 #shadow-root
3 <div>Name:
4 <slot name="username">
5 <span slot="username">John Smith</span>
6 </slot>
7 </div>
8 <div>Birthday:
9 <slot name="birthday">
10 <span slot="birthday">01.01.2001</span>
11 </slot>
12 </div>
13</user-card>

주의해야 할 것은 flattened DOM은 렌더링이나 이벤트 핸들링 목적으로만 '가상으로' 존재한다는 것이다. 보이는 것은 위처럼 보이지만, 실제로 도큐먼트의 노드들은 움직이지 않는다.

슬롯 안에 내용을 추가하면, 그 내용은 기본값이 된다. light DOM에 해당되는 내용이 없다면 브라우저는 기본값을 보여준다.

슬롯에 아예 이름을 주지 않은 첫 번째 슬롯은 'default slot'이 된다. light DOM의 슬롯되지 않은 모든 노드를 가져온다.

Template 요소

내장 <template> 요소는 HTML 마크업 템플릿의 저장소 역할을 한다. 브라우저는 템플릿 요소 내부의 내용을 무시한다. 하지만 자바스크립트로 접근 가능하기 때문에 이것을 이용해 새로운 요소를 만들 수 있다.

템플릿의 내용은 content 프로퍼티로 접근 가능하다. 사용 예시는 아래와 같다.

1<template id="tmpl">
2 <style> p { font-weight: bold; } </style>
3 <p id="message"></p>
4</template>
5
6<div id="elem">Click me</div>
7
8<script>
9elem.onclick = function() {
10 elem.attachShadow({mode: 'open'});
11
12 elem.shadowRoot.append(tmpl.content.cloneNode(true));
13
14 elem.shadowRoot.getElementById('message').innerHTML = "Hello from the shadows!";
15};
16</script>

참조:
ko.javascript.info
https://velog.io/@design0728/Web-Component-8njgyg44