선언적 섀도 돔(Declarative Shadow DOM)


원문 : Declarative Shadow DOM

HTML에서 직접 섀도 돔을 구현하고 사용하는 새로운 방법이다.


★ 선언적 섀도 돔은 크롬팀에서 피드백을 기다리고 있는 제안된 웹 플랫폼 기능이다. 실험용 플래그 또는 폴리필을 이용하여 선언적 섀도 돔을 사용해보자.

섀도 돔HTML 템플릿커스텀 엘리먼트(Custom Elements)와 함께 웹 컴포넌트 표준을 이루는 세 가지 중 하나이다. 섀도 돔은 특정 DOM 서브트리의 CSS 스타일 적용 범위를 정하고, 도큐먼트의 나머지 부분과 분리하는 방법을 제공한다. <slot>요소는 커스텀 엘리먼트에 작성된 자식 요소들이 섀도 트리 내 어느 위치에 삽입되어야 하는지 정해줄 수 있다. 이러한 기능들을 결합하여 내장(built-in) HTML 요소처럼 기존 애플리케이션에 쉽게 추가하여 사용할 수 있으며, 독립적인 자체 구현으로써, 재사용 가능한 컴포넌트를 구축할 수 있다.

이전까지 섀도 돔을 사용하는 유일한 방법은 자바스크립트를 사용하여 섀도 루트를 생성하는 것이었다.

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

이러한 명령형(imperative) API는 클라이언트 사이드 렌더링에서 잘 동작한다. 커스텀 엘리먼트를 정의하는 자바스크립트 모듈을 작성할 때도 섀도 루트를 생성하고 콘텐츠를 설정할 수 있다. 그러나 많은 웹 애플리케이션은 빌드 시 콘텐츠를 서버 사이드 또는 정적 HTML로 렌더링 해야 한다. 만약 웹 애플리케이션 이용자가 자바스크립트를 실행할 수 없는 환경을 가졌다면, 괜찮은 경험을 제공하는데 중요한 부분이 될 수 있다.

서버 사이드 렌더링(SSR)을 사용해야 하는 근거는 프로젝트마다 다르다. 일부 웹 사이트에서는 접근성 지침을 충족하기 위해 모든 기능이 동작하는 서버 렌더링 HTML을 제공해야 할 수 있다. 그 외의 웹 사이트들에서는 느린 네트워크 연결이나 열악한 디바이스 환경에서 좋은 성능을 보장하기 위한 방법으로 자바스크립트를 사용하지 않는 경험을 제공하기도 한다.

이전까지는 섀도 돔을 서버 사이드 렌더링과 함께 사용하기 힘들었다. 왜냐하면 서버에서 생성된 HTML에 섀도 루트를 표현할 수 있는 내장된 방식이 없었기 때문이다. 섀도 루트를 사용하지 않고 이미 렌더링 된 DOM 요소에 섀도 루트를 추가하면 성능에 영향을 미친다. 이로 인해 페이지가 로드된 후 레이아웃이 재배치(layout shifting)되거나 섀도 루트의 스타일 시트를 불러오는 동안 스타일이 적용되지 않은 콘텐츠('FOUC' - flash of unstyled content)가 일시적으로 표시될 수 있다.

선언적 섀도 돔(DSD - Declarative Shadow DOM)는 이러한 제한을 없애고, 섀도 돔을 서버로 가져와 사용할 수 있다.

선언적 섀도 루트 작성

선언적 섀도 루트는 <template> 요소의 shadowroot 속성을 사용한다.

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

shadowroot 속성이 정의된 template 요소는 HTML 파서에 의해 감지되고 즉시 부모 요소의 섀도 루트로 적용된다. 위 샘플 코드를 로드하면 아래 코드와 같은 DOM 트리가 생성된다.

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

★ 샘플 코드는 크롬 개발자 도구 Elements 패널의 컨벤션을 따른다. 예를 들어 문자는 슬롯이 있는 Light 돔 내용을 나타낸다.

섀도 돔의 캡슐화와 정적 HTML에서 슬롯 프로젝션(역자주: 부모 요소 내부에 작성한 HTML 요소를 특정 템플릿으로 전달할 수 있는 기능)의 이점을 제공한다. 섀도 루트를 포함한 전체 트리를 생성하는데 자바스크립트는 필요하지 않다.

