선언적 Shadow DOM

HTML에서 직접 Shadow DOM을 구현하고 사용하는 새로운 방법입니다.

선언적 Shadow DOM은 버전 90부터 Chrome에서 지원되는 표준 웹 플랫폼 기능입니다. 이 기능의 사양은 2023년에 변경되었으며 (shadowroot에서 shadowrootmode로의 이름 변경 포함) 이 기능의 모든 부분에 대한 최신 표준 버전은 Chrome 버전 124에 적용되었습니다.

Shadow DOMHTML 템플릿맞춤 요소로 완성된 세 가지 웹 구성요소 표준 중 하나입니다. Shadow DOM은 CSS 스타일의 범위를 특정 DOM 하위 트리로 지정하고 이 하위 트리를 문서의 나머지 부분에서 격리하는 방법을 제공합니다. <slot> 요소를 사용하면 맞춤 요소의 하위 요소를 그림자 트리 내에 삽입할 위치를 제어할 수 있습니다. 이러한 기능을 결합하면 내장 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을 제공해야 하며, 느린 연결이나 기기에서도 우수한 성능을 보장하기 위해 기본적인 자바스크립트 없는 환경을 제공하는 웹사이트도 있습니다.

이전에는 서버에서 생성된 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 패널의 규칙을 따릅니다. 예를 들어 {/6} 문자는 슬롯이 적용된 Light DOM 콘텐츠를 나타냅니다.

이를 통해 정적 HTML에서 Shadow DOM 캡슐화 및 슬롯 프로젝션의 이점을 얻을 수 있습니다. JavaScript는 섀도우 루트를 포함하여 전체 트리를 생성하는 데 필요하지 않습니다.

성분 수분 섭취

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

선언적 섀도우 루트가 포함된 HTML에서 업그레이드되는 맞춤 요소에는 이미 해당 섀도우 루트가 연결되어 있습니다. 즉, 코드에서 명시적으로 속성을 생성하지 않아도 요소가 인스턴스화될 때 이미 shadowRoot 속성을 사용할 수 있습니다. this.shadowRoot에서 요소의 생성자에 기존 섀도우 루트를 확인하는 것이 가장 좋습니다. 이미 값이 있는 경우 이 구성요소의 HTML에는 선언적 섀도우 루트가 포함됩니다. 값이 null이면 HTML에 선언적 섀도우 루트가 없거나 브라우저가 선언적 섀도우 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에는 이러한 경우에도 기존 구성요소가 작동할 수 있도록 약간의 변경사항이 포함되어 있습니다. 기존 선언적 그림자 루트가 있는 요소에서 attachShadow() 메서드를 호출해도 오류가 발생하지 않습니다. 대신 선언적 섀도우 루트가 비우고 반환됩니다. 이렇게 하면 선언적 Shadow 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>에서 초기화할 수 없다는 것입니다. 하지만 선언적 Shadow 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 파서의 기능입니다. 즉, 선언적 섀도우 루트는 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의 도입으로 맞춤 요소를 HTML로 렌더링하거나 작성하여 클라이언트 측 구성요소 구현이 로드되기 전에 그림자 콘텐츠가 제자리에 있고 준비되도록 할 수 있습니다.

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

이 경우 display:none 'FOUC' 규칙은 선언적 섀도우 루트의 콘텐츠가 표시되지 않도록 합니다. 하지만 이 규칙을 삭제하면 선언적 Shadow DOM이 지원되지 않는 브라우저에 선언적 Shadow DOM polyfill이 로드되어 섀도우 루트 템플릿을 실제 섀도 루트로 변환할 때까지 부정확하거나 스타일이 지정되지 않은 콘텐츠가 표시됩니다.

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

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

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

기능 감지 및 브라우저 지원

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

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

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

폴리필

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

(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);

추가 자료