Как стать автором
Обновить
3222.59
RUVDS.com
VDS/VPS-хостинг. Скидка 15% по коду HABR15

Веб-разработка на ванильном HTML, CSS и JavaScript

Уровень сложностиСредний
Время на прочтение19 мин
Количество просмотров9.3K
Автор оригинала: Joeri

В этой серии статей мы расскажем, как выполнять веб-разработку исключительно на ванильных технологиях. Ни инструментов, ни фреймворков, лишь HTML, CSS и JavaScript.

Современные фреймворки веб-разработки обладают мощными возможностями для быстрой разработки хорошо структурированных веб-приложений, поэтому они стоят изучения. Однако за эту богатую функциональность приходится расплачиваться сложностью фреймворков и инструментария, а для обеспечения безопасности и актуальности проектов часто требуется регулярная поддержка.

Выбрав ванильный стиль веб-разработки, мы обмениваем кратковременное удобство на такие долговременные преимущества, как простота и практически нулевая поддержка. Такой подход возможен благодаря современному уровню развития браузеров, обеспечивающих превосходную поддержку веб-стандартов.

Часть 1. Компоненты


▍ Что это такое?


Веб-компоненты (Web Components) — это набор технологий, позволяющий расширять стандартное множество HTML-элементов дополнительными элементами.

Есть три основные технологии:

  • Пользовательские элементы (Custom elements) — позволяют расширять HTML, чтобы мы могли создавать разметку не только из <div>, <input>, <span> и так далее, но и из более высокоуровневых примитивов.
  • Shadow DOM — расширяет пользовательские элементы, добавляя им собственную DOM; изолирует сложное поведение внутри элемента от остальной части страницы.
  • Шаблоны HTML — расширяют пользовательские элементы многократно используемыми блоками разметки с тегами <template> и <slot> для быстрой генерации сложных структур.

Эти три пункта одновременно говорят нам обо всём и ни о чём. Вероятно, это не первый ваш туториал по Web Components, и вам могло показаться, что это довольно запутанная тема.

Однако если разбираться пошагово, всё будет не так сложно…

▍ Простой компонент


Давайте начнём с простейшего — с пользовательского элемента, отображающего сообщение 'hello world!':

hello-world.js:

class HelloWorldComponent extends HTMLElement {
    connectedCallback() {
        this.textContent = 'hello world!';
    }
}
customElements.define('x-hello-world', HelloWorldComponent);

На странице его можно использовать так:

index.html:

<!doctype html>
<html lang="en">
<body>
    <script src="hello-world.js"></script>
    <p>I just want to say...</p>
    <x-hello-world></x-hello-world>
</body>
</html>

Результат будет таким:


Что здесь происходит?

Мы создали новый HTML-элемент, зарегистрировав его как тег x-hello-world, и применили его на странице. Сделав это, мы получили следующую структуру DOM:

  • body (узел)
    • x-hello-world (узел)
      • 'hello world!' (textContent)

Построчно рассмотрим код пользовательского элемента:

