Deklaracyjny DOM cienia

Nowy sposób implementacji i wykorzystania modelu Shadow DOM bezpośrednio w kodzie HTML.

Deklarowany DOM DOM to funkcja platformy internetowej, która jest obecnie w trakcie standaryzacji. Jest ona domyślnie włączona w Chrome 111.

Shadow DOM to jeden z 3 standardów komponentów sieciowych, oprócz szablonów HTML i elementów niestandardowych. Styl DOM pozwala określić zakres stylów CSS na konkretne poddrzewo DOM i odizolować je od reszty dokumentu. Element <slot> umożliwia kontrolowanie miejsca wstawienia w drzewie cienia elementu podrzędnego elementu niestandardowego. Połączenie tych funkcji pozwala stworzyć system do tworzenia samodzielnych komponentów wielokrotnego użytku, które bez problemu integrują się z istniejącymi aplikacjami tak samo jak wbudowane elementy HTML.

Do tej pory jedynym sposobem korzystania z modelu Shadow DOM było utworzenie cienia głównego pierwiastka przy użyciu JavaScriptu:

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

Taki imperatywny interfejs API dobrze sprawdza się przy renderowaniu po stronie klienta: te same moduły JavaScript, które definiują elementy niestandardowe, również tworzą korzenie cieni i określają treść. Wiele aplikacji internetowych musi jednak renderować treść po stronie serwera lub statyczny kod HTML podczas kompilacji. Może to być ważne w zapewnianiu rozsądnej obsługi użytkownikom, którzy nie mogą uruchamiać JavaScriptu.

Uzasadnienie zastosowania renderowania po stronie serwera (SSR) jest różne w zależności od projektu. Aby zachować zgodność z wytycznymi dotyczącymi ułatwień dostępu, niektóre witryny muszą zapewnić w pełni funkcjonalny kod HTML renderowany przez serwer, a inne – podstawowy sposób obsługi bez JavaScriptu, co gwarantuje dobrą wydajność w przypadku wolnych połączeń lub urządzeń.

W przeszłości trudno było używać modelu Shadow DOM w połączeniu z renderowaniem po stronie serwera, ponieważ nie było wbudowanego sposobu wyrażania korzeni cieni w kodzie HTML generowanym przez serwer. Wpływ na wydajność ma też przyłączanie drzew cienia do elementów DOM, które zostały już wyrenderowane bez tych elementów. Może to powodować przesunięcie układu po wczytaniu strony lub tymczasowo sygnalizować przebłysk niespotykanej treści („FOUC”) podczas wczytywania arkuszy stylów Shadow Root.

Deklaracja DOM Shadow DOM (DSD) eliminuje to ograniczenie, wprowadzając na serwerze model Shadow DOM.

Tworzenie deklaratywnego źródła cienia

Deklarowany główny poziom cienia to element <template> z atrybutem shadowrootmode:

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

Parser HTML wykrywa element szablonu z atrybutem shadowrootmode i od razu jest stosowany jako nadrzędny element nadrzędny jego elementu nadrzędnego. Wczytywanie znaczników HTML z powyższych przykładowych wyników w następującym drzewie DOM:

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

Ten przykładowy kod jest zgodny z konwencją wyświetlania zawartości modelu Shadow DOM w panelu Elementy Narzędzi deweloperskich w Chrome. Na przykład znak ↳ oznacza treści DOM z boksem DOM.

Daje nam to korzyści płynące z zastosowania hermetyzacji Shadow DOM i odwzorowania przedziałów w statycznym kodzie HTML. Do wygenerowania całego drzewa, w tym korzenia cienia, nie jest potrzebny JavaScript.

Składowe nawodnienie

Deklarowany DOM cienia może być używany samodzielnie jako sposób hermetyzowania stylów lub dostosowywania rozmieszczenia elementów podrzędnych. Największy zasięg stosuje się jednak w przypadku elementów niestandardowych. Komponenty utworzone przy użyciu elementów niestandardowych są automatycznie uaktualniane ze statycznego kodu HTML. Dzięki wprowadzeniu deklaracjalnego modelu cienia DOM może się zdarzyć, że przed uaktualnieniem element niestandardowy będzie mieć katalog główny cienia.

Element niestandardowy uaktualniany za pomocą kodu HTML, który zawiera deklaratywny katalog główny cienia, będzie miał już dołączony ten katalog główny. Oznacza to, że element będzie miał już dostępną właściwość shadowRoot, gdy zostanie utworzona, bez konieczności bezpośredniego tworzenia jej w kodzie. Najlepiej sprawdzić, czy w elemencie this.shadowRoot nie ma istniejącego pierwiastka cienia w konstruktorze elementu. Jeśli istnieje już wartość, kod HTML tego komponentu zawiera deklaratywny główny element cienia. Jeśli wartość to null, kod HTML nie zawiera deklaratywnego źródła cienia lub przeglądarka nie obsługuje deklaratywnego elementu 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>

