Shadow DOM v1 - 독립형 웹 구성요소

Shadow DOM을 사용하면 웹 개발자가 웹 구성요소에 대해 구획화된 DOM 및 CSS를 만들 수 있습니다.

요약

섀도우 DOM은 웹 앱 빌드의 취약성을 제거합니다. 이러한 취약성은 HTML, CSS, JS의 전역적 특성에서 비롯됩니다. 수년에 걸쳐 Google은 문제를 해결하기 위해 엄청난 수 의 도구를 개발했습니다. 예를 들어 새 HTML ID/클래스를 사용하는 경우 페이지에서 사용하는 기존 이름과 충돌하는지 알 수 없습니다. 미묘한 버그가 발생하고, CSS 특수성이 큰 문제가 되며 (!important 모든 것!), 스타일 선택기가 제어할 수 없을 정도로 커지고, 성능이 저하될 수 있습니다. 이외에도 여러 작업이 필요합니다.

섀도우 DOM은 CSS와 DOM을 수정합니다. 웹 플랫폼에 범위가 지정된 스타일을 도입합니다. 도구나 이름 지정 규칙 없이 CSS를 마크업과 번들로 묶고 구현 세부정보를 숨기고 바닐라 JavaScript로 자체 포함 구성요소를 작성할 수 있습니다.

소개

Shadow DOM은 세 가지 웹 구성요소 표준(HTML 템플릿, Shadow DOM, 맞춤 요소) 중 하나입니다. HTML 가져오기는 목록에 포함되어 있었지만 이제 지원 중단된 것으로 간주됩니다.

섀도우 DOM을 사용하는 웹 구성요소를 작성할 필요가 없습니다. 하지만 이 경우 CSS 범위 지정, DOM 캡슐화, 컴포지션과 같은 이점을 활용하고 복원력이 뛰어나고 구성 가능성이 높으며 재사용성이 뛰어난 재사용 가능한 맞춤 요소를 빌드할 수 있습니다. 맞춤 요소가 JS API를 사용하여 새 HTML을 만드는 방법이라면, shadow DOM은 HTML 및 CSS를 제공하는 방법입니다. 두 API가 결합되어 자체 포함된 HTML, CSS, JavaScript가 있는 구성요소를 만듭니다.

Shadow DOM은 구성요소 기반 앱을 빌드하기 위한 도구로 설계되었습니다. 따라서 웹 개발의 일반적인 문제에 대한 솔루션을 제공합니다.

  • 분리된 DOM: 구성요소의 DOM은 독립적입니다 (예: document.querySelector()는 구성요소의 shadow DOM에 노드를 반환하지 않음).
  • 범위가 지정된 CSS: Shadow DOM 내부에 정의된 CSS는 범위가 지정됩니다. 스타일 규칙이 유출되지 않고 페이지 스타일이 유입되지 않습니다.
  • 컴포지션: 구성요소에 선언적 마크업 기반 API를 설계합니다.
  • CSS 간소화 - 범위가 지정된 DOM은 간단한 CSS 선택자, 더 일반적인 ID/클래스 이름을 사용할 수 있으며 이름 충돌에 대해 걱정할 필요가 없음을 의미합니다.
  • 생산성 - 앱을 하나의 큰(전역) 페이지가 아닌 DOM 청크로 생각합니다.

fancy-tabs 데모

이 도움말에서는 데모 구성요소 (<fancy-tabs>)를 참조하고 이 구성요소의 코드 스니펫을 참조합니다. 브라우저가 API를 지원하는 경우 바로 아래에 실시간 데모가 표시됩니다. 그렇지 않으면 GitHub의 전체 소스를 확인하세요.

GitHub에서 소스 보기

섀도 DOM이란 무엇인가요?

DOM 배경 정보

HTML은 작업하기 쉽기 때문에 웹을 구동합니다. 몇 가지 태그를 선언하면 프레젠테이션과 구조가 모두 포함된 페이지를 몇 초 만에 작성할 수 있습니다. 하지만 HTML 자체는 그다지 유용하지 않습니다. 사람은 텍스트 기반 언어를 쉽게 이해할 수 있지만, 기계는 더 많은 것이 필요합니다. 문서 객체 모델(DOM)을 입력합니다.

브라우저가 웹페이지를 로드할 때 흥미로운 작업이 많이 수행됩니다. 이 도구는 작성자의 HTML을 라이브 문서로 변환하는 등의 작업을 수행합니다. 기본적으로 페이지의 구조를 이해하기 위해 브라우저는 HTML (정적 텍스트 문자열)을 데이터 모델 (객체/노드)로 파싱합니다. 브라우저는 이러한 노드의 트리인 DOM을 만들어 HTML의 계층 구조를 보존합니다. DOM의 멋진 점은 페이지를 실시간으로 표현한다는 것입니다. 우리가 작성하는 정적 HTML과 달리 브라우저에서 생성된 노드에는 속성, 메서드가 포함되며 무엇보다도 프로그램으로 조작할 수 있습니다. 따라서 JavaScript를 사용하여 DOM 요소를 직접 만들 수 있습니다.

const header = document.createElement('header');
const h1 = document.createElement('h1');
h1.textContent = 'Hello DOM';
header.appendChild(h1);
document.body.appendChild(header);

