선언적 Shadow DOM

선언적 Shadow DOM은 표준 웹 플랫폼 기능으로, Chrome 버전 90부터 지원되었습니다. 이 기능의 사양은 2023년에 변경되었으며 (shadowroot의 이름이 shadowrootmode으로 변경됨) 이 기능의 모든 부분에 관한 최신 표준화된 버전은 Chrome 버전 124에 제공되었습니다.

브라우저 지원

  • Chrome: 111.
  • Edge: 111.
  • Firefox: 123
  • Safari: 16.4.

소스

Shadow DOMHTML 템플릿맞춤 요소와 함께 세 가지 웹 구성요소 표준 중 하나입니다. Shadow DOM은 CSS 스타일의 범위를 특정 DOM 하위 트리로 지정하고 해당 하위 트리를 나머지 문서에서 격리하는 방법을 제공합니다. <slot> 요소를 사용하면 맞춤 요소의 하위 요소가 Shadow Tree 내에 삽입되는 위치를 제어할 수 있습니다. 이러한 기능을 결합하면 내장 HTML 요소처럼 기존 애플리케이션에 원활하게 통합되는 자체 포함된 재사용 가능한 구성요소를 빌드하는 시스템을 만들 수 있습니다.

지금까지 Shadow DOM을 사용하는 유일한 방법은 JavaScript를 사용하여 섀도 루트를 생성하는 것이었습니다.

const host = document.getElementById('host');
const shadowRoot = host.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>';

이와 같은 명령형 API는 클라이언트 측 렌더링에 적합합니다. 맞춤 요소를 정의하는 동일한 JavaScript 모듈이 그림자 루트를 만들고 콘텐츠를 설정합니다. 그러나 많은 웹 애플리케이션은 빌드 시간에 콘텐츠를 서버 측에서 렌더링하거나 정적 HTML로 렌더링해야 합니다. 이는 JavaScript를 실행할 수 없는 방문자에게 적절한 환경을 제공하는 데 중요한 부분일 수 있습니다.

서버 측 렌더링 (SSR)의 근거는 프로젝트마다 다릅니다. 일부 웹사이트는 접근성 가이드라인을 준수하기 위해 완전한 기능을 갖춘 서버 렌더링 HTML을 제공해야 하지만, 느린 연결이나 기기에서 우수한 성능을 보장하기 위해 JavaScript가 없는 기본 환경을 제공하는 웹사이트도 있습니다.

이전에는 서버에서 생성된 HTML에서 섀도우 루트를 표현하는 기본 제공 방법이 없으므로 Shadow DOM을 서버 측 렌더링과 함께 사용하기 어려웠습니다. 이미 섀도우 루트 없이 렌더링된 DOM 요소에 섀도우 루트를 연결할 때도 성능에 영향을 미칩니다. 이로 인해 페이지가 로드된 후 레이아웃이 전환되거나 섀도우 루트의 스타일시트를 로드하는 동안 스타일이 지정되지 않은 콘텐츠가 일시적으로 표시될 수 있습니다 ('FOUC').

선언적 Shadow DOM (DSD)은 이 제한을 제거하여 Shadow DOM을 서버로 가져옵니다.

선언적 그림자 루트를 빌드하는 방법

선언적 섀도우 루트는 shadowrootmode 속성이 있는 <template> 요소입니다.

<host-element>
  <template shadowrootmode="open">
    <slot></slot>
  </template>
  <h2>Light content</h2>
</host-element>

shadowrootmode 속성이 있는 템플릿 요소는 HTML 파서에 의해 감지되고 즉시 상위 요소의 섀도우 루트로 적용됩니다. 위 샘플에서 순수 HTML 마크업을 로드하면 다음과 같은 DOM 트리가 생성됩니다.

<host-element>
  #shadow-root (open)
  <slot>
    ↳
    <h2>Light content</h2>
  </slot>
</host-element>

이 코드 샘플은 Shadow DOM 콘텐츠를 표시하기 위한 Chrome DevTools Elements 패널의 규칙을 따릅니다. 예를 들어 문자는 슬롯이 지정된 Light DOM 콘텐츠를 나타냅니다.

이렇게 하면 정적 HTML에서 Shadow DOM의 캡슐화 및 슬롯 프로젝션의 이점을 누릴 수 있습니다. 그림자 루트를 포함한 전체 트리를 생성하는 데 JavaScript가 필요하지 않습니다.

구성요소 하이드레이션