Elementy niestandardowe są dostępne już od jakiegoś czasu, ale do tej pory nie było powodu, aby sprawdzać istniejący cień roota przed utworzeniem elementu za pomocą attachShadow(). Deklaracja DOM DOM zawiera niewielką zmianę, która umożliwia działanie istniejących komponentów: wywołanie metody attachShadow() w elemencie z istniejącym deklaratywnym źródłem cienia nie spowoduje błędu. Zamiast tego deklaratywne źródło cienia zostaje opróżnione i zwrócone. Dzięki temu starsze komponenty, które nie zostały utworzone na potrzeby deklaratywnego modelu Shadow DOM, mogą nadal działać, ponieważ deklaratywne korzenie są zachowywane do czasu utworzenia niezbędnej wymiany.

W przypadku nowo utworzonych elementów niestandardowych nowa właściwość ElementInternals.shadowRoot pozwala jednoznacznie uzyskać odwołanie do dotychczasowego deklarownego katalogu głównego elementu, zarówno otwartego, jak i zamkniętego. Można go użyć, aby wyszukać i wykorzystać deklaratywny katalog główny. W sytuacjach, gdy go nie podano, przywracam 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);

Po jednym cieniu na pierwiastek

Deklaratywny element główny cienia jest powiązany tylko z jego elementem nadrzędnym. Oznacza to, że pierwiastki cienia są zawsze w tym samym miejscu, z powiązanym elementem. Taka decyzja projektowa zapewnia możliwość strumieniowego przesyłania danych głównych, tak jak reszta dokumentu HTML. Jest to również wygodne w przypadku tworzenia i generowania elementów, ponieważ dodanie pierwiastka-cienia do elementu nie wymaga utrzymywania rejestru istniejących pierwiastków podrzędnych.

Kompromis w przypadku powiązania pierwiastków cienia z ich elementem nadrzędnym polega na tym, że nie można zainicjować wielu elementów z tego samego deklaratywnego źródła cienia (<template>). W większości przypadków nie ma to jednak znaczenia w przypadku stosowania deklaratywnego modelu cienia, ponieważ zawartość poszczególnych pierwiastków cienia rzadko jest taka sama. HTML renderowany przez serwer często zawiera powtarzające się struktury elementów, ale ich zawartość zazwyczaj się różni, np. występują niewielkie różnice w tekście lub atrybutach. Zawartość zserializowanego deklaratywnego źródła Shadow Root jest całkowicie statyczna, dlatego uaktualnienie wielu elementów z pojedynczego deklaratywnego źródła cienia zadziała tylko wtedy, gdy elementy te będą identyczne. Wpływ powtarzających się podobnych pierwiastków cienia na rozmiar transferu sieci jest stosunkowo niewielki ze względu na efekty kompresji.

W przyszłości może być możliwe ponowne otwarcie udostępnionych cieni źródłowych. Jeśli interfejs DOM obsługuje wbudowane szablony, deklarowane korzenie cienia mogą być traktowane jako szablony, których wystąpienia tworzy się w celu utworzenia cienia głównego elementu dla danego elementu. Obecny projekt deklaracja DOM DOM pozwala na taką możliwość w przyszłości, ograniczając powiązanie cienia głównego z jednym elementem.

Transmitowanie jest fajne

Powiązanie deklaratywnego źródła cienia bezpośrednio z elementem nadrzędnym upraszcza proces uaktualniania i dołączania ich do tego elementu. Deklarowane korzenie linków są wykrywane podczas analizy HTML i załączane natychmiast po napotkaniu ich otwierającego tagu <template>. Przeanalizowany kod HTML w elemencie <template> jest przetwarzany bezpośrednio w pierwiastku cienia, dzięki czemu może być „strumieniowy” renderowany w miarę jego odbierania.

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

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

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

Tylko parser

Deklaracja DOM Shadow to funkcja parsera HTML. Oznacza to, że deklaratywne źródło cienia będzie analizowane i dołączane tylko do tagów <template> z atrybutem shadowrootmode obecnych podczas analizy HTML. Inaczej mówiąc, deklaratywne korzenie cieni można utworzyć podczas wstępnej analizy HTML:

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

