Custom Elements v praxi

Jak se něco dozvědět o nové technologii? Vyzkoušet ji v praxi! Mám za sebou už pár neseriózních pokusů s vlastními HTML značkami a v minulých dnech a týdnech jsem s jejich pomocí přepsal regulérní aplikaci webového klienta pro MPD, nazvaného CYP. Co jsem se při tom naučil a dozvěděl?

Slaďme očekávání

Tento článek popisuje zkušenosti s použitím technologie Custom Elements. Jedná se o podmnožinu většího balíku Web Components, který kromě vlastních značek přináší ještě HTML Templates a především velmi komplexní Shadow DOM. Pro potřeby zmíněného projektu bylo postačující využít pouze Custom Elements.

Zároveň článek nenabízí kvalitativní porovnání s alternativami (plain HTML, React, Vue, Svelte, …), jelikož autor nedisponuje adekvátními vědomostmi o těchto nástrojích. Je dobré vědět, že Custom Elements (a Web Components obecně) rozhodně nejsou všespásnou technologií a pro různé potřeby je nutné volit různá řešení. Následující text je proto jen souhrnem zážitků z implementace Custom Elements, názor si čtenář bude muset udělat sám. To ovšem jistě nebude problém.

Co jsou Custom Elements

Podstata je překvapivě jednoduchá. Jedná se o standardizovaný způsob, jak v HTML vytvořit a používat další vlastní značky. Pokud nám tedy oficiální množina značek (<h1>, <p>, <header>, <a>, ...) nestačí a náš kód se hemží samými divy, je to prostor pro zvážení zavedení značek vlastních.

Nejdůležitějším úkolem HTML značek je obohacení textu o strukturu a význam. Tato role je u vlastních značek diskutabilní, protože prohlížeč jejich význam nemůže chápat. Vlastní značky proto volíme v případě, že od nich očekáváme zejména specifické chování, případně vzhled.

Začneme tak trochu od konce, tím, jak své vlastní značky použijeme. Sestavíme z nich strom stránky a můžeme je libovolně kombinovat s normálními značkami. Řešený projekt je hudební přehrávač CYP, jeho HTML proto může vypadat takto:

<body>
    <cyp-app theme="dark" color="dodgerblue">
        <header>
            <cyp-player>
                <h1>Přehrávaná píseň</h1>
                <x-range></x-range>
                <button class="prev">⏮</button>
                <button class="play">▶️</button>
                <button class="pause">⏸</button>
                <button class="next">⏭</button>
            </cyp-player>
        </header>
        <main>
            <cyp-queue></cyp-queue>
            <cyp-playlists></cyp-playlists>
            <cyp-library></cyp-library>
        </main>
        <footer>
            <cyp-menu>
                <button data-for="queue">Queue</button>
                <button data-for="playlists">Playlists</button>
                <button data-for="library">Library</button>
            </cyp-menu>
        </footer>
    </cyp-app>
</body>

Za zmínku stojí, že vlastní značky musí mít v názvu pomlčku. To je garance dopředné kompatibility, neboť ve standardu HTML se značky s pomlčkami neobjevují a objevovat nebudou. Nestane se tedy, že by naše vlastní značka v budoucnu kolidovala s nějakou oficiální. Stále ovšem zůstává riziko jmenné kolize mezi značkami více různých vývojářů.

V ukázce jsou použity tyto vlastní značky:

  • <cyp-app> jako zastřešující obal nad celou aplikací;
  • <cyp-player> zobrazující právě přehrávanou píseň a nabízející interakci;
  • <cyp-queue>, <cyp-playlists> a <cyp-library> představují různé pohledy na písničky, playlisty a soubory;
  • <cyp-menu> je navigační prvek v patičce aplikace;
  • <x-range> je vstupní prvek na zobrazení průběhu přehrávání písně.

Vytvořit, nebo upravit?

Možná vás napadá, že pro některé použité vlastní značky již HTML nabízí docela vhodnou značku standardní. Kupříkladu navigace mezi komponentami aplikace by šla popsat pomocí <nav>. Nebo by se pro průběh přehrávané písničky dala upravit značka <input type=range>. To je dobrá chvíle vysvětlit, že Custom Elements mohou být dvojího typu:

  1. Samostatné (autonomous), které nemají žádnou vestavěnou funkcionalitu a čeká se, že jejich chování garantuje autor;
  2. Upravené (customized), které vycházejí (v terminologii OOP dědí) z již existující HTML značky a funkcí ji tedy mohou zastoupit.

