Deklaracyjny DOM cienia

Deklaratywny Shadow DOM to standardowa funkcja platformy internetowej, która jest obsługiwana w Chrome od wersji 90. Uwaga: specyfikacja tej funkcji zmieniła się w 2023 r. (w tym zmieniono nazwę z shadowroot na shadowrootmode), a najnowsze ustandaryzowane wersje wszystkich części tej funkcji trafiły do Chrome w wersji 124.

Obsługa przeglądarek

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

Źródło

Shadow DOM to jeden z 3 standardów komponentów sieciowych, które są zaokrąglane przez szablony HTML i elementy niestandardowe. Shadow DOM umożliwia ograniczanie stylów CSS do konkretnego poddrzewa DOM i wyodrębnianie tego poddrzewa od reszty dokumentu. Element <slot> pozwala określić, gdzie w drzewie cienia mają zostać wstawione elementy podrzędne elementu niestandardowego. Dzięki połączeniu tych funkcji powstaje system tworzenia niezależnych komponentów wielokrotnego użytku, które płynnie integrują się z istniejącymi aplikacjami tak jak wbudowany element HTML.

Do tej pory jedynym sposobem korzystania z modelu Shadow DOM było utworzenie rdzenia cienia za pomocą JavaScriptu:

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

Taki bezwzględny interfejs API sprawdza się w przypadku renderowania po stronie klienta: te same moduły JavaScriptu, które definiują elementy niestandardowe, tworzą swoje korzenie cienia i ustalają treść. Jednak wiele aplikacji internetowych podczas kompilacji musi renderować treści po stronie serwera lub w trybie statycznego kodu HTML. Może to być ważny element zapewnienia satysfakcji użytkowników, którzy nie potrafią obsługiwać JavaScriptu.

Uzasadnienia renderowania po stronie serwera (SSR) są różne w zależności od projektu. Niektóre witryny muszą udostępniać w pełni funkcjonalny kod HTML renderowany przez serwer, aby zachować zgodność ze wskazówkami dotyczącymi ułatwień dostępu. Z kolei część bazuje na języku bez JavaScriptu, co ma na celu zapewnienie odpowiedniej wydajności w przypadku wolnych połączeń lub urządzeń.

Do tej pory trudno było używać modelu Shadow DOM w połączeniu z renderowaniem po stronie serwera, ponieważ nie mieliśmy wbudowanego sposobu wyrażania rdzeni cieni w generowanym przez serwer kodzie HTML. Dołączanie elementów Shadow Roots do elementów DOM, które zostały już wyrenderowane bez tych elementów, również wpływa na wydajność. Może to spowodować przesunięcie układu po załadowaniu strony lub tymczasowe wyświetlanie błysków bez stylu („FOUC”) podczas wczytywania arkuszy stylów Cienia.

Deklaratywny DOM Shadow (DSD) usuwa to ograniczenie, udostępniając na serwerze model Shadow DOM.

Jak zbudować deklaratywny korzeń cienia

Deklaracyjny pierwiastek cienia to element <template> z atrybutem shadowrootmode:

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

Element szablonu z atrybutem shadowrootmode jest wykrywany przez parser HTML i natychmiast stosowany jako pierwiastek cienia elementu nadrzędnego. Załadowanie czystego znacznika HTML z przykładu powyżej powoduje utworzenie takiego drzewa DOM:

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

Ten przykładowy kod jest zgodny z konwencjami panelu elementów w Narzędziach deweloperskich w Chrome dotyczącymi wyświetlania treści DOM. Na przykład znak reprezentuje treść Light DOM w boksach.

Dzięki temu możemy korzystać z zastosowania funkcji szyfrowania i odwzorowywania przedziałów w statycznym kodzie HTML w modelu Shadow DOM. Do wygenerowania całego drzewa, w tym korzenia cienia, nie jest potrzebny JavaScript.

Hydraulowanie składników

Deklaratywnej struktury cienia DOM można używać samodzielnie jako sposobu opisywania stylów lub dostosowywania położenia podrzędnego, ale najlepiej sprawdza się w przypadku elementów niestandardowych. Komponenty utworzone przy użyciu elementów niestandardowych są automatycznie uaktualniane ze statycznego kodu HTML. Dzięki wprowadzeniu deklaratywnego modelu cienia DOM element niestandardowy może mieć rdzenia cienia, zanim zostanie uaktualniony.

Uaktualniany z kodu HTML element niestandardowy, który zawiera deklaratywny pierwiastek cienia, będzie już miał dołączony ten element główny. Oznacza to, że element będzie miał już dostępną właściwość shadowRoot, gdy zostanie utworzony, bez konieczności jej jawnego tworzenia przez Twój kod. Najlepiej jest sprawdzić this.shadowRoot, czy w konstruktorze elementu nie ma już rdzenia cienia. Jeśli istnieje już wartość, kod HTML tego komponentu zawiera deklaratywny pierwiastek cienia. Jeśli wartość to null, w kodzie HTML nie było deklaratywnego rdzenia cienia lub przeglądarka nie obsługuje deklaratywnego DOM Shadow.