다음 HTML 마크업을 생성합니다.

<body>
    <header>
    <h1>Hello DOM</h1>
    </header>
</body>

이 모든 것이 좋습니다. 그렇다면 shadow DOM은 도대체 무엇일까요?

DOM… 그림자 속에서

Shadow DOM은 일반 DOM과 동일하지만 두 가지 차이점이 있습니다. 1) 생성/사용 방식과 2) 페이지의 나머지 부분과 관련하여 동작하는 방식입니다. 일반적으로 DOM 노드를 만들고 다른 요소의 하위 요소로 추가합니다. 섀도 DOM을 사용하면 요소에 연결되어 있지만 실제 하위 요소와는 별개인 범위가 지정된 DOM 트리를 만들 수 있습니다. 이 범위가 지정된 하위 트리를 섀도 트리라고 합니다. 연결된 요소는 섀도 호스트입니다. 섀도우에 추가하는 모든 항목은 <style>를 포함하여 호스팅 요소에 로컬이 됩니다. 이것이 바로 shadow DOM이 CSS 스타일 범위를 지정하는 방법입니다.

섀도 DOM 만들기

섀도 루트는 '호스트' 요소에 연결되는 문서 프래그먼트입니다. 섀도우 루트를 연결하는 행위는 요소가 섀도우 DOM을 얻는 방법입니다. 요소의 shadow DOM을 만들려면 element.attachShadow()를 호출합니다.

const header = document.createElement('header');
const shadowRoot = header.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>'; // Could also use appendChild().

// header.shadowRoot === shadowRoot
// shadowRoot.host === header

.innerHTML를 사용하여 shadow root를 채우고 있지만 다른 DOM API를 사용할 수도 있습니다. 이것이 웹입니다. 우리에게는 선택권이 있습니다.

사양은 섀도 트리를 호스팅할 수 없는 요소 목록을 정의합니다. 요소가 목록에 있는 이유는 다음과 같습니다.

  • 브라우저는 이미 요소(<textarea>, <input>)의 자체 내부 Shadow DOM을 호스팅합니다.
  • 요소가 shadow DOM (<img>)을 호스팅하는 것은 의미가 없습니다.

예를 들어 다음은 작동하지 않습니다.

    document.createElement('input').attachShadow({mode: 'open'});
    // Error. `<input>` cannot host shadow dom.

맞춤 요소의 shadow DOM 만들기

Shadow DOM은 맞춤 요소를 만들 때 특히 유용합니다. shadow DOM을 사용하여 요소의 HTML, CSS, JS를 구분하여 '웹 구성요소'를 생성합니다.

예 - 맞춤 요소가 자체에 shadow DOM을 연결하여 DOM/CSS를 캡슐화합니다.

// Use custom elements API v1 to register a new HTML tag and define its JS behavior
// using an ES6 class. Every instance of <fancy-tab> will have this same prototype.
customElements.define('fancy-tabs', class extends HTMLElement {
    constructor() {
    super(); // always call super() first in the constructor.

    // Attach a shadow root to <fancy-tabs>.
    const shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.innerHTML = `
        <style>#tabs { ... }</style> <!-- styles are scoped to fancy-tabs! -->
        <div id="tabs">...</div>
        <div id="panels">...</div>
    `;
    }
    ...
});

여기에는 몇 가지 흥미로운 점이 있습니다. 첫 번째는 <fancy-tabs>의 인스턴스가 생성될 때 맞춤 요소가 자체 shadow DOM을 생성한다는 것입니다. 이 작업은 constructor()에서 수행됩니다. 둘째, 섀도우 루트를 생성하므로 <style> 내부의 CSS 규칙은 <fancy-tabs>로 범위가 지정됩니다.

컴포지션 및 슬롯

컴포지션은 Shadow DOM에서 가장 이해하기 어려운 기능 중 하나이지만, 가장 중요한 기능이기도 합니다.

웹 개발 세계에서 컴포지션은 HTML에서 선언적으로 앱을 구성하는 방법입니다. 다양한 빌딩 블록 (<div>, <header>, <form>, <input>)이 모여 앱을 형성합니다. 이러한 태그 중 일부는 서로 함께 작동하기도 합니다. 컴포지션은 <select>, <details>, <form>, <video>와 같은 네이티브 요소가 매우 유연한 이유입니다. 이러한 각 태그는 특정 HTML을 하위 요소로 허용하고 이를 사용하여 특별한 작업을 수행합니다. 예를 들어 <select>는 <option> 및 <optgroup>를 드롭다운 및 다중 선택 위젯으로 렌더링하는 방법을 알고 있습니다. <details> 요소는 <summary>를 펼칠 수 있는 화살표로 렌더링합니다. <video>도 특정 하위 요소를 처리하는 방법을 알고 있습니다. <source> 요소는 렌더링되지 않지만 동영상의 동작에 영향을 미칩니다. 마법 같지 않나요?

용어: 라이트 DOM과 섀도우 DOM

Shadow DOM 구성은 웹 개발에 많은 새로운 기본 사항을 도입합니다. 자세히 알아보기 전에 먼저 용어를 표준화하여 동일한 용어를 사용하도록 하겠습니다.

Light DOM