선언적 Shadow DOM은 스타일을 캡슐화하거나 하위 요소 배치를 맞춤설정하는 방법으로 단독으로 사용할 수 있지만 Custom Elements와 함께 사용하면 가장 강력합니다. 맞춤 요소를 사용하여 빌드된 구성요소는 정적 HTML에서 자동으로 업그레이드됩니다. 선언적 Shadow DOM이 도입됨에 따라 이제 맞춤 요소가 업그레이드되기 전에 섀도우 루트를 가질 수 있습니다.

선언적 섀도 루트가 포함된 HTML에서 업그레이드되는 맞춤 요소에는 이미 해당 섀도 루트가 연결되어 있습니다. 즉, 코드에서 명시적으로 만들지 않아도 요소가 인스턴스화될 때 이미 사용할 수 있는 shadowRoot 속성이 있습니다. 요소의 생성자에서 this.shadowRoot에 기존 섀도우 루트가 있는지 확인하는 것이 가장 좋습니다. 이미 값이 있는 경우 이 구성요소의 HTML에는 선언적 그림자 루트가 포함됩니다. 값이 null이면 HTML에 선언적 Shadow Root가 없거나 브라우저에서 선언적 Shadow DOM을 지원하지 않는 것입니다.

<menu-toggle>
  <template shadowrootmode="open">
    <button>
      <slot></slot>
    </button>
  </template>
  Open Menu
</menu-toggle>
<script>
  class MenuToggle extends HTMLElement {
    constructor() {
      super();

      // Detect whether we have SSR content already:
      if (this.shadowRoot) {
        // A Declarative Shadow Root exists!
        // wire up event listeners, references, etc.:
        const button = this.shadowRoot.firstElementChild;
        button.addEventListener('click', toggle);
      } else {
        // A Declarative Shadow Root doesn't exist.
        // Create a new shadow root and populate it:
        const shadow = this.attachShadow({mode: 'open'});
        shadow.innerHTML = `<button><slot></slot></button>`;
        shadow.firstChild.addEventListener('click', toggle);
      }
    }
  }

  customElements.define('menu-toggle', MenuToggle);
</script>

맞춤 요소는 오래 전부터 사용되어 왔으며 지금까지는 attachShadow()를 사용하여 섀도우 루트를 만들기 전에 기존 섀도우 루트를 확인할 이유가 없었습니다. 선언적 Shadow DOM에는 기존 구성요소가 작동하도록 하는 작은 변경사항이 포함되어 있습니다. 기존 선언적 Shadow Root가 있는 요소에서 attachShadow() 메서드를 호출해도 오류가 발생하지 않습니다. 대신 선언적 섀도우 루트가 비워지고 반환됩니다. 이렇게 하면 선언적 섀도우 DOM용으로 빌드되지 않은 이전 구성요소가 계속 작동할 수 있습니다. 명령형 대체가 생성될 때까지 선언적 루트가 보존되기 때문입니다.

새로 만든 맞춤 요소의 경우 새 ElementInternals.shadowRoot 속성을 사용하면 열려 있거나 닫혀 있는 요소의 기존 선언적 그림자 루트에 대한 참조를 가져오는 명시적인 방법을 사용할 수 있습니다. 이를 사용하여 선언적 그림자 루트를 확인하고 사용할 수 있으며, 선언적 그림자 루트가 제공되지 않은 경우에는 attachShadow()로 대체됩니다.

class MenuToggle extends HTMLElement {
  constructor() {
    super();

    const internals = this.attachInternals();

    // check for a Declarative Shadow Root:
    let shadow = internals.shadowRoot;

    if (!shadow) {
      // there wasn't one. create a new Shadow Root:
      shadow = this.attachShadow({
        mode: 'open'
      });
      shadow.innerHTML = `<button><slot></slot></button>`;
    }

    // in either case, wire up our event listener:
    shadow.firstChild.addEventListener('click', toggle);
  }
}

customElements.define('menu-toggle', MenuToggle);

루트당 그림자 1개

선언적 그림자 루트는 상위 요소와만 연결됩니다. 즉, 그림 루트는 항상 연결된 요소와 함께 배치됩니다. 이 설계 결정을 통해 그림자 루트는 나머지 HTML 문서와 마찬가지로 스트리밍할 수 있습니다. 요소에 그림자 루트를 추가할 때 기존 그림자 루트의 레지스트리를 유지할 필요가 없으므로 작성 및 생성에도 편리합니다.