직렬화(Serialization)

선언적 섀도 돔은 섀도 루트를 만들어 요소에 추가하기 위한 새로운 <template> 구문 외에도 요소의 HTML 콘텐츠를 가져오는 새로운 API도 포함한다. 새롭게 지원되는 getInnerHTML()메소드는 .innerHTML처럼 작동한다. 차이점은 반환된 HTML에 섀도 루트의 포함 여부를 제어하는 옵션을 제공한다.

const html = element.getInnerHTML({includeShadowRoots: true});
`<host-element>
  <template shadowroot="open"><slot></slot></template>
  <h2>Light content</h2>
</host-element>`;

includeShadowRoots : true 옵션을 전달하면 섀도 루트를 포함하여 요소의 전체 서브 트리가 직렬화된다. 포함된 섀도 루트는 <template shadowroot>구문을 사용하여 직렬화된다.

캡슐화 관점에서 보면, 요소 내의 닫힌 섀도 루트는 기본적으로 직렬화되지 않는다. 직렬화된 HTML에 닫힌 섀도 루트를 포함하려면 새롭게 지원하는 closedRoots 옵션을 사용해 섀도 루트에 대한 참조 배열을 전달할 수 있다.

const html = element.getInnerHTML({
  includeShadowRoots: true,
  closedRoots: [shadowRoot1, shadowRoot2, ...]
});

요소 내에서 HTML을 직렬화할 때 closedRoots에 설정한 닫힌 섀도 루트 배열은 열린(open) 섀도 루트와 동일하게 <template shadowroot> 구문을 사용하여 직렬화된다.

<host-element>
  <template shadowroot="closed">
    <slot></slot>
  </template>
  <h2>Light content</h2>
</host-element>

직렬화된 닫힌 섀도 루트는 shadowroot속성 값이 closed로 표시된다.

컴포넌트 하이드레이션(hydration)

(hydration : 역자주 - 정적인 컴포넌트를 동적으로 바꾸는 작업)

선언적 섀도 돔은 스타일을 캡슐화하거나 자식 요소의 배치를 지정해주는 방법으로 사용할 수 있지만, 커스텀 엘리먼트와 함께 사용할 때 가장 강력하다. 커스텀 엘리먼트를 사용하여 빌드 된 컴포넌트는 정적 HTML에서 자동으로 업그레이드된다. 선언적 섀도 돔을 사용하면 커스텀 엘리먼트가 업그레이드되기 전에 섀도 루트를 가질 수 있다.

선언적 섀도 루트를 포함하는 HTML에서 업그레이드되는 커스텀 엘리먼트는 이미 해당 섀도 루트가 붙여져 있다. 즉, 코드에서 명시적으로 작성해주지 않더라도 요소가 인스턴스화 되었을 때 이미 shadowRoot 속성을 가진다. 작성된 요소 생성자에는 기존 섀도 루트 여부는 this.shadowRoot로 확인해보는 것이 좋다. 이미 값이 있다면, 컴포넌트의 HTML은 선언적 섀도 루트를 포함하고 있다. 만약 값이 null이라면 HTML에 선언적 섀도 루트가 없거나 브라우저에서 선언적 섀도 돔을 지원하지 않는 것이다.

<menu-toggle>
  <template shadowroot="open">
    <button>
      <slot></slot>
    </button>
  </template>
  Open Menu
</menu-toggle>

<script>
  class MenuToggle extends HTMLElement {
    constructor() {
      super();

      // SSR 컨텐트 존재 여부 감지
      if (this.shadowRoot) {
        // 선언적 섀도 루트가 존재한다!
        // 예) 이벤트 리스너 등록과 참조를 걸어줄 수 있다.
        const button = this.shadowRoot.firstElementChild;
        button.addEventListener('click', toggle);
      } else {
        // 선언적 섀도 루트가 존재하지 않는다!
        // 새로운 섀도 루트를 만들어 추가한다.
        const shadow = this.attachShadow({mode: 'open'});
        shadow.innerHTML = `<button><slot></slot></button>`;
        shadow.firstChild.addEventListener('click', toggle);
      }
    }
  }

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

