Deklaracyjny DOM cienia

Nowy sposób wdrażania i używania modelu Shadow DOM bezpośrednio w kodzie HTML.

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 najbardziej aktualne ustandaryzowane wersje wszystkich części tej funkcji trafiły do Chrome w wersji 124.

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 zakres stylów CSS do konkretnego poddrzewa DOM i wyodrębnia to drzewo od reszty dokumentu. Element <slot> pozwala nam 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 niezbędny interfejs API sprawdza się w przypadku renderowania po stronie klienta: te same moduły JavaScriptu, które definiują elementy niestandardowe, również tworzą swoje korzenie cienia i określają treść. Jednak wiele aplikacji internetowych podczas kompilacji musi renderować treści po stronie serwera lub w formie statycznego kodu HTML. Może to być ważne, ponieważ pozwala zapewnić wygodę użytkownikom, którzy nie potrafią uruchamiać 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 renderowany przez serwer kod HTML, aby zachować zgodność ze wskazówkami dotyczącymi ułatwień dostępu. Inne decydują się na obsługę języka bazowego bez JavaScriptu, ponieważ dzięki temu można zagwarantować wysoką wydajność 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ż w generowanym przez serwer kodzie HTML nie mieliśmy wbudowanego sposobu na wyrażenie rdzeni cieni. 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świetlenie błysku niestylizowanej treści („FOUC”) podczas wczytywania arkuszy stylów Shadow Root.

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

Budowanie deklaratywnego korzenia 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 katalog główny 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 ↳ oznacza treść Light DOM w boksie.

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

Deklaratywny model DOM Shadow może służyć jako samodzielna metoda oznaczania stylów i dostosowywania miejsc docelowych podrzędnych, 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 przed uaktualnieniem.

Uaktualniany element niestandardowy z kodu HTML, który zawiera deklaratywny katalog cienia, będzie już powiązany z tym głównym elementem cienia. Oznacza to, że po utworzeniu wystąpienia element będzie miał już dostępną właściwość shadowRoot, bez konieczności jej jawnego tworzenia przez Twój kod. Najlepiej sprawdzić, czy w konstruktorze elementu nie ma rdzenia cienia this.shadowRoot. 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, ale do tej pory nie było powodu, aby sprawdzać istniejący katalog główny za pomocą funkcji attachShadow(). Deklaracyjny model DOM Shadow uwzględnia niewielką zmianę, która umożliwia działanie istniejących komponentów. Wywołanie metody attachShadow() w elemencie z istniejącym deklaratywnym rdzeniem cienia nie spowoduje wyświetlenia 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 zapewnia wyraźny sposób na uzyskanie odwołania 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, sięga 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 cieni są zawsze współlokowane z powiązanym 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 głównego 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, np. drobne 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ą takie same. 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 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 korzenie 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ł cieni za pomocą interfejsów API analizy fragmentów, takich jak innerHTML czy insertAdjacentHTML(). Jedynym sposobem analizowania kodu HTML z zastosowanymi deklaratywnymi korzeniami cienia jest użycie polecenia 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ą również 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 jednego zapasowego obiektu CSSStyleSheet, który jest współużytkowany przez wszystkie cienie główne, eliminuje to zduplikowane wykorzystanie pamięci.

Konstruowalne arkusze stylów nie są obsługiwane w deklaratywnym DOM. Wynika to z tego, że obecnie nie ma możliwości serializowalności konstruowalnych arkuszy stylów w kodzie HTML ani możliwości odwoływania się do nich podczas wypełniania pola adoptedStyleSheets.

Unikanie błyskawicznych treści

Jednym z potencjalnych problemów w przeglądarkach, które nie obsługują jeszcze deklaratywnego modelu Shadow DOM, jest unikanie „flash of unstyled content” (FOUC), w którym w przypadku 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 wypełnionego podstawowego 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ść cienia była na miejscu i gotowa przed wczytaniem 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 reguła „FOUC” display:none zapobiegłaby wyświetlaniu treści pochodzących z deklaratywnego rdzenia cienia. Usunięcie tej reguły spowodowałoby jednak, że przeglądarki bez deklaratywnej obsługi DOM Shadow wyświetlały nieprawidłowe lub niesformatowane treści, dopóki polyfill deklaratywnego DOM Shadow DOM nie załaduje się i nie przekonwertuje szablonu głównego cienia na prawdziwy główny cień.

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, poprawiona reguła „FOUC” ukrywa elementy podrzędne, które podążają za 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 i Edge 91, ale zamiast standardowego atrybutu shadowrootmode używany jest starszy atrybut niestandardowy o nazwie shadowroot. Nowszy atrybut shadowrootmode i funkcja strumieniowania są dostępne w Chrome 111 i Edge 111.

Jako nowy interfejs API platformy internetowej deklaratywny DOM nie jest jeszcze powszechnie 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 dokładnie powielać semantyki czasu ani funkcji parsera, których dotyczy problem związany z implementacją w przeglądarce. 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