그림자 루트를 상위 요소와 연결하면 동일한 선언적 그림자 루트 <template>에서 여러 요소를 초기화할 수 없다는 단점이 있습니다. 그러나 각 섀도우 루트의 콘텐츠가 동일하지 않은 경우가 많으므로 선언적 섀도우 DOM이 사용되는 대부분의 경우 이는 중요하지 않습니다. 서버에서 렌더링된 HTML에는 반복되는 요소 구조가 포함되는 경우가 많지만 콘텐츠는 일반적으로 다릅니다(예: 텍스트 또는 속성의 약간의 변형). 직렬화된 선언적 그림자 루트의 콘텐츠는 완전히 정적이므로 단일 선언적 그림자 루트에서 여러 요소를 업그레이드하는 것은 요소가 동일한 경우에만 작동합니다. 마지막으로, 유사한 섀도우 루트가 반복적으로 네트워크 전송 크기에 미치는 영향은 압축 효과로 인해 상대적으로 작습니다.

향후 공유된 그림자 루트를 다시 살펴볼 수 있습니다. DOM이 기본 제공 템플릿을 지원하게 되면 선언적 섀도우 루트는 특정 요소의 섀도우 루트를 생성하기 위해 인스턴스화되는 템플릿으로 취급될 수 있습니다. 현재 선언적 Shadow DOM 설계는 섀도 루트 연결을 단일 요소로 제한하여 향후 이러한 가능성을 허용합니다.

스트리밍이 멋진 이유

선언적 섀도우 루트를 상위 요소와 직접 연결하면 해당 요소에 섀도우 루트를 업그레이드하고 연결하는 프로세스가 간소화됩니다. 선언적 그림자 루트는 HTML 파싱 중에 감지되며 시작 <template> 태그가 발견되면 즉시 연결됩니다. <template> 내에서 파싱된 HTML은 섀도우 루트로 직접 파싱되므로 수신되는 대로 렌더링되며 '스트리밍'할 수 있습니다.

<div id="el">
  <script>
    el.shadowRoot; // null
  </script>

  <template shadowrootmode="open">
    <!-- shadow realm -->
  </template>

  <script>
    el.shadowRoot; // ShadowRoot
  </script>
</div>

파서 전용

선언적 Shadow DOM은 HTML 파서의 기능입니다. 즉, 선언적 Shadow Root는 HTML 파싱 중에 있는 shadowrootmode 속성이 있는 <template> 태그에 대해서만 파싱되고 연결됩니다. 즉, 선언적 그림 루트는 초기 HTML 파싱 중에 생성될 수 있습니다.

<some-element>
  <template shadowrootmode="open">
    shadow root content for some-element
  </template>
</some-element>

<template> 요소의 shadowrootmode 속성을 설정해도 아무 일도 일어나지 않으며 템플릿은 일반 템플릿 요소로 유지됩니다.

const div = document.createElement('div');
const template = document.createElement('template');
template.setAttribute('shadowrootmode', 'open'); // this does nothing
div.appendChild(template);
div.shadowRoot; // null

또한 몇 가지 중요한 보안 고려사항을 피하기 위해 innerHTML 또는 insertAdjacentHTML()와 같은 프래그먼트 파싱 API를 사용하여 선언적 그림자 루트를 만들 수도 없습니다. 선언적 섀도우 루트가 적용된 HTML을 파싱하는 유일한 방법은 setHTMLUnsafe() 또는 parseHTMLUnsafe()를 사용하는 것입니다.

<script>
  const html = `
    <div>
      <template shadowrootmode="open"></template>
    </div>
  `;
  const div = document.createElement('div');
  div.innerHTML = html; // No shadow root here
  div.setHTMLUnsafe(html); // Shadow roots included
  const newDocument = Document.parseHTMLUnsafe(html); // Also here
</script>

스타일을 사용한 서버 렌더링

인라인 및 외부 스타일시트는 표준 <style><link> 태그를 사용하여 선언적 그림자 루트 내에서 완전히 지원됩니다.

<nineties-button>
  <template shadowrootmode="open">
    <style>
      button {
        color: seagreen;
      }
    </style>
    <link rel="stylesheet" href="/comicsans.css" />
    <button>
      <slot></slot>
    </button>
  </template>
  I'm Blue
</nineties-button>

이 방법으로 지정된 스타일도 매우 최적화됩니다. 동일한 스타일 시트가 여러 선언적 그림자 루트에 있는 경우 한 번만 로드되고 파싱됩니다. 브라우저는 모든 그림자 루트에서 공유되는 단일 백킹 CSSStyleSheet를 사용하여 중복 메모리 오버헤드를 제거합니다.

구성 가능한 스타일시트는 선언적 Shadow DOM에서 지원되지 않습니다. 이는 현재 HTML에서 생성 가능한 스타일시트를 직렬화할 방법이 없고 adoptedStyleSheets를 채울 때 이를 참조할 방법이 없기 때문입니다.

스타일이 지정되지 않은 콘텐츠가 깜박이는 것을 방지하는 방법