커스텀 엘리먼트는 한동안 사용되어 왔지만 지금까지 attachShadow()를 사용하여 섀도 루트를 만들기 전에 기존 섀도 루트를 확인할 이유가 없었다. 선언적 섀도 돔은 이러한 상황에서도 기존 구성 요소가 작동 할 수 있도록 하는 작은 변경 사항을 포함한다. 기존 선언적 섀도 루트가 있는 요소에 attachShadow() 메서드를 호출해도 오류가 발생하지 않는다. 대신 선언적 섀도 루트가 비워지고 반환된다. 이렇게 하면 섀도 루트는 다른 섀도가 붙여질 때까지 보존되기 때문에 선언적 섀도 돔 용으로 빌드되지 않은 이전 컴포넌트가 계속 동작할 수 있다.

커스텀 엘리먼트에서 지원되는 새로운 ElementInternals.shadowRoot 속성은 요소의 열리거나 닫힌 기존 선언적 섀도 루트에 대한 참조를 가져오는 명시적인 방법을 제공한다. 선언적 섀도 루트를 확인하는데 사용할 수 있으며, 제공되지 않은 경우에는 이전처럼 attachShadow()메서드를 활용한다.

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

    const internals = this.attachInternals();

    // 선언적 섀도 루트가 있는지 확인
    let shadow = internals.shadowRoot;
    if (!shadow) {
      // 섀도 루트가 없다면 새로운 섀도 루트를 생성
      shadow = this.attachShadow({mode: 'open'});
      shadow.innerHTML = `<button><slot></slot></button>`;
    }

    // 이미 섀도 루트가 있으니, 이벤트 리스너 연동
    shadow.firstChild.addEventListener('click', toggle);
  }
}
customElements.define('menu-toggle', MenuToggle);

루트 당 하나의 섀도

선언적 섀도 루트는 오직 부모 요소와 관련이 있다. 즉, 섀도 루트는 항상 관련된 요소와 함께 배치되어 작성된다. 이러한 디자인 형식은 섀도 루트가 나머지 HTML 도큐먼트와 부드럽게 이어지도록 한다. 또한 요소에 섀도 루트를 추가할 때 기존 섀도 루트의 레지스트리를 유지할 필요가 없기 때문에 제작 및 생성에도 편리하다.

부모 요소에 의존하여 섀도 루트를 작성하면 동일한 선언적 섀도 루트 <template>에서 여러 개의 요소를 초기화할 수 없다. 그러나 선언적 섀도 돔이 사용되는 대부분은 각 섀도 루트의 내용이 거의 동일하지 않기 때문에 문제가 되지 않는다. 서버에서 렌더링 된 HTML은 반복되는 요소 구조를 포함하고 있지만, 그 내용은 일반적으로 텍스트, 속성 등에서 약간의 차이가 있다. 직렬화된 선언적 섀도 루트의 내용은 완전히 정적이기 때문에 요소가 동일한 경우 단일 선언적 섀도 루트에서는 여러 요소를 업그레이드하는 것만 동작한다. 마지막으로, 반복되고 비슷한 섀도 루트가 네트워크 전송 크기에 미치는 영향은 압축 효과로 인해 상대적으로 작다.

앞으로는 공유된 섀도 루트를 다시 찾을 수 있다. 만약 돔이 내장 템플릿에 대한 지원을 얻게 된다면 선언적 섀도 루트는 주어진 요소에 대한 섀도 루트를 구성하기 위해 인스턴스화 되는 템플릿으로 처리될 수 있다. 현재의 선언적 섀도 돔 디자인은 섀도 루트 연결을 단일 요소로 제한하여 앞으로 사용될 가능성을 열어두었다.

타이밍이 중요하다

선언적 섀도 루트를 부모 요소와 직접 연결하여 작성하면 업그레이드 및 해당 요소에 붙여지는 과정이 간소화된다. 선언적 섀도 루트는 HTML 구문 분석 중에 감지되며 닫는 태그인 </template>를 만나면 즉시 붙여진다.

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

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

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