구성요소 사용자가 작성하는 마크업입니다. 이 DOM은 구성요소의 shadow DOM 외부에 있습니다. 그것은 해당 요소의 실제 자식들입니다.

<better-button>
    <!-- the image and span are better-button's light DOM -->
    <img src="gear.svg" slot="icon">
    <span>Settings</span>
</better-button>

Shadow DOM

구성요소 작성자가 작성하는 DOM입니다. Shadow DOM은 구성요소에 로컬이며 내부 구조, 범위가 지정된 CSS를 정의하고 구현 세부정보를 캡슐화합니다. 또한 구성요소의 소비자가 작성한 마크업을 렌더링하는 방법을 정의할 수도 있습니다.

#shadow-root
    <style>...</style>
    <slot name="icon"></slot>
    <span id="wrapper">
    <slot>Button</slot>
    </span>

평면화된 DOM 트리

브라우저가 사용자의 light DOM을 shadow DOM에 배포하여 최종 제품을 렌더링한 결과입니다. 평면화된 트리는 개발자 도구에서 최종적으로 표시되는 항목이며 페이지에 렌더링되는 항목입니다.

<better-button>
    #shadow-root
    <style>...</style>
    <slot name="icon">
        <img src="gear.svg" slot="icon">
    </slot>
    <span id="wrapper">
        <slot>
        <span>Settings</span>
        </slot>
    </span>
</better-button>

<slot> 요소

섀도우 DOM은 <slot> 요소를 사용하여 다양한 DOM 트리를 함께 구성합니다. 슬롯은 사용자가 자체 마크업으로 채울 수 있는 구성요소 내의 자리표시자입니다. 하나 이상의 슬롯을 정의하면 구성요소의 shadow DOM에서 외부 마크업을 렌더링할 수 있습니다. 기본적으로 '여기에 사용자의 마크업을 렌더링'하라고 지시하는 것입니다.

<slot>가 요소를 초대하면 요소가 shadow DOM 경계를 '넘을' 수 있습니다. 이러한 요소를 분산 노드라고 합니다. 개념적으로 분산 노드는 약간 이상해 보일 수 있습니다. 슬롯은 DOM을 물리적으로 이동하지 않으며, shadow DOM 내부의 다른 위치에서 렌더링합니다.

구성요소는 shadow DOM에 슬롯을 0개 이상 정의할 수 있습니다. 슬롯은 비어 있거나 대체 콘텐츠를 제공할 수 있습니다. 사용자가 light DOM 콘텐츠를 제공하지 않으면 슬롯은 대체 콘텐츠를 렌더링합니다.

<!-- Default slot. If there's more than one default slot, the first is used. -->
<slot></slot>

<slot>fallback content</slot> <!-- default slot with fallback content -->

<slot> <!-- default slot entire DOM tree as fallback -->
    <h2>Title</h2>
    <summary>Description text</summary>
</slot>

이름이 지정된 슬롯을 만들 수도 있습니다. 이름이 지정된 슬롯은 사용자가 이름으로 참조하는 Shadow DOM의 특정 구멍입니다.

예 - <fancy-tabs>의 shadow DOM에 있는 슬롯:

#shadow-root
    <div id="tabs">
    <slot id="tabsSlot" name="title"></slot> <!-- named slot -->
    </div>
    <div id="panels">
    <slot id="panelsSlot"></slot>
    </div>

구성요소 사용자는 다음과 같이 <fancy-tabs>를 선언합니다.

<fancy-tabs>
    <button slot="title">Title</button>
    <button slot="title" selected>Title 2</button>
    <button slot="title">Title 3</button>
    <section>content panel 1</section>
    <section>content panel 2</section>
    <section>content panel 3</section>
</fancy-tabs>

<!-- Using <h2>'s and changing the ordering would also work! -->
<fancy-tabs>
    <h2 slot="title">Title</h2>
    <section>content panel 1</section>
    <h2 slot="title" selected>Title 2</h2>
    <section>content panel 2</section>
    <h2 slot="title">Title 3</h2>
    <section>content panel 3</section>
</fancy-tabs>

평면화된 트리는 다음과 같습니다.

<fancy-tabs>
    #shadow-root
    <div id="tabs">
        <slot id="tabsSlot" name="title">
        <button slot="title">Title</button>
        <button slot="title" selected>Title 2</button>
        <button slot="title">Title 3</button>
        </slot>
    </div>
    <div id="panels">
        <slot id="panelsSlot">
        <section>content panel 1</section>
        <section>content panel 2</section>
        <section>content panel 3</section>
        </slot>
    </div>
</fancy-tabs>

구성요소는 다양한 구성을 처리할 수 있지만 평면화된 DOM 트리는 동일하게 유지됩니다. <button>에서 <h2>로 전환할 수도 있습니다. 이 구성요소는 <select>와 마찬가지로 다양한 유형의 하위 요소를 처리하도록 작성되었습니다.

스타일 지정

웹 구성요소의 스타일을 지정하는 방법에는 여러 가지가 있습니다. 섀도 DOM을 사용하는 구성요소는 기본 페이지에서 스타일을 지정하거나, 자체 스타일을 정의하거나, 사용자가 기본값을 재정의할 수 있는 후크(CSS 맞춤 속성 형식)를 제공할 수 있습니다.