Logické by proto bylo, aby <cyp-menu> vycházelo z HTML značky <nav>. Bohužel, podpora Customized Custom Elements je v prohlížečích zatím stále neúplná (nefungují v mobilním ani desktopovém Safari), takže jistější je zatím implementovat vlastní značky jako Autonomous Custom Elements.

Konečně JavaScript

Prohlížeč se k vlastním značkám staví indiferentně. Nevadí mu, nezpůsobují žádné chyby či problémy, ale zároveň není zřejmé, co by s nimi měl dělat. Je proto na autorovi, aby prohlížeči vysvětlil, co a jak.

Tomuto vysvětlení se formálně říká definování vlastní značky a dělá se to voláním JS funkce customElements.define(). V ní autor provede párování názvu značky (řetězec) a JS třídy, která obsahuje související funkcionalitu. Klíčové a logické přitom je, že zmiňovaná třída musí dědit z vestavěné třídy HTMLElement (která jí dodá očekávanou funkcionalitu, jako např. appendChild, innerHTML a podobně).

Téměř nejjednodušší příklad vlastní značky by tedy byl:

class MyElement extends HTMLElement {
    constructor() {
        super();
        alert("vlastni znacka");
    }
}

customElements.define("my-element", MyElement);

Konstruktor není povinný, ale dobře ilustruje, co se po definování stane: prohlížeč projde všechny již zpracované výskyty značky <my-element> a jeden po druhém je převede na objekty MyElement, tj. vytvoří jim nové instance (a vykoná jejich konstruktory). Pokud prohlížeč v budoucnu (do opuštění stránky) narazí na další značku <my-element>, rovnou vytvoří příslučnou instanci MyElement.

Za zmínku stojí, že proces definování můžeme provést nezávisle na tom, kdy se v HTML dokumentu vlastní značky vyskytnou. Pokud ale přidáme vlastní značce nějakou funkcionalitu (třeba novou metodu), nemůžeme ji zavolat, dokud nebude značka definována.

V JavaScriptovém kódu nyní můžeme vytvářet nové HTML značky buď tradičně pomocí document.createElement("my-element"), nebo prostou instancializací new MyElement(). Obojí vrátí stejný HTML prvek, v druhé variantě můžeme konstruktoru předat nějaké parametry.

Jak být slušným spoluobčanem

Vestavěné HTML prvky dodržují jistá pravidla, předepisovaná standardem DOM. Můžeme z nich stavět strom dokumentu, přistupovat ke společným vlastnostem a měnit jejich atributy. Custom Elements dovolují vývojářům toto chování pozorovat a reagovat na něj pomocí tzv. lifecycle callbacks. Jedná se o sadu metod, kterými ve vlastním prvku sledujeme jeho interakci se zbytkem stránky:

class MyElement extends HTMLElement {
    connectedCallback() {
        alert("tento prvek byl připnut do jiného prvku ve stránce");
    }

    disconnectedCallback() {
        alert("tento prvek již není součástí stránky");
    }

    adoptedCallback() {
        alert("tento prvek byl přesunut do jiného dokumentu");
    }
}

Poslední a patrně nejužitečnější lifecycle callback se týká práce s atributy. Vlastní prvek z předka HTMLElement dědí metody setAttribute a další; jejich volání lze sledovat. Musíme ale říci, které změny (resp. změny kterých atributů) nás zajímají:

class MyElement extends HTMLElement {
    static get observedAttributes() {
        return ["a", "b"];
    }

    attributeChangedCallback(name, oldValue, newValue) {
        console.log("Atribut", name, "změněn z", oldValue, "na", newValue);
    }
}

customElements.define("my-element", MyElement);
let me = new MyElement();
me.setAttribute("a", "test"); // console.log
me.setAttribute("x", "y");    // atribut se nastavi, ale callback neprobehne

Práce s atributy není vždy ideální. Mohou to být jen řetězce a u běžných HTML prvků jsme zvyklí, že jejich funkcionalita bývá dostupná také pomocí JS reflektovaných vlastností. Proto můžeme (i když to není povinné) atributy do vlastností zrcadlit:

class MyElement extends HTMLElement {
    static get observedAttributes() {
        return ["a"];
    }

    attributeChangedCallback(name, oldValue, newValue) {
        console.log("Atribut", name, "změněn z", oldValue, "na", newValue);
    }