아직 선언적 Shadow DOM을 지원하지 않는 브라우저에서 발생할 수 있는 한 가지 문제는 아직 업그레이드되지 않은 맞춤 요소에 원시 콘텐츠가 표시되는 '스타일이 적용되지 않은 콘텐츠 플래시' (FOUC)를 방지하는 것입니다. 선언적 Shadow DOM 이전에는 FOUC를 방지하기 위한 일반적인 기법 중 하나가 아직 로드되지 않은 맞춤 요소에 display:none 스타일 규칙을 적용하는 것이었습니다. 이러한 맞춤 요소에는 섀도 루트가 연결되고 채워지지 않았기 때문입니다. 이렇게 하면 콘텐츠가 '준비'될 때까지 표시되지 않습니다.

<style>
  x-foo:not(:defined) > * {
    display: none;
  }
</style>

선언적 Shadow DOM이 도입됨에 따라 클라이언트 측 구성요소 구현이 로드되기 전에 Shadow DOM 콘텐츠가 제자리에 있고 준비되도록 맞춤 요소를 HTML로 렌더링하거나 작성할 수 있습니다.

<x-foo>
  <template shadowrootmode="open">
    <style>h2 { color: blue; }</style>
    <h2>shadow content</h2>
  </template>
</x-foo>

이 경우 display:none 'FOUC' 규칙으로 인해 선언적 그림자 루트의 콘텐츠가 표시되지 않습니다. 그러나 이 규칙을 삭제하면 선언적 섀도우 DOM 폴리필이 섀도우 루트 템플릿을 실제 섀도우 루트로 로드하고 변환할 때까지 선언적 섀도우 DOM 지원이 없는 브라우저에서 잘못되거나 스타일이 지정되지 않은 콘텐츠가 표시됩니다.

다행히 FOUC 스타일 규칙을 수정하여 CSS에서 이 문제를 해결할 수 있습니다. 선언적 Shadow DOM을 지원하는 브라우저에서는 <template shadowrootmode> 요소가 즉시 섀도우 루트로 변환되어 DOM 트리에 <template> 요소가 남지 않습니다. 선언적 Shadow DOM을 지원하지 않는 브라우저는 <template> 요소를 보존하며, 이를 사용하여 FOUC를 방지할 수 있습니다.

<style>
  x-foo:not(:defined) > template[shadowrootmode] ~ *  {
    display: none;
  }
</style>

수정된 'FOUC' 규칙은 아직 정의되지 않은 맞춤 요소를 숨기는 대신 <template shadowrootmode> 요소 뒤에 오는 하위 요소를 숨깁니다. 맞춤 요소가 정의되면 규칙이 더 이상 일치하지 않습니다. HTML 파싱 중에 <template shadowrootmode> 하위 요소가 삭제되므로 선언적 Shadow DOM을 지원하는 브라우저에서는 이 규칙이 무시됩니다.

기능 감지 및 브라우저 지원

선언적 Shadow DOM은 Chrome 90 및 Edge 91부터 사용할 수 있었지만 표준화된 shadowrootmode 속성 대신 이전의 비표준 속성인 shadowroot를 사용했습니다. 최신 shadowrootmode 속성과 스트리밍 동작은 Chrome 111 및 Edge 111에서 사용할 수 있습니다.

선언적 Shadow DOM은 새로운 웹 플랫폼 API이므로 아직 모든 브라우저에서 광범위하게 지원되지는 않습니다. 브라우저 지원은 HTMLTemplateElement의 프로토타입에 shadowRootMode 속성이 있는지 확인하여 감지할 수 있습니다.

function supportsDeclarativeShadowDOM() {
  return HTMLTemplateElement.prototype.hasOwnProperty('shadowRootMode');
}

폴리필

선언적 Shadow DOM용으로 간소화된 폴리필을 빌드하는 것은 비교적 간단합니다. 폴리필은 브라우저 구현에서 우려하는 타이밍 시맨틱스 또는 파서 전용 특성을 완벽하게 복제할 필요가 없기 때문입니다. 선언적 Shadow DOM을 폴리필하려면 DOM을 스캔하여 모든 <template shadowrootmode> 요소를 찾은 다음 상위 요소의 연결된 Shadow Root로 변환하면 됩니다. 이 프로세스는 문서가 준비된 후에 실행되거나 맞춤 요소 수명 주기와 같은 더 구체적인 이벤트에 의해 트리거될 수 있습니다.

(function attachShadowRoots(root) {
  root.querySelectorAll("template[shadowrootmode]").forEach(template => {
    const mode = template.getAttribute("shadowrootmode");
    const shadowRoot = template.parentNode.attachShadow({ mode });

    shadowRoot.appendChild(template.content);
    template.remove();
    attachShadowRoots(shadowRoot);
  });
})(document);

추가 자료