구성요소 정의 스타일

섀도우 DOM의 가장 유용한 기능은 범위가 지정된 CSS입니다.

  • 외부 페이지의 CSS 선택자는 구성요소 내부에 적용되지 않습니다.
  • 내부에 정의된 스타일은 외부로 유출되지 않습니다. 호스트 요소로 범위가 지정됩니다.

섀도 DOM 내에서 사용되는 CSS 선택기는 구성요소에 로컬로 적용됩니다. 실제로 이는 페이지의 다른 곳에서 충돌을 걱정하지 않고 일반적인 ID/클래스 이름을 다시 사용할 수 있음을 의미합니다. 섀도우 DOM 내에서는 더 간단한 CSS 선택자를 사용하는 것이 좋습니다. 실적에도 도움이 됩니다.

예 - shadow root에 정의된 스타일은 로컬입니다.

#shadow-root
    <style>
    #panels {
        box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
        background: white;
        ...
    }
    #tabs {
        display: inline-flex;
        ...
    }
    </style>
    <div id="tabs">
    ...
    </div>
    <div id="panels">
    ...
    </div>

스타일시트도 섀도 트리에 범위가 지정됩니다.

#shadow-root
    <link rel="stylesheet" href="styles.css">
    <div id="tabs">
    ...
    </div>
    <div id="panels">
    ...
    </div>

multiple 속성을 추가할 때 <select> 요소가 드롭다운 대신 다중 선택 위젯을 렌더링하는 방식이 궁금한 적이 있으신가요?

<select multiple>
  <option>Do</option>
  <option selected>Re</option>
  <option>Mi</option>
  <option>Fa</option>
  <option>So</option>
</select>

<select>는 선언한 속성에 따라 자체 스타일을 다르게 지정할 수 있습니다. 웹 구성요소는 :host 선택기를 사용하여 자체적으로 스타일을 지정할 수도 있습니다.

예 - 자체 스타일을 지정하는 구성요소

<style>
:host {
    display: block; /* by default, custom elements are display: inline */
    contain: content; /* CSS containment FTW. */
}
</style>

:host의 한 가지 주의점은 상위 페이지의 규칙이 요소에 정의된 :host 규칙보다 구체성이 높다는 것입니다. 즉, 외부 스타일이 우선 적용됩니다. 이렇게 하면 사용자가 외부에서 최상위 스타일 지정을 재정의할 수 있습니다. 또한 :host는 shadow root의 컨텍스트에서만 작동하므로 shadow DOM 외부에서는 사용할 수 없습니다.

:host(<selector>)의 함수 형식을 사용하면 <selector>과 일치하는 경우 호스트를 타겟팅할 수 있습니다. 이는 구성요소가 사용자 상호작용에 반응하는 동작을 캡슐화하거나 호스트를 기반으로 내부 노드의 상태 또는 스타일을 지정하는 좋은 방법입니다.

<style>
:host {
    opacity: 0.4;
    will-change: opacity;
    transition: opacity 300ms ease-in-out;
}
:host(:hover) {
    opacity: 1;
}
:host([disabled]) { /* style when host has disabled attribute. */
    background: grey;
    pointer-events: none;
    opacity: 0.4;
}
:host(.blue) {
    color: blue; /* color host when it has class="blue" */
}
:host(.pink) > #tabs {
    color: pink; /* color internal #tabs node when host has class="pink". */
}
</style>

컨텍스트에 따른 스타일 지정

:host-context(<selector>)는 구성요소 또는 상위 요소가 <selector>과 일치하는 경우 구성요소와 일치합니다. 일반적으로 구성요소의 주변 환경에 따라 테마를 지정하는 데 사용됩니다. 예를 들어 많은 사용자가 <html> 또는 <body>에 클래스를 적용하여 테마를 지정합니다.

<body class="darktheme">
    <fancy-tabs>
    ...
    </fancy-tabs>
</body>

:host-context(.darktheme)는 .darktheme의 하위 요소인 경우 <fancy-tabs>의 스타일을 지정합니다.

:host-context(.darktheme) {
    color: white;
    background: black;
}

:host-context()은 테마 설정에 유용할 수 있지만 CSS 맞춤 속성을 사용하여 스타일 후크를 만드는 것이 더 좋은 방법입니다.

분산 노드 스타일 지정

::slotted(<compound-selector>)는 <slot>에 배포된 노드와 일치합니다.

이름 배지 구성요소를 만들었다고 가정해 보겠습니다.

<name-badge>
    <h2>Eric Bidelman</h2>
    <span class="title">
    Digital Jedi, <span class="company">Google</span>
    </span>
</name-badge>

구성요소의 shadow DOM은 사용자의 <h2> 및 .title의 스타일을 지정할 수 있습니다.

<style>
::slotted(h2) {
    margin: 0;
    font-weight: 300;
    color: red;
}
::slotted(.title) {
    color: orange;
}
/* DOESN'T WORK (can only select top-level nodes).
::slotted(.company),
::slotted(.title .company) {
    text-transform: uppercase;
}
*/
</style>
<slot></slot>