<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>

Elementy niestandardowe są dostępne już od jakiegoś czasu i do tej pory nie było powodu, aby sprawdzać istniejący katalog główny przed utworzeniem elementu za pomocą attachShadow(). Deklaracyjny model DOM Shadow wprowadza niewielką zmianę, która pozwala na działanie istniejących komponentów. Wywołanie metody attachShadow() w elemencie z deklaratywnym rdzeniem cienia nie powoduje zgłoszenia błędu. Zamiast tego deklaratywny pierwiastek cienia jest opróżniany i zwracany. Dzięki temu starsze komponenty, które nie zostały stworzone z myślą o deklaratywnym modelu Shadow DOM, będą nadal działać, ponieważ pierwiastki deklaratywne są zachowywane do momentu utworzenia imperatywnego zastąpienia.

W przypadku nowo utworzonych elementów niestandardowych nowa właściwość ElementInternals.shadowRoot pozwala w wyraźny sposób uzyskać odniesienie do istniejącego deklaratywnego rdzenia cienia elementu, zarówno otwartego, jak i zamkniętego. Pozwala to sprawdzić i wykorzystać deklaratywny pierwiastek cienia, a w przypadku, gdy go nie podano, nadal będzie używana wartość 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);

Jeden cień na pierwiastek

Deklaracyjny pierwiastek cienia jest powiązany tylko z jego elementem nadrzędnym. Oznacza to, że pierwiastki cienia są zawsze współlokowane z powiązanym z nimi elementem. Ta decyzja projektowa daje możliwość strumieniowego przesyłania danych głównych cieni tak samo jak reszta dokumentu HTML. Jest to również wygodne przy tworzeniu i generowaniu, ponieważ dodanie do elementu podstawowego poziomu cienia nie wymaga posiadania rejestru istniejących rdzeni cieni.

Zaletą powiązania pierwiastków cieni z ich elementem nadrzędnym jest to, że nie można zainicjować wielu elementów z tego samego deklaratywnego pierwiastka cienia <template>. Jest to jednak mało prawdopodobne, aby miało to znaczenie w większości przypadków, gdy używana jest deklaratywna architektura cienia, ponieważ zawartość każdego rdzenia cienia rzadko jest taka sama. Chociaż kod HTML renderowany przez serwer często zawiera powtarzające się struktury elementów, ich treść zasadniczo się różni – na przykład niewielkie różnice w tekście lub atrybutach. Ponieważ zawartość serializowanego deklaratywnego rdzenia cienia jest całkowicie statyczna, uaktualnienie wielu elementów z jednego deklaratywnego rdzenia cienia zadziała tylko wtedy, gdy elementy będą identyczne. I wreszcie, wpływ powtarzających się podobnych pierwiastków cieni na rozmiar transferu sieci jest stosunkowo niewielki ze względu na efekty kompresji.

W przyszłości może być możliwe ponowne przejście do współdzielonych rdzeni cieni. Jeśli model DOM obsługuje wbudowane szablony, deklaratywne rdzenie cienia mogą być traktowane jako szablony utworzone w celu utworzenia głównego źródła cienia dla danego elementu. Obecna konstrukcja deklaratywnego DOM Shadow stwarza taką możliwość w przyszłości, ograniczając powiązanie rdzenia cienia do pojedynczego elementu.

Strumieniowanie jest fajne

Powiązanie deklaratywnych korzeni cienia bezpośrednio z elementem nadrzędnym upraszcza proces uaktualniania i dołączenia ich do tego elementu. Deklaracyjne źródła cienia są wykrywane podczas analizy kodu HTML i dołączane natychmiast po napotkaniu ich otwierającego tagu <template>. Przetworzony kod HTML w elemencie <template> jest analizowany bezpośrednio w katalogu głównym, więc można go „strumieniowo” renderować.

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

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

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

Tylko parser

Deklaratywny DOM Shadow to funkcja parsera HTML. Oznacza to, że deklaratywny katalog cienia jest analizowany i dołączany tylko w przypadku tagów <template> z atrybutem shadowrootmode, który występuje podczas analizy kodu HTML. Inaczej mówiąc, deklaratywne rdzenie cienia można utworzyć podczas początkowej analizy kodu HTML:

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

Ustawienie atrybutu shadowrootmode elementu <template> nie powoduje żadnego efektu, a szablon pozostaje zwykłym elementem szablonu:

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

Aby uniknąć ważnych kwestii związanych z bezpieczeństwem, nie można też tworzyć deklaratywnych źródeł cienia przy użyciu interfejsów API analizy fragmentów, takich jak innerHTML czy insertAdjacentHTML(). Jedynym sposobem analizowania kodu HTML z zastosowanymi deklaratywnymi korzeniami cienia jest użycie właściwości setHTMLUnsafe() lub 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>

