javascript.info의 web component 파트를 학습 및 정리한 내용이다.
https://javascript.info/web-components
웹 컴포넌트란?
웹 컴포넌트는 완결성있는 기능을 포함한 컴포넌트를 만들기 위한 표준 기술 집합입니다.
자체 애트리뷰트 및 메서드, 캡슐화된 DOM 및 스타일을 포함한 사용자 정의 HTML 요소입니다.
컴포넌트란 재사용 가능한 UI의 단위다.
UI는 마크업 뿐만 아니라, 스타일, 렌더링 로직, 비즈니스 로직 등을 전부 포함한다.
즉, 컴포넌트는 시각적 의미와 구조(마크업), 디자인 요소(스타일), 기능(함수) 세가지의 적절한 조합이다.
웹 컴포넌트는 UI 컴포넌트를 개발하기 위한 웹 표준 기술이다.
웹 컴포넌트는 다음과 같은 요소로 구성된다.
- JS 클래스
- DOM 구조(클래스에 의해 관리되며 외부에서 접근할 수 없다.)
- 해당 컴포넌트 스코프에만 적용되는 CSS
- API : 이벤트, prop, 클래스 메서드 등
웹 컴포넌트는 자체적으로 완결성을 가지며, 자신만의 스코프를 갖는다.
즉, DOM, CSS를 캡슐화 해야 한다.
또한 서브 컴포넌트를 포함할 수 있다. (메세지 리스트와 메세지 컴포넌트)
웹 컴포넌트와 인터랙션은 public api interface(이벤트, 클래스 메서드)를 사용한다.
컴포넌트의 단위는 아래와 같이 시각적으로 도출하는 것이 좋다.
(물론 컴포넌트 라는 것이 꼭 시각적인 요소를 포함할 필요는 없다.(렌더리스 컴포넌트))
시각적 요소들은 좀 더 세부적으로 조합 가능하다.
컴포넌트의 마크업 구조는 기존 html 요소처럼 합성 가능하도록 만드는 것이 좋다.
예로 아토믹 디자인 패턴에는 탬플릿이라는 요소를 두는데,
이를 이용해 컴포넌트를 합성하는 책임을 해당 계층으로 분리한다.
웹 컴포넌트를 구축하기 위해선 다음과 같은 기술들이 사용된다.
세부적으로 아는 것보단 왜 이런 기술들이 필요한지 큰 그림을 이해하는 것이 좋아보인다.
Custom elements
자체 메소드와 애트리뷰트, 이벤트 등을 사용하여 클래스에서 설명하는 사용자 정의 HTML 요소를 만들 수 있다.
해당 요소는 내장 HTML 요소와 동일하게 사용할 수 있다.
사용자 정의 HTML 요소에는 두 가지 타입이 있다.
- Autonomous custom elements – HTMLElement 클래스를 확장한다, 완전히 새로운 엘리먼트다. 새로운 태그라 할 수 있다.
- Customized built-in elements – 버튼, 인풋과 같이 기존 엘리먼트를 확장하는 엘리먼트다. 기존 태그를 재활용하낟.
먼저 1번 자율 사용자 정의 컴포넌트를 다루어보자.
사용자 정의 요소를 생성하려면 브라우저에 표시 방법, 요소가 페이지에 추가되거나 제거될 때 수행할 작업 등
몇 가지 세부 정보를 브라우저에 알려야 한다.
해당 동작은 전부 메서드를 통해 이루어지는데, 몇개 없고 전부 옵셔널하다.
아래는 해당 메서드의 전체 목록이다.
class MyElement extends HTMLElement {
constructor() {
super();
// element created
}
connectedCallback() {
// browser calls this method when the element is added to the document
// (can be called many times if an element is repeatedly added/removed)
}
disconnectedCallback() {
// browser calls this method when the element is removed from the document
// (can be called many times if an element is repeatedly added/removed)
}
static get observedAttributes() {
return [/* array of attribute names to monitor for changes */];
}
attributeChangedCallback(name, oldValue, newValue) {
// called when one of attributes listed above is modified
}
adoptedCallback() {
// called when the element is moved to a new document
// (happens in document.adoptNode, very rarely used)
}
// there can be other element methods and properties
}
- connectedCallback()
- 해당 요소가 문서에 추가될 때 브라우저에 의해 호출됩니다.
- 요소가 반복해서 추가/제거 될 경우 여러 번 호출될 수 있습니다.
- disconnectedCallback()
- 해당 요소가 문서에서 제거될 때 브라우저에 의해 호출됩니다.
- 요소가 반복해서 추가/제거 될 경우 여러 번 호출될 수 있습니다.
- observedAttributes
- 변화를 모니터링할 애트리뷰트 이름의 배열을 반환합니다.
- attributeChangedCallback(name, oldValue, newValue)
- observedAttributes에서 명시한 애트리뷰트 중 하나가 변경될 때 호출됩니다.
- adoptedCallback()은 요소가 새 문서로 이동될 때 호출됩니다.
- document.adoptNode에서 발생하며, 거의 사용되지 않습니다.
// <my-element>가 새 클래스에서 제공됨을 브라우저에 알립니다.
customElements.define("my-element", MyElement);
이제 <my-element> 태그가 있는 모든 HTML 요소에 대해 MyElement의 인스턴스가 생성되고 앞서 언급한 메서드가 호출된다.
또한 JavaScript에서 document.createElement('my-element')를 사용할 수 있다.
사용자 정의 요소 명엔 -(hyphen)이 있어야 한다.
my-element 및 super-button은 유효한 이름이지만 myelement는 유효하지 않다.
이는 내장 HTML 요소와 사용자 정의 HTML 요소 간에 이름 충돌이 없도록 하기 위한 것이다.
예제 : 타임 포매터
예를 들어 HTML에는 날짜/시간에 대한 <time> 요소가 이미 존재한다.
그러나 자체적으로 서식을 지정하지는 않는다.
즉, 의미론적인 구문에 불과하다.
time 요소에 format기능을 포함하여 컴포넌트를 만들어보자.
<script>
class TimeFormatted extends HTMLElement { // (1)
connectedCallback() {
let date = new Date(this.getAttribute('datetime') || Date.now());
this.innerHTML = new Intl.DateTimeFormat("default", {
year: this.getAttribute('year') || undefined,
month: this.getAttribute('month') || undefined,
day: this.getAttribute('day') || undefined,
hour: this.getAttribute('hour') || undefined,
minute: this.getAttribute('minute') || undefined,
second: this.getAttribute('second') || undefined,
timeZoneName: this.getAttribute('time-zone-name') || undefined,
}).format(date);
}
}
customElements.define("time-formatted", TimeFormatted); // (2)
</script>
<!-- (3) -->
<time-formatted datetime="2023-04-08"
year="numeric" month="long" day="numeric"
hour="numeric" minute="numeric" second="numeric"
time-zone-name="short"
></time-formatted>
- 이 클래스에는 하나의 메서드 connectedCallback()만 있다.
- 브라우저는 <time-formatted> 요소가 페이지에 추가될 때(또는 HTML 파서가 이를 감지할 때) 이 메서드를 호출한다.
- 빌트인 Intl.DateTimeFormat 데이터 포맷터를 사용한다.
- 브라우저는 <time-formatted> 요소가 페이지에 추가될 때(또는 HTML 파서가 이를 감지할 때) 이 메서드를 호출한다.
- customElements.define(tag, class)으로 요소를 등록한다.
- 해당 컴포넌트를 전역적으로 사용할 수 있다.
사용자 정의 요소의 승격(upgrade)
customElements.define 이전에 <time-formatted> 요소를 만나는 것은 오류가 아니다.
해당 요소는 비표준 태그와 마찬가지로 unknown이다.
해당 비표준 태그들은 :not(:defined)로 스타일을 정의할 수 있다.
이 태그들은 customElement.define이 호출되면 "승격"된다.
이제서야 각각에 대해 TimeFormatted의 새 인스턴스가 생성되고 connectedCallback이 호출된다.
또한 :defined 셀렉터 대상이 된다.
사용자 정의 요소에 대한 정보를 얻으려면 다음 메서드를 사용한다.
- customElements.get(name) – 주어진 이름을 가진 사용자 정의 요소에 대한 클래스를 반환한다.
- customElements.whenDefined(name) – 주어진 이름을 가진 사용자 정의 요소가 정의될 때 값 없이 resolved되는 promise를 반환한다.
렌더링은 생성자가 아닌 connectedCallback에서 발생한다.
요소 컨텐츠는 connectedCallback에서 렌더링(html 생성)된다.
생성자가 호출되는 시점은 너무 이르다.
우리는 해당 요소가 문서의 일부가 될 필요가 있을 때만 렌더링 하면 된다.
문서가 요소에 추가될 때 해당 함수가 호출되고 자식으로 다른 요소에 추가되어 페이지의 일부가 된다.
즉 지연 평가의 일종이다.
attribute 관찰
현재 구조에서는 처음 렌더링 된 후 attribute가 변경된다 하더라도 리렌더링 되지 않는다.
일반적인 html 요소의 경우 href와 같은 attribute을 변경하면 변경사항이 즉시 화면에 표시된다.
web component의 경우 observeAttributes getter를 활용하여 이를 수행한다.
<script>
class TimeFormatted extends HTMLElement {
render() { // (1)
let date = new Date(this.getAttribute('datetime') || Date.now());
this.innerHTML = new Intl.DateTimeFormat("default", {
year: this.getAttribute('year') || undefined,
month: this.getAttribute('month') || undefined,
day: this.getAttribute('day') || undefined,
hour: this.getAttribute('hour') || undefined,
minute: this.getAttribute('minute') || undefined,
second: this.getAttribute('second') || undefined,
timeZoneName: this.getAttribute('time-zone-name') || undefined,
}).format(date);
}
connectedCallback() { // (2)
if (!this.rendered) {
this.render();
this.rendered = true;
}
}
static get observedAttributes() { // (3)
return ['datetime', 'year', 'month', 'day', 'hour', 'minute', 'second', 'time-zone-name'];
}
attributeChangedCallback(name, oldValue, newValue) { // (4)
this.render();
}
}
customElements.define("time-formatted", TimeFormatted);
</script>
<time-formatted id="elem" hour="numeric" minute="numeric" second="numeric"></time-formatted>
<script>
setInterval(() => elem.setAttribute('datetime', new Date()), 1000); // (5)
</script>
- 렌더링 로직이 render() 헬퍼 메서드로 이동하였다.
- 요소가 페이지에 삽입될 때 render 메서드를 호출한다.
- observeAttributes()에 나열된 애트리뷰트 변경의 경우 attributeChangedCallback을 트리거한다.
- 리렌더링된다.
렌더링 순서
HTML파서가 DOM을 빌드할 때, 요소는 순서대로 처리되며, 부모는 자식보다 먼저 처리된다.
예를 들어 <outer><inner></inner></outer>가 있으면 <outer> 요소가 먼저 생성되고 DOM에 연결된 다음 <inner>가 처리된다.
이는 custom emelent에 중대한 결과를 초래한다.
사용자 정의 요소가 connectedCallback에서 innerHTML에 액세스할 수 없다.
<script>
customElements.define('user-info', class extends HTMLElement {
connectedCallback() {
alert(this.innerHTML); // empty (*)
}
});
</script>
<user-info>John</user-info>
아직 자식 처리가 완료되지 않았기 때문이다.
HTML 구문 분석기는 사용자 정의 요소 <user-info>를 연결하고 자식 처리를 진행할 예정이지만 아직 해당 단계를 진행하지 않았다.
사용자 정의 요소에 정보를 전달하려면 attribute를 사용한다.
혹은 setTimeout을 사용하는 방법도 았다.
하지만 이 방법도 완벽한 것은 아니다.
파싱 순서를 따라 외부 setTimeout이 트리거 된 후 내부 setTimeout이 트리거된다.
즉 외부 요소가 먼저 초기화 된 후 내부 요소가 초기화된다.
<script>
customElements.define('user-info', class extends HTMLElement {
connectedCallback() {
setTimeout(() => alert(this.innerHTML)); // John (*)
}
});
</script>
<user-info>John</user-info>
아래 코드는 다음 순서로 alert을 출력한다.
문서 연결 및 초기화가 outer > inner 순서로 발생함을 확인할 수 있다.
- outer connected.
- inner connected.
- outer initialized.
- inner initialized.
<script>
customElements.define('user-info', class extends HTMLElement {
connectedCallback() {
alert(`${this.id} connected.`);
setTimeout(() => alert(`${this.id} initialized.`));
}
});
</script>
<user-info id="outer">
<user-info id="inner"></user-info>
</user-info>
사용자 정의 빌트인 요소
<time-formatted>와 같이 우리가 만드는 새로운 요소에는 관련 의미 체계가 없다.
검색 엔진과 접근성 장치가 처리할 수 없다.
기존 HTML 요소를 확장하여 이러한 의미 체계를 전달할 수 있다.
1. 사용자 정의 클래스에서 내장 버튼 클래스를 확장한다.
class HelloButton extends HTMLButtonElement { /* custom element methods */ }
2. customElements.define 메서드의 두번째 요소에 파라미터를 전달한다.
해당 클래스를 button이 아닌 다른 태그로 확장할 수도 있음을 보여준다.
customElements.define('hello-button', HelloButton, {extends: 'button'});
3. 사용자 정의 요소를를 사용하려면 일반 <button> 태그를 삽입하되 is="hello-button" 애트리뷰트를 추가한다.
<button is="hello-button">click me</button>
<button is="hello-button" disabled>Disabled</button>
이제 해당 버튼은 빌트인 버튼을 확장하므로 버튼과 동일한 스타일과 표준 기능을 사용할 수 있다.
사용자 정의 웹 컴포넌트 요약
사용자 정의 컴포넌트는 두 가지 타입이 있음
1. "Autonomous" - HTMLElement를 확장하는 새로운 태그.
스키마 - 완전히 세로은 엘리먼트를 만들기에 여러 메서드를 정의해주어야 함.
class MyElement extends HTMLElement {
constructor() { super(); /* ... */ }
connectedCallback() { /* ... */ }
disconnectedCallback() { /* ... */ }
static get observedAttributes() { return [/* ... */]; }
attributeChangedCallback(name, oldValue, newValue) { /* ... */ }
adoptedCallback() { /* ... */ }
}
customElements.define('my-element', MyElement);
/* <my-element> */
2. "Customized built-in elements" - 기존 Element의 확장
.define 메서드의 두번째 파라미터, is attribute가 필요함.
class MyButton extends HTMLButtonElement { /*...*/ }
customElements.define('my-button', MyElement, {extends: 'button'});
/* <button is="my-button"> */
https://plnkr.co/edit/5A1yvhPjyH9RbdoX?p=preview&preview
Shadow DOM
Shadow DOM은 웹 컴포넌트의 캡슐화를 위해 사용된다.
이를 통해 컴포넌트는 고유한 "섀도우" DOM 트리를 가질 수 있다.
- 외부 문서에서 엑세스 할수 없도록 할 수 있고, 외부 스타일의 영향을 받지 않도록 할 수 있다.
- 고유한 로컬 스타일 규칙을 가질 수 있다.
브라우저는 어떻게 컨트롤에 스타일을 적용할까?
브라우저는 그것들을 그리기 위해 내부적인 DOM/CSS를 사용한다.
그 DOM 구조는 일반적으로 우리에게 숨겨져 있지만 개발자 도구를 통해 볼 수 있다.
Chrome에서는 Dev Tools에서 "Show user agent shadow DOM" 옵션을 활성화해야 한다.
<input type="range">는 사실 다음과 같은 구조를 갖고 있다.
일반적인 JavaScript나 셀렉터로 내장된 Shadow DOM 요소에 접근할 수는 없다.
이들은 일반적인 자식 요소가 아니다. 강력한 캡슐화 기술을 포함한다.
또한 pseudo와 같은 비표준 속성을 사용하고 있는 것을 볼 수 있다.
이러한 요소들에 스타일을 적용하기 위해 벤더 프리픽스를 사용하는 경우를 종종 볼 수 있다.
<style>
/* make the slider track red */
input::-webkit-slider-runnable-track {
background: red;
}
</style>
<input type="range">
이와 같은 캡슐화를 적용하기 위해 shadow dom을 사용할 수 있다.
Shadow tree
- Light Tree – HTML 자식으로 구성된 일반적인 DOM 서브트리다.
- Shadow Tree – HTML 구조에 반영되지 않은 숨겨진 DOM 하위 트리다.
요소에 둘 다 있는 경우 브라우저는 Shadow 트리만 렌더링한다.
하지만 Light Tree와 Shadow Tree를 합성할 수 있다.
컴포넌트 내부를 숨기고 로컬 스타일을 적용하기 위해 사용자 정의 요소에서 Shadow tree를 사용할 수 있다.
예를 들어 이 <show-hello> 요소는 섀도우 트리에서 내부 DOM을 숨긴다.
<script>
customElements.define('show-hello', class extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({mode: 'open'});
shadow.innerHTML = `<p>
Hello, ${this.getAttribute('name')}
</p>`;
}
});
</script>
<show-hello name="John"></show-hello>
elem.attachShadow({mode: …})를 호출하면 섀도우 트리가 생성된다.
두 가지 한계가 있다.
- 요소당 하나의 섀도우 루트만 만들 수 있다.
- 요소는 사용자 정의 요소이거나 다음 중 하나여야 한다.
- “article”, “aside”, “blockquote”, “body”, “div”, “footer”, “h1…h6”, “header”, “main” “nav”, “p”, “section”, or “span”
- "img"와 같은 요소는 섀도우 트리를 만들 수 없다.
mode 옵션은 캡슐화 수준을 설정한다.
다음 두 값 중 하나를 포함해야 한다.
- "open" – 섀도우 루트를 elem.shadowRoot로 접근할 수 있다.
- 모든 코드는 요소의 섀도우 트리에 액세스할 수 있다.
- "closed" – elem.shadowRoot는 항상 null이다.
- attachShadow에 의해 반환된 참조에 의해서만 Shadow DOM에 액세스할 수 있다
- (아마도 클래스 내부에 숨겨져 있을 것이다.
- <input type="range">와 같은 브라우저 기본 그림자 트리는 closed이다. 엑세스 할 수 있는 방법은 없다.
- attachShadow에 의해 반환된 참조에 의해서만 Shadow DOM에 액세스할 수 있다
attachShadow에 의해 반환되는 섀도우 루트는 요소와 동일하다.
innerHTML 또는 DOM 메서드(예: append)를 사용할 수 있다.
섀도우 루트가 있는 요소를 "섀도우 트리 호스트"라고 host property로 접근 가능하다.
// assuming {mode: "open"}, otherwise elem.shadowRoot is null
alert(elem.shadowRoot.host === elem); // true
캡슐화
- Shadow DOM 요소는 light DOM의 querySelector에서 접근할 수 없다.
- Shadow DOM 요소에는 light DOM의 ID와 충돌하는 ID를 사용해도 된다.
- 이들은 섀도우 트리 내에서는 고유해야 한다.
- Shadow DOM 요소에는 light DOM의 ID와 충돌하는 ID를 사용해도 된다.
- Shadow DOM에는 자신만의 스타일시트가 있다.
- 외부 DOM의 스타일 규칙은 적용되지 않는다.
<style>
/* document style won't apply to the shadow tree inside #elem (1) */
p { color: red; }
</style>
<div id="elem"></div>
<script>
elem.attachShadow({mode: 'open'});
// shadow tree has its own style (2)
elem.shadowRoot.innerHTML = `
<style> p { font-weight: bold; } </style>
<p>Hello, John!</p>
`;
// <p> is only visible from queries inside the shadow tree (3)
alert(document.querySelectorAll('p').length); // 0
alert(elem.shadowRoot.querySelectorAll('p').length); // 1
</script>
- 문서의 스타일은 Shadow 트리에 영향을 주지 않는다.
- Shadow Tree 내부 스타일은 정의된다.
- Shadow 트리의 요소는 Shadow Tree 내부에서 쿼리한다.
Shadow DOM 요약
- shadowRoot = elem.attachShadow({mode: open|closed}) – elem에 대한 Shadow DOM을 생성한다.
- mode="open"이면 elem.shadowRoot 속성으로 액세스할 수 있다.
- mode="closed"이면 elem.shadowRoot는 null이다.
- attachShadow의 레퍼런스를 클래스 안에서 사용한다.
- innerHTML 또는 다른 DOM 메서드를 사용하여 shadowRoot를 조작할 수 있다.
Shadow DOM 요소는
- 자신의 ID 공간을 포함한다.
- querySelector와 같은 기본 문서의 JavaScript 셀렉터로 접근할 수 없다.
- 기본 문서가 아닌 오직 Shadow Tree의 스타일만 사용한다.
- 요소의 자식으로 Shadow DOM Tree가 존재하는 경우 소위 "light DOM"(일반 자식) 대신 Shadow DOM만 렌더링 된다.
템플릿 요소
내장 <template> 요소는 HTML 마크업 템플릿의 저장소 역할을 한다.
브라우저는 내용을 무시하고 구문 유효성만 확인한다
JavaScript로 접근 및 사용하여 다른 요소를 만들 수 있다.
이론적으로 HTML 마크업 저장 목적으로 HTML 어딘가에 보이지 않는 요소를 만들 수 있다.
그렇다면 <template>의 특별한 점은 무엇인가?
첫째, 콘텐츠는 일반적으로 적절한 둘러싸는 태그가 필요한 경우에도 유효한 HTML이 될 수 있다.
예를 들어 테이블 행 <tr>을 배치할 수 있습니다.
<template>
<tr>
<td>Contents</td>
</tr>
</template>
일반적으로 <tr>을 <div> 안에 넣으려고 하면 브라우저는 잘못된 DOM 구조를 감지 및 수정한다.
즉, div를 table로 변경한다.
이것은 우리가 원하는 동작이 아니다.
반면에 <template>은 우리가 배치한 것을 그대로 유지한다.
즉, <template>에 스타일과 스크립트를 넣을 수 있다.<template>
<style>
p { font-weight: bold; }
</style>
<script>
alert("Hello");
</script>
</template>
브라우저는 <템플릿> 콘텐츠를 "문서 외부(out of the document)"로 간주한다.
스타일이 적용되지 않고, 스크립트가 실행되지 않고, <비디오 자동 재생>이 실행되지 않는 등의 효과가 있다.
문서에 삽입할 때 콘텐츠가 활성화된다(스타일 적용, 스크립트 실행 등).
템플릿 삽입하기
템플릿 콘텐츠는 콘텐츠 속성에서 특수한 유형의 DOM 노드인 DocumentFragment로 사용할 수 있다.
어딘가에 삽입할 때 자식이 대신 삽입되는 하나의 특별한 속성을 제외하고 다른 DOM 노드처럼 취급할 수 있다.
<template id="tmpl">
<script>
alert("Hello");
</script>
<div class="message">Hello, world!</div>
</template>
<script>
let elem = document.createElement('div');
// Clone the template content to reuse it multiple times
elem.append(tmpl.content.cloneNode(true));
document.body.append(elem);
// Now the script from <template> runs
</script>
<template id="tmpl">
<style> p { font-weight: bold; } </style>
<p id="message"></p>
</template>
<div id="elem">Click me</div>
<script>
elem.onclick = function() {
elem.attachShadow({mode: 'open'});
elem.shadowRoot.append(tmpl.content.cloneNode(true)); // (*)
elem.shadowRoot.getElementById('message').innerHTML = "Hello from the shadows!";
};
</script>
*의 tmpl.content는 DocumentFragment다.
즉 template의 자식 노드들이 html에 삽입되어 아래와 같은 shadow DOM을 만든다.
<div id="elem">
#shadow-root
<style> p { font-weight: bold; } </style>
<p id="message"></p>
</div>
템플릿 요약
- 템플릿 콘텐츠는 문법적으로 올바른 HTML이다.
- 템플릿 콘텐츠는 "문서 외부"로 간주되므로, 문서 삽입 이전엔 아무 영향도 미치지 않는다.
- JavaScript에서 template.content에 액세스하고 복제하여 새 컴포넌트에서 재사용할 수 있다.
<template> 태그는 다음과 같은 이유로 독특하다.
- 브라우저는 태그 내부의 HTML 구문을 확인한다.(스크립트 내에서 템플릿 문자열을 사용하는 것과 반대).
- 적절한 래퍼 없이는 의미가 없는 태그(예: <tr>)를 포함하여 모든 HTML 태그를 최상위 요소로 사용할 수 있다.
- 문서에 삽입되면 콘텐츠가 대회형이 된다: 스크립트가 실행되고 비디오가 자동 재생 된다.
<template> 요소 자체적으로 반복 메커니즘, 데이터 바인딩 또는 변수 대체 기능을 제공하지는 않는다. js를 이용해 구현해야 한다.
Shadow DOM 슬롯과 컴포지션
탭, 메뉴, 이미지 갤러리 등과 같은 많은 유형의 컴포넌트에는 렌더링할 콘텐츠가 필요하다.
빌트인 요소 <select>가 <option> 항목을 예상하는 것처럼 <custom-tabs>는 실제 탭 콘텐츠가 전달될 것을 예상할 수 있다.
<custom-menu>는 메뉴 항목을 예상할 수 있다.
<custom-menu>
<title>Candy menu</title>
<item>Lollipop</item>
<item>Fruit Toast</item>
<item>Cup Cake</item>
</custom-menu>
이를 어떻게 구현할까?
Shadow DOM은 light DOM의 콘텐츠로 자동으로 채워지는 <slot> 요소를 지원한다.
Named Slots
<script>
customElements.define('user-card', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `
<div>Name:
<slot name="username"></slot>
</div>
<div>Birthday:
<slot name="birthday"></slot>
</div>
`;
}
});
</script>
<user-card>
<span slot="username">John Smith</span>
<span slot="birthday">01.01.2001</span>
</user-card>
Shadow DOM에서 <slot name="X">는 slot="X"가 있는 요소가 렌더링되는 위치인 "삽입 지점"을 정의한다.
그런 다음 브라우저는 "컴포지션"을 수행한다. Light DOM에서 요소를 가져와서 Shadow DOM의 해당 슬롯에 렌더링한다.
이제 우리는 데이터로 채울 수 있는 컴포넌트를 갖게 되었다.
(정확하게는 다른 컴포넌트, 엘리먼트 트리로 채울 수 있는)
Shadow DOM 슬롯 컴포지션을 고려하지 않은 컴포넌트 구조는 다음과 같다.
<user-card>
#shadow-root
<div>Name:
<slot name="username"></slot>
</div>
<div>Birthday:
<slot name="birthday"></slot>
</div>
<span slot="username">John Smith</span>
<span slot="birthday">01.01.2001</span>
</user-card>
우리는 Shadow DOM을 만들었으므로 모든 요소는 #shadow-root 아래에 있다.
이제 요소에는 light과 shadow DOM이 모두 존재한다.
렌더링을 위해 Shadow DOM의 각 <slot name="...">에 대해 브라우저는 light DOM에서 동일한 이름을 가진 slot="..."을 찾는다.
각 요소는 슬롯 내부에 렌더링된다.
<user-card>
#shadow-root
<div>Name:
<slot name="username">
<!-- slotted element is inserted into the slot -->
<span slot="username">John Smith</span>
</slot>
</div>
<div>Birthday:
<slot name="birthday">
<span slot="birthday">01.01.2001</span>
</slot>
</div>
</user-card>
하지만 평면화된 DOM은 렌더링 및 이벤트 처리 목적으로만 존재한다. 일종의 가상적 개념이다.
화면에는 다음과 같이 표시되지만, 실제 document 노드들이 이동하는 것은 아니다.
아래처럼 document에서 접근 가능하다.
// light DOM <span> nodes are still at the same place, under `<user-card>`
alert( document.querySelectorAll('user-card span').length ); // 2
즉 평면화된 DOM은 슬롯에 light dom을 삽입한 Shadow DOM에서부터 파생된다.
브라우저는 그것을 렌더링하고 스타일 상속, 이벤트 전파(나중에 자세히 설명)에 사용한다.
하지만 JavaScript는 Shadow DOM으로 파생하기 전의 문서 구조를 바라본다.
또한 slot 애트리뷰트는 탑 레벨 shadow host의 직계 자식에서만 유효하다.
아래처럼 중첩된 경우 무시된다.
<user-card>
<span slot="username">John Smith</span>
<div>
<!-- invalid slot, must be direct child of user-card -->
<span slot="birthday">01.01.2001</span>
</div>
</user-card>
<user-card>
<span slot="username">John</span>
<span slot="username">Smith</span>
</user-card>
평탄화된 DOM 구조는 다음과 같다.
<user-card>
#shadow-root
<div>Name:
<slot name="username">
<span slot="username">John</span>
<span slot="username">Smith</span>
</slot>
</div>
<div>Birthday:
<slot name="birthday"></slot>
</div>
</user-card>
Slot fallback content
슬롯 안에 컨텐츠르 넣으면 디폴트 컨텐츠가 된다.
즉 light DOM을 해당 슬롯에 제공하지 않으면 해당 컨텐츠가 보인다.
<div>Name:
<slot name="username">Anonymous</slot>
</div>
Default slot : first unnamed
name 속성이 없는 없는 Shadow DOM의 첫 번째 <slot>은 "default" 슬롯이다.
슬롯이 없는 라이트 DOM의 모든 노드를 가져와서 해당 위치에 렌더링한다.
<user-card>
<div>I like to swim.</div> <!--1-->
<span slot="username">John Smith</span>
<span slot="birthday">01.01.2001</span>
<div>...And play volleyball too!</div> <!--2-->
</user-card>
<script>
customElements.define('user-card', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `
<div>Name:
<slot name="username"></slot>
</div>
<div>Birthday:
<slot name="birthday"></slot>
</div>
<fieldset>
<legend>Other information</legend>
<slot></slot> // 1, 2
</fieldset>
`;
}
});
</script>
요소는 차례로 슬롯에 추가되므로 슬롯이 없는 두 정보는 함께 디폴트 슬롯에 위치한다.
평탄화된 돔 구조는 다음과 같다.
<user-card>
#shadow-root
<div>Name:
<slot name="username">
<span slot="username">John Smith</span>
</slot>
</div>
<div>Birthday:
<slot name="birthday">
<span slot="birthday">01.01.2001</span>
</slot>
</div>
<fieldset>
<legend>Other information</legend>
<slot>
<div>I like to swim.</div>
<div>...And play volleyball too!</div>
</slot>
</fieldset>
</user-card>
Menu 예제로 보기
아래 탬플릿을 사용헤 Shadow DOM을 만들어 보자.
<template id="tmpl">
<style> /* menu styles */ </style>
<div class="menu">
<slot name="title"></slot>
<ul><slot name="item"></slot></ul>
</div>
</template>
해당 탬플릿을 활용해 shadow Root를 만든다.
이는 웹 컴포넌트를 정의하는 것과 유사하다.
customElements.define('custom-menu', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
// tmpl is the shadow DOM template (above)
this.shadowRoot.append( tmpl.content.cloneNode(true) );
// we can't select light DOM nodes, so let's handle clicks on the slot
this.shadowRoot.querySelector('slot[name="title"]').onclick = () => {
// open/close the menu
this.shadowRoot.querySelector('.menu').classList.toggle('closed');
};
}
});
아래 마크업을 이용해 웹 컴포넌트를 렌더링한다.
<custom-menu>
<span slot="title">Candy menu</span>
<li slot="item">Lollipop</li>
<li slot="item">Fruit Toast</li>
<li slot="item">Cup Cake</li>
</custom-menu>
- <span slot="title">은 <slot name="title">에 들어간다.
- <custom-menu>에는 많은 <li slot="item">이 있지만 템플릿에는 하나의 <slot name="item">만 있다.
- 모든 <li slot="item">은 <slot name="item">에 차례로 추가되어 목록을 만든다.
Slot 업데이트 하기
브라우저가 알아서 슬롯의 변경사항을 업데이트 해주기 위해 리렌더링을 위해 따로 해줄 일은 없다.
코드를 통해 슬롯 변경에 반응할 수도 있다.
<custom-menu id="menu">
<span slot="title">Candy menu</span>
</custom-menu>
<script>
customElements.define('custom-menu', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `<div class="menu">
<slot name="title"></slot>
<ul><slot name="item"></slot></ul>
</div>`;
// shadowRoot can't have event handlers, so using the first child
this.shadowRoot.firstElementChild.addEventListener('slotchange',
e => alert("slotchange: " + e.target.name)
);
}
});
setTimeout(() => {
menu.insertAdjacentHTML('beforeEnd', '<li slot="item">Lollipop</li>')
}, 1000);
setTimeout(() => {
menu.querySelector('[slot="title"]').innerHTML = "New menu";
}, 2000);
</script>
Slot API
- node.assignedSlot – 노드가 할당된 <slot> 요소를 반환합니다.
- slot.assignedNodes({flatten: true/false})
- 슬롯에 할당된 DOM 노드. flatten 옵션은 디폴트로 false
- 명시적으로 true로 설정하면 평탄화된 DOM을 더 깊이 살펴본다.
- 즉, 중첩된 슬롯을 반환하며, 중첩된 컴포넌트 혹은 노드가 할당되지 않은 슬롯의 경우 fall 콘텐츠를 반환한다.
- slot.assignedElements({flatten: true/false}) – 슬롯에 할당된 DOM 요소(위와 동일하지만 요소 노드만 있음).
이러한 메서드는 슬롯 콘텐츠를 표시할 뿐만 아니라 JavaScript에서 추적해야 할 때 유용하다.
예를 들어 <custom-menu> 컴포넌트가 무엇을 표시하는지 알고 싶다면 슬롯 변경을 추적하여
slot.assignedElements에서 항목을 가져올 수 있다.
<custom-menu id="menu">
<span slot="title">Candy menu</span>
<li slot="item">Lollipop</li>
<li slot="item">Fruit Toast</li>
</custom-menu>
<script>
customElements.define('custom-menu', class extends HTMLElement {
items = []
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `<div class="menu">
<slot name="title"></slot>
<ul><slot name="item"></slot></ul>
</div>`;
// triggers when slot content changes
this.shadowRoot.firstElementChild.addEventListener('slotchange', e => {
let slot = e.target;
if (slot.name == 'item') {
this.items = slot.assignedElements().map(elem => elem.textContent);
alert("Items: " + this.items);
}
});
}
});
// items update after 1 second
setTimeout(() => {
menu.insertAdjacentHTML('beforeEnd', '<li slot="item">Cup Cake</li>')
}, 1000);
</script>
Summary
일반적으로 요소에 Shadow DOM이 있는 경우 Light DOM은 표시하지 않지만,
슬롯을 사용하면 Shadow DOM의 지정된 위치에 Light DOM의 요소를 표시할 수 있다.
- 명명된(named) 슬롯: <slot name="X">...</slot> – slot="X"인 라이트 자식을 가져온다.
- 디폴트 슬롯: 이름이 없는 첫 번째 <slot>(다음에 나오는 이름없는 슬롯은 무시됨) – 슬롯이 없는 라이트 자식을 가져온다.
- 동일한 슬롯에 대해 많은 요소가 있는 경우 - 차례로 추가된다.
- <slot> 요소의 콘텐츠는 폴백으로 사용된다. 슬롯에 대한 라이트 자식이 없는 경우 표시된다.
슬롯 내부에 슬롯이 있는 요소를 렌더링하는 프로세스를 "composition"이라고 하며. 그 결과를 "flatten DOM"이라고 한다.
컴포지션은 실제로 노드를 이동하지 않으며 JavaScript 관점에서 DOM은 여전히 동일하다.
JavaScript는 다음 메서드를 사용하여 슬롯에 액세스할 수 있다.
- slot.assignedNodes/Elements() – 슬롯 내부의 노드, 엘리먼트들을 리턴한다.
- node.assignedSlot – 노드의 슬롯을 리턴한다.
다음을 사용하여 슬롯 콘텐츠를 추적할 수 있다.
- slotchange event - 특정 슬롯에 아이템이 추가되거나 삭제될 때(light DOM의 slot 속성) 발생
- MutationObserver
Shadow DOM styling
<style>, <link rel="stylesheet" href="…"> 두 가지 방법으로 스타일을 적용할 수 있다.
일반적으로 로컬 스타일은 섀도우 트리 내부에서만 작동하고 문서 스타일은 그 외부에서 동작하지만 몇 가지 예외가 있다.
:host
<template id="tmpl">
<style>
/* the style will be applied from inside to the custom-dialog element */
:host {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: inline-block;
border: 1px solid red;
padding: 10px;
}
</style>
<slot></slot>
</template>
<script>
customElements.define('custom-dialog', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'}).append(tmpl.content.cloneNode(true));
}
});
</script>
<custom-dialog>
Hello!
</custom-dialog>
Cascading
<style>
custom-dialog {
padding: 0;
}
</style>
:host 규칙에서 "디폴트" 컴포넌트 스타일을 설정한 다음 문서에서 쉽게 재정의할 수 있으므로 이는 편리할 수 있다.
예외는 로컬 속성 레이블이 !important인 경우다. 이러한 속성의 경우 로컬 스타일이 우선한다.
:host(selector)
:host와 동일하지만 섀도우 호스트가 셀렉터와 일치하는 경우에만 적용된다.
예를 들어, 우리는 <custom-dialog>가 centered 속성을 가진 경우에만 중앙에 배치하고 싶다.
<template id="tmpl">
<style>
:host([centered]) {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
border-color: blue;
}
:host {
display: inline-block;
border: 1px solid red;
padding: 10px;
}
</style>
<slot></slot>
</template>
<script>
customElements.define('custom-dialog', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'}).append(tmpl.content.cloneNode(true));
}
});
</script>
<custom-dialog centered>
Centered!
</custom-dialog>
<custom-dialog>
Not centered.
</custom-dialog>
Styling slotted content
슬롯 요소는 Light DOM에서 가져오므로 문서 스타일을 사용한다.
즉, 로컬 스타일은 슬롯 콘텐츠에 영향을 주지 않는다.
아래 예에서 slot이 있는 <span>은 문서 스타일에 따라 굵게 표시되지만 로컬 스타일에서 빨간색 배경을 가져오지 않는다.
<style>
span { font-weight: bold }
</style>
<user-card>
<div slot="username"><span>John Smith</span></div>
</user-card>
<script>
customElements.define('user-card', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `
<style>
span { background: red; }
</style>
Name: <slot name="username"></slot>
`;
}
});
</script>
슬롯 콘텐츠에 스타일을 적용하는 방법은 두 가지가 있다.
먼저 <slot> 자체에 스타일을 지정하고 CSS 상속에 의존할 수 있다.
<user-card>
<div slot="username"><span>John Smith</span></div>
</user-card>
<script>
customElements.define('user-card', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `
<style>
slot[name="username"] { font-weight: bold; }
</style>
Name: <slot name="username"></slot>
`;
}
});
</script>
<p>John Smith</p>는 굵게 표시된다.
CSS 상속이 <slot>과 그 내용 사이에 적용되기 때문이다.
하지만 CSS 자체는 문서에서 상속받지 않음을 주의한다.
또 다른 옵션은 ::slotted(selector) 의사 클래스를 사용하는 것이다.
두 가지 조건에 따라 요소를 매칭한다.
- light DOM에서 오는 슬롯에 배치된 요소
- 슬롯 이름은 무관하다.
- 슬롯에 배치된 요소지만 자식이 아닌 요소를 의미한다.
- selector와 매칭되는 요소
<user-card>
<div slot="username">
<div>John Smith</div>
</div>
</user-card>
<script>
customElements.define('user-card', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `
<style>
::slotted(div) { border: 1px solid red; }
</style>
Name: <slot name="username"></slot>
`;
}
});
</script>
::slotted(div span) {
/* our slotted <div> does not match this */
}
::slotted(div) p {
/* can't go inside light DOM */
}
CSS hooks with custom properties
<style>
.field {
color: var(--user-card-field-color, black);
/* if --user-card-field-color is not defined, use black color */
}
</style>
<div class="field">Name: <slot name="username"></slot></div>
<div class="field">Birthday: <slot name="birthday"></slot></div>
user-card {
--user-card-field-color: green;
}
그리고 아래와 같이 사용한다.
<style>
user-card {
--user-card-field-color: green;
}
</style>
<template id="tmpl">
<style>
.field {
color: var(--user-card-field-color, black);
}
</style>
<div class="field">Name: <slot name="username"></slot></div>
<div class="field">Birthday: <slot name="birthday"></slot></div>
</template>
<script>
customElements.define('user-card', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.append(document.getElementById('tmpl').content.cloneNode(true));
}
});
</script>
<user-card>
<span slot="username">John Smith</span>
<span slot="birthday">01.01.2001</span>
</user-card>
Shadow DOM 스타일 지정 요약
Shadow DOM은 <style> 또는 <link rel="stylesheet">를 사용해 스타일 지정 가능하다.
로컬 스타일은 다음에 영향을 줄 수 있다.
- shadow tree
- shadow host (:host, :host() 이용)
- 슬롯배치 요소(light DOM)
- ::slotted(selector)는 슬롯배치 요소 자체를 선택할 수 있지만, 자식 요소들은 선택할 수 없다.
문서 스타일은 다음에 영향을 줄 수 있다.
- shadow host(shadow host는 shadow dom 밖의 document에 위치한다.)
- 슬롯배치 요소 및 컨텐츠(shadow dom 밖의 document에도 존재한다)
CSS 속성이 충돌하는 경우 속성이 !important로 레이블 지정되지 않는 한 일반적으로 문서 스타일이 우선한다.
CSS 사용자 정의 속성은 Shadow DOM을 관통합니다. 컴포넌트의 스타일을 지정하는 "hook"으로 사용할 수 있다.
Shadow DOM과 이벤트
섀도우 트리의 기본 아이디어는 컴포넌트 내부 구현 세부사항을 캡슐화하는 것이다.
<user-card> 컴포넌트의 Shadow DOM 내부에서 클릭 이벤트가 발생한다고 가정해 보자.
문서의 스크립트는 특히 컴포넌트가 서드파티 라이브러리에서 제공되는 경우 Shadow DOM 내부에 대해 전혀 모른다.
따라서 세부 정보를 캡슐화하기 위해 브라우저는 이벤트 대상을 재지정한다.
Shadow DOM에서 발생하는 이벤트는 컴포넌트 외부에서 포착될 때 호스트 요소를 타겟으로 한다.
<user-card></user-card>
<script>
customElements.define('user-card', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `<p>
<button>Click me</button>
</p>`;
this.shadowRoot.firstElementChild.onclick =
e => alert("Inner target: " + e.target.tagName);
}
});
document.onclick =
e => alert("Outer target: " + e.target.tagName);
</script>
아래 버튼을 클릭하면 메시지는 다음과 같다.
- 내부 타겟: 버튼 - 내부 이벤트 핸들러가 올바른 타겟인 Shadow DOM 내부의 요소를 가져옴
- 외부 타겟: USER-CARD – 문서 이벤트 핸들러가 섀도우 호스트를 타겟으로 가져옴
이벤트 타겟 변경은 외부 문서가 컴포넌트 내부에 대해 알 필요가 없기 때문에 좋은 기능이다.
이 관점에서 이벤트는 <user-card>에서 발생하였다.
Light DOM에 물리적으로 존재하는 슬롯이 있는 요소에서 이벤트가 발생하는 경우 타겟 변경이 발생하지 않는다.
예를 들어, 아래 예에서 사용자가 <span slot="username">을 클릭하면 light, shadow dom target 핸들러 모두 span이 대상이다.
<user-card id="userCard">
<span slot="username">John Smith</span>
</user-card>
<script>
customElements.define('user-card', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `<div>
<b>Name:</b> <slot name="username"></slot>
</div>`;
this.shadowRoot.firstElementChild.onclick =
e => alert("Inner target: " + e.target.tagName);
}
});
userCard.onclick = e => alert(`Outer target: ${e.target.tagName}`);
</script>
"John Smith"에서 클릭이 발생하면 내부 및 외부 핸들러 모두에 대해 타겟은 <span slot="username">다.
light DOM의 요소이므로 재타겟팅이 필요없다.
반면 <b>Name</b>(Shadow DOM)을 클릭하면 event.target이 <user-card>로 재설정된다.
Bubbling, event.composedPath()
<user-card id="userCard">
#shadow-root
<div>
<b>Name:</b>
<slot name="username">
<span slot="username">John Smith</span>
</slot>
</div>
</user-card>
해당 디테일은 {mode:'open'}일 때만 보여진다.
섀도우 트리가 {mode: 'closed'}로 생성된 경우 구성된 경로는 host: user-card 이상에서 시작한다.
이는 Shadow DOM과 함께 작동하는 다른 메서드와 유사한 원리다.
닫힌 트리의 내부는 완전히 숨겨져 있다.
event.composed
대부분의 이벤트는 Shadow DOM 경계를 통해 성공적으로 버블링된다.
이는 이벤트 객체의 composed 속성에 의해 제어된다.
- true라면 이벤트는 shadow dom 경계를 넘어간다.
- 그렇지 않으면 Shadow DOM 내부에서만 포착할 수 있다.
- blur, focus, focusin, focusout,
- click, dblclick,
- mousedown, mouseup mousemove, mouseout, mouseover,
- wheel,
- beforeinput, input, keydown, keyup.
모든 터치 이벤트와 포인터 이벤트도 true다.
다음 이벤트들은 composed의 디폴트 값이 false다.
- mouseenter, mouseleave (전혀 버블링되지 않음)
- load, unload, abort, error,
- select,
- slotchange.
이러한 이벤트는 이벤트 타겟이 있는 동일한 DOM 내의 요소에서만 포착할 수 있다.
Custom events
커스텀 이벤트를 발송할 때 bubble과 composed 속성을 모두 true로 설정해야 컴포넌트 밖으로 버블링된다.
예를 들어 여기에서는 div#outer의 Shadow DOM에 div#inner를 생성하고 여기에서 두 개의 이벤트를 트리거한다.
composed : true인 경우에만 문서로 이벤트가 전송된다.
<div id="outer"></div>
<script>
outer.attachShadow({mode: 'open'});
let inner = document.createElement('div');
outer.shadowRoot.append(inner);
/*
div(id=outer)
#shadow-dom
div(id=inner)
*/
document.addEventListener('test', event => alert(event.detail));
inner.dispatchEvent(new CustomEvent('test', {
bubbles: true,
composed: true,
detail: "composed"
}));
inner.dispatchEvent(new CustomEvent('test', {
bubbles: true,
composed: false,
detail: "not composed"
}));
</script>
Shadow Dom 이벤트 요약
이벤트는 composed 플래그가 true로 설정된 경우에만 섀도우 DOM 경계를 넘어간다.
빌트인 이벤트는 대부분 관련 사양에 설명된 대로 composed 값이 true다.
- UI Events https://www.w3.org/TR/uievents.
- Touch Events https://w3c.github.io/touch-events.
- Pointer Events https://www.w3.org/TR/pointerevents.
- …And so on.
몇몇 이벤트는 composed 값이 false다.
- mouseenter, mouseleave (also do not bubble),
- load, unload, abort, error,
- select,
- slotchange.
'FrontEnd' 카테고리의 다른 글
[번역] 자바스크립트 함수형 파이프라인으로 리팩토링 (0) | 2023.04.07 |
---|---|
[번역] 자바스크립트 리팩토링과 추상화 (0) | 2023.04.05 |
[번역] 리액트 Compound Component 잘 사용하기 (0) | 2023.04.04 |
Leetcode 241: Different Ways to Add Parentheses(수식 모든 경우의 수 계산하기)[BFS,DFS] (0) | 2023.04.03 |
프론트엔드 클린 아키텍처 with React (0) | 2023.04.02 |