앞서 설명했듯이 <slot>는 사용자의 라이트 DOM을 이동하지 않습니다. 노드가 <slot>에 분산되면 <slot>가 DOM을 렌더링하지만 노드는 물리적으로 그대로 유지됩니다. 배포 전에 적용된 스타일은 배포 후에도 계속 적용됩니다. 하지만 light DOM이 배포되면 추가 스타일 (shadow DOM에 의해 정의된 스타일)을 취할 수 있습니다.

<fancy-tabs>의 또 다른 심층적인 예시:

const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
    <style>
    #panels {
        box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
        background: white;
        border-radius: 3px;
        padding: 16px;
        height: 250px;
        overflow: auto;
    }
    #tabs {
        display: inline-flex;
        -webkit-user-select: none;
        user-select: none;
    }
    #tabsSlot::slotted(*) {
        font: 400 16px/22px 'Roboto';
        padding: 16px 8px;
        ...
    }
    #tabsSlot::slotted([aria-selected="true"]) {
        font-weight: 600;
        background: white;
        box-shadow: none;
    }
    #panelsSlot::slotted([aria-hidden="true"]) {
        display: none;
    }
    </style>
    <div id="tabs">
    <slot id="tabsSlot" name="title"></slot>
    </div>
    <div id="panels">
    <slot id="panelsSlot"></slot>
    </div>