요소에 붙여지기 전에 shadowroot 속성이 있는 <template> 요소의 내용은 비활성 상태의 도큐먼트 조각이며 표준 템플릿처럼 .content 속성을 사용할 수 없다. 이러한 보안 조치는 자바스크립트가 닫힌 섀도 루트에 대한 참조를 얻을 수 없도록 한다. 결과적으로 선언적 섀도 루트의 내용은 닫는 </template> 태그가 구문 분석될 때까지 렌더링 되지 않는다.

<div>
  <template id="shadow" shadowroot="open">
    shadow realm
    <script>
      shadow.content; // null
    </script>
  </template>
</div>

파서 전용

선언적 섀도 돔은 HTML 파서의 기능이다. 즉, 선언적 섀도 루트는 HTML 구문 분석 중에 shadowroot속성을 가지는 <template> 태그에서만 구문 분석되어 연결된다. 선언적 섀도 루트는 초기 구문 분석되는 HTML로 작성하거나 innerHTML을 사용하여 생성될 수 있다.

<some-element>
  <template shadowroot="open">
    shadow realm
  </template>
</some-element>

<script>
  const div = document.createElement('div');
  div.innerHTML = `
    <some-element>
      <template shadowroot="open">
        shadow realm
      </template>
    </some-element>
  `;
</script>

이 외 다른 방법으로 작성하는 경우, <template> 요소의 shadowroot 속성을 설정해도 아무런 작업도 하지 않으며 일반적인 템플릿 요소로 남아있게 된다.

const div = document.createElement('div');
const template = document.createElement('template');
template.setAttribute('shadowroot', 'open'); // 아무런 작업도 일어나지 않는다
div.appendChild(template);
div.shadowRoot; // null

스타일 서버 렌더링

인라인 및 외부 스타일 시트는 표준 <style><link> 태그를 사용하여 선언적 섀도 루트 내에서 완벽하게 지원된다.

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

이러한 방식으로 지정된 스타일은 고도로 최적화된다. 동일한 스타일 시트가 여러 선언적 섀도 루트에 있으면 한 번만 로드되고 구문 분석된다. 브라우저는 모든 섀도 루트가 공유되는 단일 백업 CSSStyleSheet을 사용하여 중복된 메모리 오버헤드를 제거한다.

CSSStyleSheet 생성자를 이용하여 작성한 스타일 시트(Constructable Stylesheets)는 선언적 섀도 돔에서 지원되지 않는다. 이유는 현재 HTML에서 생성자를 이용해 작성한 스타일 시트를 직렬화 할 수 있는 방법이 없고 섀도 루트의 adoptedStyleSheets를 참조할 방법이 없기 때문이다.

기능 감지 및 브라우저 지원

크롬 팀은 Chrome 88에서 선언적 섀도 돔 플래그를 해제하는 것에 대해 잠정적으로 검토하고 있다. 그전까지는 Chrome 85의 실험적 웹 플랫폼 기능 플래그를 사용하여 섀도 돔 기능을 켤 수 있다. chrome://flags/#enable-experimental-web으로 이동하여 섀도 돔 기능을 설정해보자.

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

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

폴리필

선언적 섀도 돔을 위한 폴리필을 구현하는 것은 비교적 간단하다. 폴리필은 브라우저 구현과 관련된 타이밍 체계 또는 파서 전용 특성을 완벽하게 복제할 필요가 없기 때문이다. 선언적 섀도 돔을 폴리필하기 위해서는, 돔을 스캔하여 모든 <template shadowroot> 요소를 찾은 다음, 부모 요소에 연결된 섀도 루트로 변환 할 수 있다. 이 프로세스는 도큐먼트가 준비되면 완료되거나 커스텀 엘리먼트의 생명 주기와 같이 더 구체적인 이벤트에 의해 트리거 될 수 있다.

document.querySelectorAll('template[shadowroot]').forEach(template => {
  const mode = template.getAttribute('shadowroot');
  const shadowRoot = template.parentNode.attachShadow({ mode });
  shadowRoot.appendChild(template.content);
  template.remove();
}

더 읽어볼 거리

조정은2020.10.07
Back to list