Ustawienie atrybutu shadowrootmode elementu <template> nie daje ż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ąć niektórych ważnych kwestii związanych z bezpieczeństwem, deklaratywne źródła cienia również nie mogą być tworzone za pomocą interfejsów API analizy fragmentów, takich jak innerHTML czy insertAdjacentHTML(). Jedynym sposobem analizowania kodu HTML z zastosowaniem deklaratywnego źródła cienia jest przekazanie nowej opcji includeShadowRoots do interfejsu 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>

Stylizowane renderowanie na serwerze

Wbudowane i zewnętrzne arkusze stylów są w pełni obsługiwane wewnątrz deklaratywnej struktury cieni za pomocą 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ż bardzo zoptymalizowane: jeśli ten sam arkusz stylów znajduje się w wielu deklaracyjnych źródłach cienia, jest on wczytywany i analizowany tylko raz. Przeglądarka korzysta z jednego zapasowego pliku CSSStyleSheet, który jest wspólny dla wszystkich podrzędnych katalogów głównych, co eliminuje zduplikowany nadmiar pamięci.

Konstruktywne arkusze stylów nie są obsługiwane w deklaratywnym interfejsie cienia DOM. Wynika to z tego, że obecnie nie ma możliwości serializacji możliwych do utworzenia arkuszy stylów w języku HTML ani odwoływania się do nich podczas wypełniania funkcji adoptedStyleSheets.

Unikanie błysku niespójnych treści

Jednym z potencjalnych problemów w przeglądarkach, które jeszcze nie obsługują deklaratywnego modelu cienia, jest unikanie „flash of unstyled content” (FOUC), gdzie nieprzetworzona zawartość jest wyświetlana w przypadku elementów niestandardowych, które nie zostały jeszcze uaktualnione. Przed wdrożeniem deklaratywnego modelu cienia jedną z powszechnych techniką unikania FOUC było stosowanie reguły stylu display:none do elementów niestandardowych, które nie zostały jeszcze wczytane, ponieważ nie mają one przypisanego i wypełnionego pierwiastka cienia. W ten sposób treść nie będzie wyświetlana, dopóki nie będzie „gotowa”:

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

Dzięki wprowadzeniu deklaracji deklaratywnej cienia DOM elementy niestandardowe można renderować lub tworzyć w języku HTML w taki sposób, aby ich zawartość cieniowana była na swoim 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 display:none „FOUC” zapobiega wyświetlaniu treści z deklaratywnego źródła cienia. Usunięcie tej reguły spowoduje jednak, że przeglądarki bez obsługi deklaratywnego DOM w postaci cienia będą wyświetlać nieprawidłowe lub niesformatowane treści, dopóki obiekt polyfill deklaracyjny Shadow DOM nie zostanie wczytany i przekształci szablon cienia głównego w prawdziwy element główny cienia.

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

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

Zamiast ukrywać niezdefiniowany jeszcze element niestandardowy, poprawiona reguła „FOUC” ukrywa elementy podrzędne, gdy podążają za elementem <template shadowrootmode>. Po zdefiniowaniu elementu niestandardowego reguła przestaje być zgodna. Reguła jest ignorowana w przeglądarkach, które obsługują deklaatywny model cienia DOM, ponieważ element podrzędny <template shadowrootmode> jest usuwany podczas analizy HTML.

Wykrywanie funkcji i obsługa przeglądarek

Deklaracja DOM DOM jest dostępna od wersji Chrome 90 i Edge 91, ale zamiast ustandaryzowanego atrybutu shadowrootmode wykorzystywano starszy niestandardowy atrybut o nazwie shadowroot. Nowszy atrybut shadowrootmode i działanie strumieniowania są dostępne w Chrome 111 i Edge 111.

Deklarowany DOM DOM to nowy interfejs API platformy internetowej, który nie jest jeszcze obsługiwany we wszystkich przeglądarkach. Obsługę przeglądarek można wykryć, sprawdzając, czy w prototypie HTMLTemplateElement występuje właściwość shadowRootMode:

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

Włókno poliestrowe

Tworzenie uproszczonego kodu polyfill na potrzeby deklaratywnego modelu cienia jest stosunkowo proste, ponieważ kod ten nie musi idealnie replikować semantyki czasowej ani właściwości parsera, które dotyczą konkretnej implementacji przeglądarki. Aby polyfill deklaracyjny model cienia DOM, możemy przeskanować DOM, aby znaleźć wszystkie elementy <template shadowrootmode>, a następnie przekonwertować je na dołączone korzenie cienia w elemencie nadrzędnym. Można to zrobić, gdy dokument będzie gotowy, lub aktywować go 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