    get a() { return this.getAttribute("a"); }
    set a(a) { this.setAttribute("a", a); }
}

customElements.define("my-element", MyElement);
let me = new MyElement();
me.a = "test"; // console.log

Styly a Shadow DOM

Jak vzniklé komponenty stylovat? Zcela tuctově pomocí CSS, kde v selektorovém jazyce můžeme používat názvy vlastních značek. Pro navigační patičku pak třeba:

cyp-menu button:not(:disabled) {
    cursor: pointer;
}

Komponentové systémy často dovolují defenzivní přístup, kdy jednotlivé části stránky neovlivňují své okolí a zároveň jsou uzamčeny vůči nežádoucím nahodilým změnám zvenčí. To koliduje s globální náturou jazyka CSS. Řešení tohoto problému se nazývá Shadow DOM a jedná se o technologii, která dovede podstrom dokumentu zapouzdřit tak, aby se do něj nepromítaly vnější styly (dokonce tento podstrom není následně přístupný ani JavaScriptovým metodám pro procházení dokumentu). Práce s Shadow DOM je ovšem velmi komplexní a jeho podpora v prohlížečích je opět neúplná. Protože v projektu přehrávače CYP nehrozí interference komponent různých autorů, tato technika není nutná.

Hierarchie

Komponenty aplikace CYP komunikují pomocí architektury client-server se vzdáleným hudebním přehrávačem MPD. K tomu využívají technologie Web Sockets. Je zde otázka, jakým způsobem má či může JS kód jednotlivých komponent k tomuto zdroji přistupovat.

Různých řešení je mnoho: singleton, depedency injection, service locator… pro hierarchii Custom Elements mi přišlo přímočaré, aby majitelem instance WebSocket byla zastřešující značka <cyp-app>. Její komponenty se k socketu dostanou tak, že v rámci hierarchie svých rodičů naleznou tuto značku:

class CypApp extends HTMLElement {
    constructor() {
        this.socket = new WebSocket("...");
    }
}

class CypComponent extends HTMLElement {
    get socket() {
        return this.closest("cyp-app").socket;
    }
}

Tento kód bude ovšem fungovat jen za předpokladu, že značka <cyp-app> již byla definována. Pokud takovou garanci v komponentě potomka nemáme, můžeme využít Promise vrácenou metodou customElements.whenDefined():

async function test() {
    await customElements.whenDefined("cyp-app");
    console.log(document.querySelector("cyp-app").socket);
}

Závěrem

Byl jsem vyzván, abych se ve světle nabytých zkušeností vyjádřil k článku Richarda Harrise (mj. autora nástrojů Rollup a Svelte), ve kterém kritizuje Web Components. Nuže, ve zkratce:

  1. Aplikace CYP stojí a padá na WebSocketové komunikaci s MPD. Otázku fungování bez JS není třeba řešit.
  2. Shadow DOM jsem nepoužil.
  3. ¯\_(ツ)_/¯
  4. Žádný polyfill jsem nepoužil. Cílím na množinu prohlížečů, které Custom Elements V1 podporují.
  5. Výhrada se týká slotted elements z HTML Templates, nepoužil jsem.
  6. Ano, dualita vlastností a atributů je problematická. Vnímám ji jako daň za kompatibilitu s HTML.
  7. To mi vrásky nedělá.
  8. Ukázka by zabrala polovinu místa, kdyby autor reflexi vlastností naimplementoval cyklem defineProperty na Adder.prototype.
  9. V aplikaci CYP s vlastními komponentami jsem na tento problém nenarazil.
  10. https://www.youtube.com/watch?v=KauRmlffjqc

Autor pracuje ve společnosti Seznam na všem, co alespoň trochu souvisí s JavaScriptem. Ve volném čase se mimo jiné zabývá věcmi, které alespoň trochu souvisí s JavaScriptem. O obojím občas tweetuje jako @0ndras.

Komentáře: 7

Přehled komentářů

Josef Marianek Dekuji moc
Vít Heřman Co si myslíte o novějších reaktivních knihovnách/frameworcích?
Ondřej Žára Re: Co si myslíte o novějších reaktivních knihovnách/frameworcích?
Mlocik97 Re: Co si myslíte o novějších reaktivních knihovnách/frameworcích?
L.
Mlocik97 Re:
L. Re:
Zdroj: https://www.zdrojak.cz/?p=23635