선언적 Shadow DOM

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

선언형 Shadow DOM은 현재 표준화 과정에 있는 웹 플랫폼 기능입니다. 이 기능은 Chrome 버전 111에서 기본적으로 사용 설정되어 있습니다.

Shadow DOM은 세 가지 웹 구성요소 표준 중 하나로, HTML 템플릿맞춤 요소로 명시되어 있습니다. 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는 클라이언트 측 렌더링에 적합합니다. 맞춤 요소를 정의하는 동일한 자바스크립트 모듈도 그림자 루트를 만들고 콘텐츠를 설정합니다. 그러나 많은 웹 애플리케이션은 빌드 시 콘텐츠를 서버 측에서 렌더링하거나 정적 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 요소 패널의 규칙을 따릅니다. 예를 들어 브랜드 이름은 슬롯이 있는 Light DOM 콘텐츠를 나타냅니다.

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

구성요소 수화

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

선언적 그림자 루트가 포함된 HTML에서 업그레이드되는 맞춤 요소에는 이미 섀도우 루트가 연결되어 있습니다. 즉, 코드가 명시적으로 만들지 않아도 요소가 인스턴스화될 때 이미 shadowRoot 속성을 사용할 수 있습니다. 요소의 생성자에서 this.shadowRoot에 기존 섀도 루트가 있는지 확인하는 것이 가장 좋습니다. 이미 값이 있으면 이 구성요소의 HTML에 선언적 그림자 루트가 포함됩니다. 값이 null이면 HTML에 선언적 그림자 루트가 없거나 브라우저가 선언적 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에는 이러한 경우에도 기존 구성요소가 작동할 수 있도록 하는 작은 변경사항이 포함되어 있습니다. 기존 선언적 그림자 루트가 있는 요소에서 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을 파싱하는 유일한 방법은 새로운 includeShadowRoots 옵션을 DOMParser에 전달하는 것입니다.

<script>
  const html = `
    <div>
      <template shadowrootmode="open"></template>
    </div>
  `;
  const div = document.createElement('div');
  div.innerHTML = html; // No shadow root here
  const fragment = new DOMParser().parseFromString(html, 'text/html', {
    includeShadowRoots: true
  }); // Shadow root 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> 요소 뒤에 오는 하위 요소를 숨깁니다. 맞춤 요소가 정의되면 규칙이 더 이상 일치하지 않습니다. 이 규칙은 HTML 파싱 중에 <template shadowrootmode> 하위 요소가 삭제되므로 선언적 Shadow DOM을 지원하는 브라우저에서 무시됩니다.

기능 감지 및 브라우저 지원

선언적 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의 간소화된 polyfill을 빌드하는 것은 비교적 간단합니다. 폴리필이 브라우저 구현과 관련된 타이밍 의미 체계나 파서 전용 특성을 완벽하게 복제할 필요가 없기 때문입니다. 선언적 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);

추가 자료