class HelloWorldComponent extends HTMLElement { — каждый пользовательский элемент — это класс, расширяющий HTMLElement. Теоретически, возможно расширить другие классы, например HTMLButtonElement, чтобы расширить <button>, но на практике это не работает в Safari.

connectedCallback() { — этот метод вызывается, когда наш элемент добавляется в DOM; это означает, что элемент готов вносить изменения в DOM. Стоит отметить, что он может вызываться несколько раз, если элемент или один из его предков перемещается по DOM.

this.textContent = 'hello world!'; — в данном случае this ссылается на наш элемент, имеющий полный API HTMLElement, включая его предков Element и Node, у которых можно найти свойство textContent, используемое для добавления строки 'hello world!' в DOM.

customElements.define('x-hello-world', HelloWorldComponent); — для каждого веб-компонента необходимо один раз вызвать window.customElements.define, чтобы зарегистрировать класс пользовательского элемента и связать его с тегом. После вызова этой строки пользовательский элемент становится доступным для использования в HTML-разметке, а уже существующие его использования в отрендеренной разметке вызывают свои конструкторы.

Почему он называется x-hello-world, а не hello-world?

Существуют правила именования тегов пользовательских элементов; в частности, имя должно начинаться с буквы в нижнем регистре и содержать дефис. Кроме того, все теги участвуют в глобальном пространстве имён, где всегда существует риск конфликта имён. Хотя hello-world тоже может быть валидным именем, обычно лучше всего начинать все имена тегов пользовательских элементов уникальным префиксом. Мы выбрали префикс x-.

Ещё одна тонкость заключается в том, что теги пользовательских элементов не могут быть самозакрывающимися. Поэтому <x-hello-world></x-hello-world> применять можно, а <x-hello-world /> — нельзя.

▍ Сложный компонент


Приведённая выше версия вполне подойдёт для небольшого демо, но вам, вероятно, быстро потребуется что-то большее:

  • Добавление элементов DOM в качестве дочерних для обеспечения возможности более разнообразного контента.
  • Передача атрибутов и обновление DOM на основании изменений в этих атрибутах.
  • Стилизация элемента, предпочтительно так, чтобы это происходило изолированно и удобно масштабировалось.
  • Определение всех пользовательских элементов из одной центральной точки вместо закидывания произвольных тегов скриптов посередине разметки.

Чтобы показать, как делать всё это при помощи пользовательских элементов, приведём пример пользовательский элемент <x-avatar>, реализующий упрощённую версию компонента NextUI Avatar (React):


components/avatar.js:

/**
 * Использование:
 * <x-avatar src="https://.../avatar.png"></x-avatar>
 * <x-avatar src="https://.../avatar-large.png" size="lg"></x-avatar>
 */
class AvatarComponent extends HTMLElement {
    connectedCallback() {
        if (!this.querySelector('img')) {
            this.append(document.createElement('img'));
        }
        this.update();
    }

    static get observedAttributes() {
        return ['src', 'alt'];
    }

    attributeChangedCallback() {
        this.update();
    }

    update() {
        const img = this.querySelector('img');
        if (img) {
            img.src = this.getAttribute('src');
            img.alt = this.getAttribute('alt') || 'avatar';    
        }
    }
}

export const registerAvatarComponent = () => {
    customElements.define('x-avatar', AvatarComponent);
}

Основные изменившиеся компоненты:

  • Геттер observedAttributes возвращает атрибуты элемента, которые при изменении приводят к вызову attributeChangedCallback() браузером, позволяя обновить UI.
  • Метод connectedCallback написан с допущением, что он будет вызываться многократно. Этот метод вызывается, когда элемент впервые добавляется DOM, а также когда он перемещается.
  • Метод update() обрабатывает изначальный рендеринг, а также обновления, централизуя логику UI. Стоит отметить, что этот метод написан безопасным образом с оператором if, потому что он может быть вызван из метода attributeChangedCallback() до того, как connectedCallback() создаст элемент <img>.
  • Экспортированная функция registerAvatarComponent позволяет централизовать логику, определяющую все пользовательские элементы в приложении.

После рендеринга этот компонент аватара будет иметь следующую структуру DOM:

  • body (узел)
    • x-avatar (узел)
      • img (узел)
        • src (атрибут)
        • alt (атрибут)

Для стилизации компонента можно использовать отдельный файл CSS:

components/avatar.css:

x-avatar {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 2.5rem;
    height: 2.5rem;
}

x-avatar[size=lg] {
    width: 3.5rem;
    height: 3.5rem;
}

x-avatar img {
    border-radius: 9999px;
    width: 100%;
    height: 100%;
    vertical-align: middle;
    object-fit: cover;
}

Обратите внимание:

  • Так как мы знаем тег нашего компонента, можно легко управлять областью видимости стилей, добавляя к ним префикс x-avatar, чтобы они не конфликтовали с остальной частью страницы.
  • Так как пользовательский элемент — это просто HTML, мы можем выполнять стилизацию пользовательских атрибутов элемента на чистом CSS, например, атрибутом size, изменяющим размер компонента без JavaScript.

Пример, демонстрирующий два разных размера на странице:


HTML этого примера централизует логику JavaScript и CSS по двум файлам index, чтобы упростить масштабирование до новых веб-компонентов. Такой или подобный паттерн можно использовать для упорядочивания веб-приложения, состоящего из десятков или сотен различных веб-компонентов.

index.html:

<!doctype html>
<html lang="en">
<head>
    <link rel="stylesheet" href="index.css">
</head>
<body>
    <script type="module" src="index.js"></script>
    <p>A basic avatar component in two sizes:</p>
    <x-avatar src="https://i.pravatar.cc/150?u=a042581f4e29026024d"></x-avatar>
    <x-avatar src="https://i.pravatar.cc/150?u=a04258114e29026302d" size="lg"></x-avatar>
</body>
</html>

index.js:

import { registerAvatarComponent } from './components/avatar.js';
const app = () => {
    registerAvatarComponent();
}
document.addEventListener('DOMContentLoaded', app);

index.css:

@import url("./components/avatar.css");
body { font-family: monospace; }

Использование ключевого слова CSS @import может показаться неожиданным, потому что этого ключевого слова часто опасаются из соображений производительности, но в современных браузерах с HTTP/2 и в особенности с HTTP/3 снижение производительности при таком подходе не такое уж серьёзное.

Разве нам не нужен бандлер?

Если вы читали внимательно, то заметили, что мы применили в нашем коде синтаксис модулей ES для import и export, но не настроили никакого бандлера JavaScript для транспиляции этого синтаксиса. Магия, благодаря которой всё работает, находится в index.html, где атрибут типа тега <script type="module" src="index.js"> позволяет включить режим модуля ES для всего JavaScript. Он поддерживается во всех современных браузерах.

▍ Добавляем дочерние элементы


Возможность добавления дочерних элементов к веб-компоненту реализовать не так сложно. На самом деле, это поведение по умолчанию. Чтобы проверить, как это работает, давайте расширим наш пример с аватаром, обернув его в значок:


index.html:

<!doctype html>
<html lang="en">
<head>
    <link rel="stylesheet" href="index.css">
</head>
<body>
    <script type="module" src="index.js"></script>
    <p>Avatar and badge, when their powers combine...</p>
    <div>
        <x-badge content="5" id="live-badge">
            <x-avatar src="https://i.pravatar.cc/150?u=a042581f4e29026704d"></x-avatar>
        </x-badge>
        ←
        <input type="number" value="5" title="badge value"
               oninput="document.getElementById('live-badge').content = this.value" />    
    </div>
</body>
</html>

index.js:

import { registerAvatarComponent } from './components/avatar.js';
import { registerBadgeComponent } from './components/badge.js';
const app = () => {
    registerAvatarComponent();
    registerBadgeComponent();
}
document.addEventListener('DOMContentLoaded', app);

index.css:

@import url("./components/avatar.css");
@import url("./components/badge.css");

p, div { margin: 1em; font-family: sans-serif; }
x-badge { vertical-align: middle; }

Давайте разберёмся в созданной структуре DOM:

  • div (узел)
    • x-badge (узел)
      • content (атрибут)
      • span (узел, отображающий контент)
      • x-avatar (узел)
    • input (узел)

Компонент x-avatar идентичен компоненту из предыдущего примера. но как же работает x-badge?

components/badge.js:

class BadgeComponent extends HTMLElement {
    #span;

    connectedCallback() {
        if (!this.#span) {
            this.#span = document.createElement('span');
            this.#span.className = 'x-badge-label';                
        }
        this.insertBefore(this.#span, this.firstChild);
        this.update();
    }

    update() {
        if (this.#span) this.#span.textContent = this.getAttribute('content');
    }

    static get observedAttributes() {
        return ['content'];
    }

    attributeChangedCallback() {
        this.update();
    }

    set content(value) {
        if (this.getAttribute('content') !== value) {
            this.setAttribute('content', value);
        }
    }

    get content() {
        return this.getAttribute('content');
    }
}

export const registerBadgeComponent = () => customElements.define('x-badge', BadgeComponent);

components/badge.css:

x-badge {
    position: relative;
    display: inline-flex;
    flex-shrink: 0;
    box-sizing: border-box;
}

x-badge > span.x-badge-label {
    /* размер и позиция */
    box-sizing: inherit;
    position: absolute;
    top: 0.2rem;
    right: 0.2rem;
    width: 1.25rem;
    height: 1.25rem;
    transform: translate(50%, -50%);
    z-index: 10;
    /* цвета и шрифты */
    color: white;
    background-color: rgb(0, 111, 238);
    border-style: solid;
    border-color: #333333;
    border-width: 2px;
    border-radius: 9999px;
    font-size: 0.875rem;
    line-height: 1.2;
    /* расположение текста */
    display: flex;
    place-content: center;
    user-select: none;
}

Примечания о происходящем здесь:

this.insertBefore — нужно быть внимательными, чтобы не перезаписать дочерние элементы, уже добавленные в разметку, например, выполнив присвоение innerHTML. В этом случае span, отображающий badge, вставляется перед дочерними элементами. Также это означает, что для пользовательских элементов, которые не должны иметь дочерних элементов, это можно реализовать принудительно, вызвав this.innerHTML = '' из connectedCallback().

set content(value) { — доступ к атрибутам пользовательского элемента в JavaScript возможен только через методы setAttribute() и getAttribute(). Чтобы JavaScript API был чище, необходимо создать сеттер и геттер для свойства класса, обёртывающего атрибут content пользовательского элемента. См. пример в index.html выше.

Выявление дочерних элементов

Узнать, когда к веб-компоненту добавляются или удаляются дочерние элементы, можно при помощи MutationObserver, вызвав его метод observe при помощи observer.observe(this, { childList: true }). Если создать это в конструкторе, то можно будет наблюдать за изменением типа childList при первом добавлении дочерних элементов, а также позже, когда элементы добавляются или удаляются.

▍ Украшательства


Разобравшись, как выглядят обычные веб-компоненты, мы можем подняться на последний уровень их сложности, где используются более сложные фичи: Shadow DOM и шаблоны HTML.

Всё это можно объединить вместе в этом примере структуры страницы, объявляющем новый компонент <x-header>:


index.html:

<!doctype html>
<html lang="en">
<head>
    <link rel="stylesheet" href="index.css">
</head>
<body>
    <x-header title="Welcome">
        <x-badge content="2">
            <x-avatar src="https://i.pravatar.cc/150?u=a042581f4e29026704d"></x-avatar>
        </x-badge>
    </x-header>
    <main>
        <p>Hello, shadow DOM!</p>
    </main>
    <script type="module" src="index.js"></script>
</body>
</html>

Вот код нового добавленного компонента<x-header>:

components/header.js:

const template = document.createElement('template');
template.innerHTML = `
    <link rel="stylesheet" href="${import.meta.resolve('./header.css')}">
    <header>
        <h1></h1>
        <slot></slot>
    </header>
`;

class HeaderComponent extends HTMLElement {
    constructor() {
        super();
        if (!this.shadowRoot) {
            this.attachShadow({ mode: 'open' });
            this.shadowRoot.append(template.content.cloneNode(true));
        }
        this.update();
    }

    update() {
        this.shadowRoot.querySelector('h1').textContent = this.getAttribute('title');
    }

    static get observedAttributes() {
        return ['title'];
    }

    attributeChangedCallback() {
        this.update();
    }
}

export const registerHeaderComponent = () => customElements.define('x-header', HeaderComponent);

В header.js происходит много интересного, давайте разбираться.

const template = document.createElement('template'); — код заголовка начинается с очистки шаблона HTML. Шаблоны — это фрагменты HTML, которые можно легко копировать и добавлять в DOM. В случае сложных веб-компонентов, имеющих много разметки, часто более удобно применение шаблонов. Если создать экземпляр шаблона вне его класса, то его можно будет многократно использовать во всех экземплярах компонента <x-header>.

<link rel="stylesheet" href="${import.meta.resolve('./header.css')}"> — так как наш компонент использует shadow DOM, он изолирован от содержащей его страницы и изначально не имеет стилизации. header.css необходимо импортировать в shadow DOM при помощи тега <link>. Трюк с import.meta.resolve импортирует файл CSS по тому же пути, что и у файла header.js.

<slot></slot> — в элемент <slot> отправляются дочерние элементы (например, дочерний <x-badge> элемента <x-header>). Размещение дочерних элементов в слоте схоже с использованием пропса children в компоненте React. Применение слотов возможно только в случае, если веб-компоненты имеют shadow DOM.

constructor() { — это первый пример, использующий конструктор. Конструктор вызывается при первом создании элемента, но до того, как он готов к взаимодействию с DOM. Поведение конструктора по умолчанию заключается в вызове конструктора super() родительского класса. То есть если требуется только поведение конструктора HTMLElement по умолчанию, то конструктор указывать не нужно.

Здесь он указан потому, что конструктор гарантированно будет вызван ровно один раз, то это идеальное место для подключения shadow DOM.

if (!this.shadowRoot) { this.attachShadow({ mode: 'open' });

attachShadow прикрепляет shadow DOM к текущему элементу, изолированную часть структуры DOM с CSS, отделённым от содержащей его страницы и опционально с теневым контентом, спрятанным от контекста JavaScript родительской страницы (если задано mode: 'closed'). Если веб-компоненты используются в известной кодовой базе, то обычно удобнее использовать их в режиме open, как и сделано здесь.

if (!this.shadowRoot) { необязателен, но обеспечивает возможность генерации HTML на стороне сервера при помощи декларативного shadow DOM.

this.shadowRoot.append(template.content.cloneNode(true)); — свойство shadowRoot является корневым элементом прикреплённого shadow DOM, которое рендерится на страницу как контент элемента <x-header>. Шаблон HTML клонируется и присоединяется к нему.

Shadow DOM становится доступным сразу после вызова attachShadow, и именно поэтому шаблон можно присоединить к конструктору, а также вызвать здесь метод update(). В случае пользовательских элементов без shadow DOM рендеринг контента элемента должен быть отложен до connectedCallback().

Объединим все новые файлы этого примера:

index.html:

<!doctype html>
<html lang="en">
<head>
    <link rel="stylesheet" href="index.css">
</head>
<body>
    <x-header title="Welcome">
        <x-badge content="2">
            <x-avatar src="https://i.pravatar.cc/150?u=a042581f4e29026704d"></x-avatar>
        </x-badge>
    </x-header>
    <main>
        <p>Hello, shadow DOM!</p>
    </main>
    <script type="module" src="index.js"></script>
</body>
</html>

index.css:

@import url("./reset.css");
@import url("./components/avatar.css");
@import url("./components/badge.css");

body { 
    font-family: system-ui, sans-serif;
}

x-header, main { 
    margin: 1em; 
    padding: 1em;
    border: 1px dashed black;
}

reset.css:

/* Обобщённый минимальный сброс CSS
   источник вдохновения: https://www.digitalocean.com/community/tutorials/css-minimal-css-reset */

:root {
    box-sizing: border-box;
    line-height: 1.4;
    /* https://kilianvalkhof.com/2022/css-html/your-css-reset-needs-text-size-adjust-probably/ */
    -moz-text-size-adjust: none;
    -webkit-text-size-adjust: none;
    text-size-adjust: none;
}

*, *:before, *:after {
    box-sizing: inherit;
}

body, h1, h2, h3, h4, h5, h6, p {
    margin: 0;
    padding: 0;
    font-weight: normal;
}

img {
    max-width:100%;
    height:auto;
}

index.js:

import { registerAvatarComponent } from './components/avatar.js';
import { registerBadgeComponent } from './components/badge.js';
import { registerHeaderComponent } from './components/header.js';
const app = () => {
    registerAvatarComponent();
    registerBadgeComponent();
    registerHeaderComponent();
}
document.addEventListener('DOMContentLoaded', app);

components/header.js:

const template = document.createElement('template');
template.innerHTML = `
    <link rel="stylesheet" href="${import.meta.resolve('./header.css')}">
    <header>
        <h1></h1>
        <slot></slot>
    </header>
`;

class HeaderComponent extends HTMLElement {
    constructor() {
        super();
        if (!this.shadowRoot) {
            this.attachShadow({ mode: 'open' });
            this.shadowRoot.append(template.content.cloneNode(true));
        }
        this.update();
    }

    update() {
        this.shadowRoot.querySelector('h1').textContent = this.getAttribute('title');
    }

    static get observedAttributes() {
        return ['title'];
    }

    attributeChangedCallback() {
        this.update();
    }
}

export const registerHeaderComponent = () => customElements.define('x-header', HeaderComponent);

components/header.css:

@import url("../reset.css");

:host {
    display: block;
}

header {
    display: flex;
    flex-flow: row wrap;
    justify-content: right;
    align-items: center;
}

h1 {
    font-family: system-ui, sans-serif;
    margin: 0;
    display: flex;
    flex: 1 1 auto;
}

::slotted(*) {
    display: flex;
    flex: 0 1 auto;
}

Как можно увидеть в header.css, стилизация контента shadow DOM немного отличается:

  • Псевдоселектор :host применяет стили к элементу из «светлой» DOM, хранящей shadow DOM (или, иными словами, к самому пользовательскому элементу).
  • Другие стили (в этом примере h1) изолированы внутри shadow DOM.
  • Изначально shadow DOM не стилизован, именно поэтому reset.css импортируется повторно.

Использовать или не использовать shadow?

Так как пользовательские элементы могут содержать дочерние элементы с shadow DOM и без неё, можно задаться вопросом, когда её использовать.

Shadow DOM имеет множество недостатков:


Поэтому если вы не создаёте компоненты для кого-то ещё, то может оказаться проще отказаться от shadow DOM.

Однако Shadow DOM рекомендуется в следующих ситуациях:

  • Промежуточные элементы, которые нужно создать между корневым элементом и содержащимися в нём дочерними, как в примере с <header>. Это возможно только благодаря слоту внутри shadow DOM.
  • Когда дочерние элементы принимаются в нескольких местах веб-компонента. Эта возможность обеспечивается именованными слотами. Это может быть удобно для компонентов, задающих структуру страницы.
  • Стили и DOM должны быть изолированы от содержащей их страницы. Часто такое бывает в случаях, когда веб-компоненты должны использоваться другими разработчиками или встраиваться в сторонние сайты.

▍ Передача данных


Выше мы предполагали, что передаваемые между веб-компонентами данные очень просты, обычные отправляемые вниз числовые и строковые атрибуты. Однако в реальных веб-приложениях между родительским и дочерними компонентами передаются сложные данные, например, объекты и массивы.

В этом примере показаны три основных способа передачи данных между веб-компонентами:


▍ События


Первый способ — это передача событий, обычно от дочерних компонентов к родительскому. Это продемонстрировано формой в начале примера.

components/form.js:

class SantasForm extends HTMLElement {
    connectedCallback() {
        if (this.querySelector('form')) return;
        this.innerHTML = `
            <form>
                <label for="name">Name</label>
                <input type="text" id="name" name="name" required />
                <input type="checkbox" id="nice" name="nice" value="1" />
                <label for="nice">Nice?</label>
                <button type="submit">Add</button>
            </form>
        `;
        this.querySelector('form').onsubmit = (e) => {
            e.preventDefault();
            const data = new FormData(e.target);
            this.dispatchEvent(new CustomEvent('add', {
                detail: { form: Object.fromEntries(data.entries()) }
            }));
            e.target.reset();
        }
    }
}

export const registerSantasForm = 
    () => customElements.define('santas-form', SantasForm);

При каждом нажатии на кнопку Add при помощи метода dispatchEvent вызывается CustomEvent типа add. В свойстве detail данных события хранятся отправленные в форме данные.

Это событие обрабатывается одним уровнем выше:

components/app.js:

class SantasApp extends HTMLElement {
    #theList = [/* { name, nice } */];

    connectedCallback() {
        if (this.querySelector('h1')) return;
        this.innerHTML = `
            <h1>Santa's List</h1>
            <santas-form></santas-form>
            <santas-list></santas-list>
            <santas-summary></santas-summary>
        `;
        this.querySelector('santas-form')
            .addEventListener('add', (e) => {
                this.#theList.push(e.detail.form);
                this.update();
            });
        this.update();
    }

    update() {
        this.querySelector('santas-list').list = this.#theList.slice();
        this.querySelector('santas-summary').update(this.#theList.slice());
    }
}

export const registerApp = 
    () => customElements.define('santas-app', SantasApp);

Метод update() отправляет обновлённый список вниз, компонентам <santas-list> и <santas-summary>, при помощи следующих двух способов.

▍ Свойства


Второй способ передачи сложных данных — использование свойств класса, показанного на примере компонента <santas-list>:

components/list.js:

class SantasList extends HTMLElement {
    #currentList = [/* { name, nice } */];
    set list(newList) {
        this.#currentList = newList;
        this.update();
    }
    update() {
        this.innerHTML = 
            '<ul>' +
            this.#currentList.map(person => 
                `<li>${person.name} is ${person.nice ? 'nice' : 'naughty'}</li>`
            ).join('\n') +
            '</ul>';
    }
}

export const registerSantasList =
    () => customElements.define('santas-list', SantasList);

Сеттер list вызывает метод update() для повторного рендеринга списка.

Это рекомендованный способ передачи сложных данных хранящим состояние веб-компонентам.

Опасайтесь XSS

Наблюдательные читатели могли заметить, что в приведённом выше примере есть баг cross-site scripting. Его можно устранить, правильно закодировав сущности person.name. О том, как это делается, мы поговорим в главе о кодировании сущностей в четвёртой части серии статей («Приложения»).

Какого-то бесспорного и однозначного способа реализации атрибутов, свойств и событий нет. В статье, посвящённой изменению поведения веб-компонентов, объясняется, как сделать так, чтобы пользовательские компоненты вели себя схоже со встроенными элементами; рекомендуется читать её при создании компонентов, которые будут встраиваться в сторонние сайты.

▍ Методы


Третий способ передачи сложных данных — это вызов метода веб-компонента; примером может быть компонент <santas-summary>:

components/summary.js:

class SantasSummary extends HTMLElement {
    update(list) {
        const nice = list.filter((item) => item.nice).length;
        const naughty = list.length - nice;
        this.innerHTML = list.length ? `
            <p>${nice} nice, ${naughty} naughty</p>
        ` : "<p>Nobody's on the list yet.</p>";
    }
}

export const registerSantasSummary = 
    () => customElements.define('santas-summary', SantasSummary);

Это рекомендованный способ передачи сложных данных к веб-компонентам без состояния.

▍ Полный пример


Вот полный код приложения Santa's List:

index.html:

<!doctype html>
<html lang="en">
<head>
    <link rel="stylesheet" href="index.css">
</head>
<body>
    <script type="module" src="index.js"></script>
    <santas-app></santas-app>
</body>
</html>

index.js:

import { registerSantasForm } from './components/form.js';
import { registerSantasList } from './components/list.js';
import { registerSantasSummary } from './components/summary.js';
import { registerApp } from './components/app.js';

const app = () => {
    registerSantasForm();
    registerSantasList();
    registerSantasSummary();
    registerApp();
}

document.addEventListener('DOMContentLoaded', app);

index.css:
body { 
    font-family: 'Iowan Old Style', 'Palatino Linotype', 'URW Palladio L', P052, serif;
    margin: 1em;
}

button { font-family: inherit; font-size: 100%; }

santas-form { display: block }
santas-form * { vertical-align: middle; }

santas-app h1 { color: darkred; }

components/app.js:

class SantasApp extends HTMLElement {
    #theList = [/* { name, nice } */];

    connectedCallback() {
        if (this.querySelector('h1')) return;
        this.innerHTML = `
            <h1>Santa's List</h1>
            <santas-form></santas-form>
            <santas-list></santas-list>
            <santas-summary></santas-summary>
        `;
        this.querySelector('santas-form')
            .addEventListener('add', (e) => {
                this.#theList.push(e.detail.form);
                this.update();
            });
        this.update();
    }

    update() {
        this.querySelector('santas-list').list = this.#theList.slice();
        this.querySelector('santas-summary').update(this.#theList.slice());
    }
}

export const registerApp = 
    () => customElements.define('santas-app', SantasApp);

components/form.js:

class SantasForm extends HTMLElement {
    connectedCallback() {
        if (this.querySelector('form')) return;
        this.innerHTML = `
            <form>
                <label for="name">Name</label>
                <input type="text" id="name" name="name" required />
                <input type="checkbox" id="nice" name="nice" value="1" />
                <label for="nice">Nice?</label>
                <button type="submit">Add</button>
            </form>
        `;
        this.querySelector('form').onsubmit = (e) => {
            e.preventDefault();
            const data = new FormData(e.target);
            this.dispatchEvent(new CustomEvent('add', {
                detail: { form: Object.fromEntries(data.entries()) }
            }));
            e.target.reset();
        }
    }
}

export const registerSantasForm = 
    () => customElements.define('santas-form', SantasForm);

components/list.js:

class SantasList extends HTMLElement {
    #currentList = [/* { name, nice } */];
    set list(newList) {
        this.#currentList = newList;
        this.update();
    }
    update() {
        this.innerHTML = 
            '<ul>' +
            this.#currentList.map(person => 
                `<li>${person.name} is ${person.nice ? 'nice' : 'naughty'}</li>`
            ).join('\n') +
            '</ul>';
    }
}

export const registerSantasList =
    () => customElements.define('santas-list', SantasList);

components/summary.js:

class SantasSummary extends HTMLElement {
    update(list) {
        const nice = list.filter((item) => item.nice).length;
        const naughty = list.length - nice;
        this.innerHTML = list.length ? `
            <p>${nice} nice, ${naughty} naughty</p>
        ` : "<p>Nobody's on the list yet.</p>";
    }
}

export const registerSantasSummary = 
    () => customElements.define('santas-summary', SantasSummary);

Сила браузера

Напоминаю, что весь этот код по-прежнему ванильный. В нём не используется ни один фреймворк, библиотека или инструмент сборки, и он работает во всех основных браузерах. Ему никогда не потребуются изменения для обеспечения совместимости с новыми версиями зависимостей. А самое важное то, что он остаётся читаемым и удобным в поддержке.

О веб-компонентах можно сказать ещё многое. Чтобы глубже разобраться в их жизненном цикле, прочитайте нашу статью.

В следующей части серии статей мы поговорим о стилизации веб-компонентов для обеспечения их инкапсуляции и удобства использования.

Telegram-канал со скидками, розыгрышами призов и новостями IT 💻
Теги:
Хабы:
+68
Комментарии28

Публикации

Информация

Сайт
ruvds.com
Дата регистрации
Дата основания
Численность
11–30 человек
Местоположение
Россия
Представитель
ruvds