`;

이 예시에는 탭 제목의 이름이 지정된 슬롯과 탭 패널 콘텐츠의 슬롯이라는 두 개의 슬롯이 있습니다. 사용자가 탭을 선택하면 선택한 탭을 굵게 표시하고 패널을 표시합니다. 이는 selected 속성이 있는 분산 노드를 선택하여 수행됩니다. 맞춤 요소의 JS (여기에 표시되지 않음)는 적절한 시점에 해당 속성을 추가합니다.

외부에서 구성요소 스타일 지정

외부에서 구성요소의 스타일을 지정하는 방법에는 몇 가지가 있습니다. 가장 쉬운 방법은 태그 이름을 선택기로 사용하는 것입니다.

fancy-tabs {
    width: 500px;
    color: red; /* Note: inheritable CSS properties pierce the shadow DOM boundary. */
}
fancy-tabs:hover {
    box-shadow: 0 3px 3px #ccc;
}

외부 스타일은 항상 shadow DOM에 정의된 스타일보다 우선 적용됩니다. 예를 들어 사용자가 선택기 fancy-tabs { width: 500px; }를 작성하면 구성요소의 규칙 :host { width: 650px;}보다 우선 적용됩니다.

구성요소 자체의 스타일 지정에는 한계가 있습니다. 하지만 구성요소의 내부를 스타일 지정하려면 어떻게 해야 할까요? 이를 위해서는 CSS 맞춤 속성이 필요합니다.

CSS 맞춤 속성을 사용하여 스타일 후크 만들기

구성요소 작성자가 CSS 맞춤 속성을 사용하여 스타일 지정 후크를 제공하는 경우 사용자는 내부 스타일을 조정할 수 있습니다. 개념적으로 이 아이디어는 <slot>와 유사합니다. 사용자가 재정의할 수 있는 '스타일 자리표시자'를 만듭니다.

예 - <fancy-tabs>를 사용하면 사용자가 배경 색상을 재정의할 수 있습니다.

<!-- main page -->
<style>
    fancy-tabs {
    margin-bottom: 32px;
    --fancy-tabs-bg: black;
    }
</style>
<fancy-tabs background>...</fancy-tabs>

섀도 DOM 내:

:host([background]) {
    background: var(--fancy-tabs-bg, #9E9E9E);
    border-radius: 10px;
    padding: 10px;
}

이 경우 구성요소는 사용자가 제공한 black를 배경 값으로 사용합니다. 그렇지 않으면 기본값은 #9E9E9E입니다.

고급 주제

비공개 그림자 루트 만들기 (피해야 함)

'closed' 모드라는 또 다른 형태의 그림자 DOM이 있습니다. 비공개 shadow 트리를 만들면 외부 JavaScript가 구성요소의 내부 DOM에 액세스할 수 없습니다. 이는 <video>와 같은 네이티브 요소의 작동 방식과 유사합니다. 브라우저가 닫힌 모드 섀도우 루트를 사용하여 구현하므로 JavaScript가 <video>의 섀도우 DOM에 액세스할 수 없습니다.

예 - 비공개 섀도 트리를 만듭니다.

const div = document.createElement('div');
const shadowRoot = div.attachShadow({mode: 'closed'}); // close shadow tree
// div.shadowRoot === null
// shadowRoot.host === div

다른 API도 종료 모드의 영향을 받습니다.

  • Element.assignedSlot / TextNode.assignedSlot는 null를 반환합니다.
  • Event.composedPath(): shadow DOM 내부의 요소와 연결된 이벤트의 경우 []를 반환합니다.

{mode: 'closed'}로 웹 구성요소를 만들면 안 되는 이유를 요약하면 다음과 같습니다.

  1. 잘못된 보안 인식 공격자가 Element.prototype.attachShadow를 가로채는 것을 막을 수 있는 방법은 없습니다.

  2. 비공개 모드는 커스텀 요소 코드가 자체 shadow DOM에 액세스하지 못하도록 합니다. 완전히 실패한 거죠. 대신 querySelector()와 같은 항목을 사용하려면 나중에 사용할 수 있도록 참조를 보관해야 합니다. 이는 비공개 모드의 원래 목적에 완전히 위배됩니다.

        customElements.define('x-element', class extends HTMLElement {
        constructor() {
        super(); // always call super() first in the constructor.
        this._shadowRoot = this.attachShadow({mode: 'closed'});
        this._shadowRoot.innerHTML = '<div class="wrapper"></div>';
        }
        connectedCallback() {
        // When creating closed shadow trees, you'll need to stash the shadow root
        // for later if you want to use it again. Kinda pointless.
        const wrapper = this._shadowRoot.querySelector('.wrapper');
        }
        ...
    });
    
  3. 비공개 모드를 사용하면 최종 사용자가 구성요소를 유연하게 사용할 수 없습니다. 웹 구성요소를 빌드하다 보면 기능을 추가하는 것을 잊어버릴 때가 있습니다. 구성 옵션입니다. 사용자가 원하는 사용 사례 일반적인 예로 내부 노드에 적절한 스타일 지정 후크를 포함하는 것을 잊어버리는 경우가 있습니다. 비공개 모드에서는 사용자가 기본값을 재정의하고 스타일을 조정할 수 없습니다. 구성요소의 내부에 액세스할 수 있다는 점이 매우 유용합니다. 결국 사용자는 원하는 작업을 수행하지 못하면 구성요소를 포크하거나 다른 구성요소를 찾거나 자체 구성요소를 만듭니다. :(

JS에서 슬롯 사용하기

섀도 DOM API는 슬롯 및 분산 노드 작업을 위한 유틸리티를 제공합니다. 이는 맞춤 요소를 작성할 때 유용합니다.

slotchange 이벤트

slotchange 이벤트는 슬롯의 분산 노드가 변경될 때 실행됩니다. 예를 들어 사용자가 라이트 DOM에서 하위 요소를 추가/삭제하는 경우입니다.

const slot = this.shadowRoot.querySelector('#slot');
slot.addEventListener('slotchange', e => {
    console.log('light dom children changed!');
});

라이트 DOM에 대한 다른 유형의 변경사항을 모니터링하려면 요소의 생성자에 MutationObserver를 설정하면 됩니다.

슬롯에 어떤 요소가 렌더링되고 있나요?

슬롯과 연결된 요소를 파악하는 것이 유용할 때가 있습니다. slot.assignedNodes()를 호출하여 슬롯이 렌더링하는 요소를 찾습니다. {flatten: true} 옵션은 슬롯의 대체 콘텐츠도 반환합니다 (노드가 배포되지 않는 경우).

예를 들어 shadow DOM이 다음과 같다고 가정해 보겠습니다.

<slot><b>fallback content</b></slot>
사용통화결과
<my-component>component text</my-component> slot.assignedNodes(); [component text]
<my-component></my-component> slot.assignedNodes(); []
<my-component></my-component> slot.assignedNodes({flatten: true}); [<b>fallback content</b>]

요소는 어떤 슬롯에 할당되나요?

반대의 질문에 답하는 것도 가능합니다. element.assignedSlot는 요소가 할당된 구성요소 슬롯을 알려줍니다.

섀도우 DOM 이벤트 모델

이벤트가 shadow DOM에서 버블링되면 shadow DOM이 제공하는 캡슐화를 유지하도록 타겟이 조정됩니다. 즉, 이벤트는 섀도 DOM 내의 내부 요소가 아닌 구성요소에서 발생한 것처럼 보이도록 재타겟팅됩니다. 일부 이벤트는 Shadow DOM 외부로 전파되지도 않습니다.

그림자 경계를 넘는 이벤트는 다음과 같습니다.

  • 포커스 이벤트: blur, focus, focusin, focusout
  • 마우스 이벤트: click, dblclick, mousedown, mouseenter, mousemove 등
  • 휠 이벤트: wheel
  • 입력 이벤트: beforeinput, input
  • 키보드 이벤트: keydown, keyup
  • 컴포지션 이벤트: compositionstart, compositionupdate, compositionend
  • DragEvent: dragstart, drag, dragend, drop 등

팁

섀도 트리가 열려 있으면 event.composedPath()를 호출하면 이벤트가 통과한 노드 배열이 반환됩니다.

맞춤 이벤트 사용하기

섀도우 트리의 내부 노드에서 실행되는 맞춤 DOM 이벤트는 composed: true 플래그를 사용하여 이벤트를 만들지 않는 한 섀도우 경계 밖으로 버블링되지 않습니다.

// Inside <fancy-tab> custom element class definition:
selectTab() {
    const tabs = this.shadowRoot.querySelector('#tabs');
    tabs.dispatchEvent(new Event('tab-select', {bubbles: true, composed: true}));
}

composed: false (기본값)인 경우 소비자는 shadow root 외부에서 이벤트를 수신할 수 없습니다.

<fancy-tabs></fancy-tabs>
<script>
    const tabs = document.querySelector('fancy-tabs');
    tabs.addEventListener('tab-select', e => {
    // won't fire if `tab-select` wasn't created with `composed: true`.
    });
</script>

포커스 처리

섀도 DOM의 이벤트 모델에서 기억한다면 섀도 DOM 내에서 실행되는 이벤트는 호스팅 요소에서 오는 것처럼 보이도록 조정됩니다. 예를 들어 shadow root 내에서 <input>를 클릭한다고 가정해 보겠습니다.

<x-focus>
    #shadow-root
    <input type="text" placeholder="Input inside shadow dom">

focus 이벤트는 <input>가 아닌 <x-focus>에서 발생한 것처럼 보입니다. 마찬가지로 document.activeElement은 <x-focus>입니다. 섀도우 루트가 mode:'open'로 생성된 경우(비공개 모드 참고) 포커스를 얻은 내부 노드에도 액세스할 수 있습니다.

document.activeElement.shadowRoot.activeElement // only works with open mode.

여러 수준의 shadow DOM이 사용 중인 경우 (예: 다른 맞춤 요소 내의 맞춤 요소) shadow 루트로 재귀적으로 드릴다운하여 activeElement를 찾아야 합니다.

function deepActiveElement() {
    let a = document.activeElement;
    while (a && a.shadowRoot && a.shadowRoot.activeElement) {
    a = a.shadowRoot.activeElement;
    }
    return a;
}

포커스의 또 다른 옵션은 delegatesFocus: true 옵션으로, 이 옵션은 shadow 트리 내 요소의 포커스 동작을 확장합니다.

  • 섀도 DOM 내의 노드를 클릭하고 노드가 포커스 가능 영역이 아닌 경우 포커스 가능 영역 중 첫 번째 영역에 포커스가 설정됩니다.
  • shadow DOM 내의 노드가 포커스를 얻으면 :focus가 포커스가 있는 요소 외에도 호스트에 적용됩니다.

예 - delegatesFocus: true가 포커스 동작을 변경하는 방법

<style>
    :focus {
    outline: 2px solid red;
    }
</style>

<x-focus></x-focus>

<script>
customElements.define('x-focus', class extends HTMLElement {
    constructor() {
    super(); // always call super() first in the constructor.

    const root = this.attachShadow({mode: 'open', delegatesFocus: true});
    root.innerHTML = `
        <style>
        :host {
            display: flex;
            border: 1px dotted black;
            padding: 16px;
        }
        :focus {
            outline: 2px solid blue;
        }
        </style>
        <div>Clickable Shadow DOM text</div>
        <input type="text" placeholder="Input inside shadow dom">`;

    // Know the focused element inside shadow DOM:
    this.addEventListener('focus', function(e) {
        console.log('Active element (inside shadow dom):',
                    this.shadowRoot.activeElement);
    });
    }
});
</script>

결과

delegatesFocus: true 동작

위는 <x-focus>에 포커스가 있거나 (사용자 클릭, 탭, focus() 등) 'Clickable Shadow DOM text'를 클릭하거나 내부 <input>에 포커스가 있는 경우(autofocus 포함)의 결과입니다.

delegatesFocus: false를 설정하면 다음과 같이 표시됩니다.

delegatesFocus: false이고 내부 입력에 포커스가 있습니다.
delegatesFocus: false 및 내부 <input>에 포커스가 있습니다.
delegatesFocus: false이고 x-focus가 포커스를 얻습니다 (예: tabindex='0').
delegatesFocus: false 및 <x-focus>가 포커스를 얻습니다 (예: tabindex="0"가 있음).
delegatesFocus: false이고 'Clickable Shadow DOM text'를 클릭합니다 (또는 요소의 Shadow DOM 내에 있는 다른 빈 영역을 클릭함).
delegatesFocus: false 및 'Clickable Shadow DOM text'를 클릭합니다 (또는 요소의 Shadow DOM 내에 있는 다른 빈 영역을 클릭함).

도움말 및 유용한 정보

수년에 걸쳐 웹 구성요소 작성에 대해 몇 가지를 배웠습니다. 구성요소를 작성하고 Shadow DOM을 디버깅하는 데 유용한 몇 가지 도움말을 확인해 보세요.

CSS 격리 사용

일반적으로 웹 구성요소의 레이아웃/스타일/페인트는 상당히 독립적입니다. :host에서 CSS 포함을 사용하여 성능을 개선합니다.

<style>
:host {
    display: block;
    contain: content; /* Boom. CSS containment FTW. */
}
</style>

상속 가능한 스타일 재설정

상속 가능한 스타일 (background, color, font, line-height 등)은 Shadow DOM에서 계속 상속됩니다. 즉, 기본적으로 섀도우 DOM 경계를 통과합니다. 새로 시작하려면 all: initial;를 사용하여 상속 가능한 스타일이 그림자 경계를 넘을 때 초기 값으로 재설정합니다.

<style>
    div {
    padding: 10px;
    background: red;
    font-size: 25px;
    text-transform: uppercase;
    color: white;
    }
</style>

<div>
    <p>I'm outside the element (big/white)</p>
    <my-element>Light DOM content is also affected.</my-element>
    <p>I'm outside the element (big/white)</p>
</div>

<script>
const el = document.querySelector('my-element');
el.attachShadow({mode: 'open'}).innerHTML = `
    <style>
    :host {
        all: initial; /* 1st rule so subsequent properties are reset. */
        display: block;
        background: white;
    }
    </style>
    <p>my-element: all CSS properties are reset to their
        initial value using <code>all: initial</code>.</p>
    <slot></slot>