Renderowanie serwera ze stylem

Wbudowane i zewnętrzne arkusze stylów są w pełni obsługiwane w deklaratywnych źródłach cienia przy użyciu standardowych tagów <style> i <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>

Style określone w ten sposób są też wysoce zoptymalizowane: jeśli ten sam arkusz stylów występuje w kilku deklaratywnych korzeniach cienia, jest on ładowany i przeanalizowany tylko raz. Przeglądarka używa pojedynczego zapasowego obiektu CSSStyleSheet, który jest współużytkowany przez wszystkie rdzenie cieni, eliminuje to zduplikowane wykorzystanie pamięci.

Konstruowalne arkusze stylów nie są obsługiwane w deklaratywnym DOM. Dzieje się tak, ponieważ w tej chwili nie ma możliwości serializowania konstruowalnych arkuszy stylów w kodzie HTML ani możliwości odwoływania się do nich podczas wypełniania pola adoptedStyleSheets.

Jak uniknąć błyskawicznych treści bez stylu

Jednym z potencjalnych problemów w przeglądarkach, które nie obsługują jeszcze deklaratywnego modelu Shadow DOM, jest unikanie „flashowania niesformatowanych treści”. (FOUC), gdzie dla elementów niestandardowych, które nie zostały jeszcze uaktualnione, wyświetlana jest nieprzetworzona zawartość. Przed wprowadzeniem deklaratywnego DOM Shadow jedną z metod unikania FOUC było stosowanie reguły stylu display:none do elementów niestandardowych, które nie zostały jeszcze wczytane, ponieważ nie miały przypisanego i uzupełnionego źródła cienia. Dzięki temu zawartość nie będzie wyświetlana, dopóki nie będzie „gotowa”:

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

Dzięki wprowadzeniu deklaratywnego modelu Shadow DOM elementy niestandardowe można renderować i tworzyć w kodzie HTML, tak aby ich treść w cieniu znajdowała się na miejscu i była gotowa przed załadowaniem implementacji komponentu po stronie klienta:

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

W tym przypadku „FOUC” (display:none) zapobiegłaby wyświetlaniu treści z deklaratywnego rdzenia cienia. Usunięcie tej reguły spowodowałoby jednak, że przeglądarki bez obsługi deklaratywnej struktury cienia DOM będą wyświetlać nieprawidłowe lub niesformatowane treści, dopóki polyfill deklaratywnego DOM Shadow DOM nie załaduje i nie przekonwertuje szablonu cienia głównego na rzeczywisty katalog cienia.

Na szczęście ten problem można rozwiązać w CSS, modyfikując regułę stylu FOUC. W przeglądarkach, które obsługują deklaratywny model DOM Shadow, element <template shadowrootmode> jest natychmiast konwertowany na pierwiastek cienia, przez co w drzewie DOM nie ma żadnego elementu <template>. Przeglądarki, które nie obsługują deklaratywnego modelu cienia DOM, zachowują element <template>, którego można użyć, aby zapobiec FOUC:

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

Zamiast jeszcze ukrywać jeszcze niezdefiniowany element niestandardowy, poprawiono „FOUC”. Reguła ukrywa elementy podrzędne, gdy są zgodne z elementem <template shadowrootmode>. Po zdefiniowaniu elementu niestandardowego reguła przestaje być dopasowywana. Reguła jest ignorowana w przeglądarkach, które obsługują deklaratywny DOM Shadow, ponieważ element podrzędny <template shadowrootmode> jest usuwany podczas analizy kodu HTML.

Wykrywanie funkcji i obsługa przeglądarki

Deklaracyjny model Shadow DOM jest dostępny od Chrome 90 oraz Edge 91, ale zamiast standardowego atrybutu shadowrootmode używany jest starszy atrybut niestandardowy o nazwie shadowroot. Nowszy atrybut shadowrootmode i działanie strumieniowania są dostępne w Chrome 111 i Edge 111.

Jest to nowy interfejs API platformy internetowej, więc deklaratywny DOM Shadow nie jest jeszcze obsługiwany we wszystkich przeglądarkach. Obsługę przeglądarki można wykryć, sprawdzając, czy w prototypie HTMLTemplateElement istnieje właściwość shadowRootMode:

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

Watolina

Tworzenie uproszczonego kodu polyfill na potrzeby deklaratywnego DOM Shadow jest stosunkowo proste, ponieważ nie musi on idealnie powielać semantyki czasowej lub tylko parsera, z którymi wiąże się implementacja przeglądarki. Aby wykorzystać kod Deklaratywny Shadow DOM, możemy przeskanować obiekt DOM w poszukiwaniu wszystkich elementów <template shadowrootmode>, a następnie przekonwertować je na dołączone korzenie cienia w elemencie nadrzędnym. Ten proces można wykonać, gdy dokument będzie gotowy, lub zostać aktywowany przez bardziej szczegółowe zdarzenia, takie jak cykle życia elementu niestandardowego.

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

Więcej informacji