`;
</script>

페이지에서 사용하는 모든 맞춤 요소 찾기

페이지에서 사용되는 맞춤 요소를 찾는 것이 유용한 경우도 있습니다. 이렇게 하려면 페이지에 사용된 모든 요소의 섀도우 DOM을 재귀적으로 순회해야 합니다.

const allCustomElements = [];

function isCustomElement(el) {
    const isAttr = el.getAttribute('is');
    // Check for <super-button> and <button is="super-button">.
    return el.localName.includes('-') || isAttr && isAttr.includes('-');
}

function findAllCustomElements(nodes) {
    for (let i = 0, el; el = nodes[i]; ++i) {
    if (isCustomElement(el)) {
        allCustomElements.push(el);
    }
    // If the element has shadow DOM, dig deeper.
    if (el.shadowRoot) {
        findAllCustomElements(el.shadowRoot.querySelectorAll('*'));
    }
    }
}

findAllCustomElements(document.querySelectorAll('*'));

<template>에서 요소 만들기

.innerHTML를 사용하여 shadow root를 채우는 대신 선언적 <template>를 사용할 수 있습니다. 템플릿은 웹 구성요소의 구조를 선언하는 데 이상적인 자리표시자입니다.

'커스텀 요소: 재사용 가능한 웹 구성요소 빌드'의 예를 참고하세요.

기록 및 브라우저 지원

지난 몇 년 동안 웹 구성요소를 사용해 왔다면 Chrome 35+/Opera에서 한동안 이전 버전의 Shadow DOM을 제공해 왔다는 사실을 알고 있을 것입니다. Blink는 당분간 두 버전을 모두 병렬로 지원할 예정입니다. v0 사양은 섀도 루트를 만드는 다른 메서드(v1의 element.attachShadow 대신 element.createShadowRoot)를 제공했습니다. 이전 메서드를 호출하면 v0 시맨틱스로 섀도 루트가 계속 생성되므로 기존 v0 코드가 중단되지 않습니다.

이전 v0 사양에 관심이 있다면 html5rocks 도움말(1, 2, 3)을 확인하세요. 섀도 DOM v0과 v1의 차이점을 비교한 훌륭한 자료도 있습니다.

브라우저 지원

Shadow DOM v1은 Chrome 53 (상태), Opera 40, Safari 10, Firefox 63에서 제공됩니다. Edge는 개발을 시작했습니다.

섀도 DOM을 감지하려면 attachShadow가 있는지 확인합니다.

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

폴리필

브라우저 지원이 널리 제공될 때까지 shadydom 및 shadycss 폴리필은 v1 기능을 제공합니다. Shady DOM은 Shadow DOM의 DOM 범위 지정을 모방하고 shadycss는 CSS 맞춤 속성과 네이티브 API가 제공하는 스타일 범위 지정을 폴리필합니다.

폴리필을 설치합니다.

bower install --save webcomponents/shadydom
bower install --save webcomponents/shadycss

폴리필을 사용합니다.

function loadScript(src) {
    return new Promise(function(resolve, reject) {
    const script = document.createElement('script');
    script.async = true;
    script.src = src;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
    });
}

// Lazy load the polyfill if necessary.
if (!supportsShadowDOMV1) {
    loadScript('/bower_components/shadydom/shadydom.min.js')
    .then(e => loadScript('/bower_components/shadycss/shadycss.min.js'))
    .then(e => {
        // Polyfills loaded.
    });
} else {
    // Native shadow dom v1 support. Go to go!
}

스타일을 shim/scope하는 방법에 관한 안내는 https://github.com/webcomponents/shadycss#usage를 참고하세요.

결론

사상 처음으로 적절한 CSS 범위 지정, DOM 범위 지정, 진정한 컴포지션을 갖춘 API 프리미티브가 생겼습니다. 맞춤 요소와 같은 다른 웹 구성요소 API와 결합된 Shadow DOM은 해킹이나 <iframe>와 같은 이전의 번거로운 작업을 사용하지 않고도 진정으로 캡슐화된 구성요소를 작성하는 방법을 제공합니다.

오해하지 마세요. 섀도우 DOM은 확실히 복잡한 녀석입니다. 하지만 배울 가치가 있는 괴물입니다 시간을 들여 살펴보세요. 학습하고 질문하세요.

추가 자료

FAQ

지금 Shadow DOM v1을 사용할 수 있나요?

폴리필을 사용하면 가능합니다. 브라우저 지원을 참고하세요.

섀도 DOM은 어떤 보안 기능을 제공하나요?

섀도 DOM은 보안 기능이 아닙니다. CSS 범위를 지정하고 구성요소에서 DOM 트리를 숨기는 경량 도구입니다. 진정한 보안 경계를 원한다면 <iframe>를 사용하세요.

웹 구성요소는 shadow DOM을 사용해야 하나요?

아닙니다. 섀도 DOM을 사용하는 웹 구성요소를 만들 필요는 없습니다. 하지만 Shadow DOM을 사용하는 맞춤 요소를 작성하면 CSS 범위 지정, DOM 캡슐화, 컴포지션과 같은 기능을 활용할 수 있습니다.

개방형 그림자 루트와 폐쇄형 그림자 루트의 차이점은 무엇인가요?

비공개 shadow root를 참고하세요.