Компонентно-ориентированный подход уже давно зарекомендовал себя как отличная практика разработки. Его массовая популярность пришла вместе с такими библиотеками, как React и Vue. Создавая компоненты, мы чётко разграничиваем логику, формируем зоны ответственности и эффективно боремся с дублированием кода. Обычно компонент отвечает за рендеринг HTML-разметки и динамически обновляет её в зависимости от своего состояния. Кроме того, ключевую роль играют механизмы контроля жизненного цикла, например, обработка этапов: «компонент присоединился», «компонент обновился» и «компонент был удалён». Это база, но часто существует и множество других хуков.

Раньше для работы с этой парадигмой мы были вынуждены использовать React, Vue или аналогичные фреймворки. Однако сегодня можно обойтись без дополнительных библиотек и обязательной сложной сборки, потому что компоненты доступны «из коробки» в современных браузерах. Да, я говорю о Веб-компонентах. Если быть точнее, о Пользовательских элементах (Custom Elements), поскольку «Веб-компоненты» — это скорее набор стандартных технологий, позволяющих создавать эти самые элементы.

Прежде чем углубляться в детали, давайте проверим, насколько эта технология готова к использованию в продакшене. Для этого зайдём на сайт, который все мы часто открываем для проверки поддержки функций браузерами, — на https://caniuse.com/

Действительно, у Safari до сих пор нет полной поддержки всех спецификаций. Однако важно понимать: Web Components — это обширная тема, и основная функциональность — создание абсолютно новых элементов (автономных пользовательских элементов, или Autonomous Custom Elements) — работает стабильно и без проблем во всех современных браузерах, включая Safari.

Проблема в Safari касается лишь одной конкретной, хотя и важной, возможности: наследования и расширения логики встроенных элементов (таких как <button><input>), которые называются «пользовательскими встроенными элементами» (Customized Built-in Elements). Эта часть спецификации до сих пор не реализована в WebKit.

Ну и как сказал один многоуважаемый человек:

Давайте приступим к практической части, создадим веб элемент — для этого нам потребуется html файл с базовой разметкой:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Web Components</title>
</head>
<body>
    <custom-counter></custom-counter>
</body>
</html>

Создадим элемент custom-counter это будет кнопка с счетчиком при клике на кнопку значения счетчика будет увеличиваться.

На данный момент компонент не определен и он абсолютно пустой стили также отсутствуют:

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

Давайте наполним тело компонента и напишем стили. Стили и скрпты следует писать в отдельных файлах, тут так сделано в качестве демонстрации:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Web Components</title>

    <style>
        body {
            display: flex;
            justify-content: center;
        }
        custom-counter {
            display: flex;
            flex-direction: column;
            align-items: center;
        }
    </style>
</head>
<body>
    <custom-counter>
        <h1 data-id="count">0</h1>
        <button name="add">Add</button>
    </custom-counter>
</body>
</html>

Осталось добавить логику, при клике на кнопку увеличивать значение на 1. Добавим скрипт: Создадим класс который должен наслдеовать HTMLElement. А также нужно сообщить бразуеру (customElements.define) что для кастомного элемента custom-counter существует такой класс: CustomeCounter и нужно его обрабатывать. Если все сделали верно то на странице в консоле должно быть сообщение: "Подключился!"

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Web Components</title>

    <style>
        body {
            display: flex;
            justify-content: center;
        }
        custom-counter {
            display: flex;
            flex-direction: column;
            align-items: center;
        }
    </style>
</head>
<body>
    <custom-counter>
        <h1 data-id="count">0</h1>
        <button name="add">Add</button>
    </custom-counter>

    <script>
        class CustomeCounter extends HTMLElement {
            constructor () {
                super()
            }

            connectedCallback () {
                console.log('Подключился!')

                this.addEventListener('click', event => {
                    const { target } = event 
                })
            }
        }

        customElements.define('custom-counter', CustomeCounter)
    </script>
</body>
</html>

Теперь реализуем обработку кликов по кнопке. Чтобы постоянно не обращаться к DOM в поисках элемента счётчика, я вынес его определение в конструктор класса, сохранив в свойстве this.countEl

class CustomeCounter extends HTMLElement {
    constructor () {
        super()

        this.countEl = this.querySelector('[data-id=count]')
    }

    connectedCallback () {
        console.log('Подключился!')

        this.addEventListener('click', event => {
            const { target } = event
            const addBtnEl = target.closest('button[name=add]')
            if (!addBtnEl) return

            this.countEl.innerText = Number(this.countEl.innerText) + 1
        })
    }
}

Таким образом при клике на кнопку значение счетчика будет увеличиваться:

Всё работает! Однако это лишь один из способов создания Веб-компонентов. Существуют и другие: элементы с Shadow DOM для полной инкапсуляции, Declarative Shadow DOM для серверного рендеринга, а также использование слотов (slot) и шаблонов (template) для создания гибкой структуры. Я планирую подробно рассказать об этих подходах в следующей статье.

А сейчас я покажу несколько способов, как создавать элемент, не дублируя постоянно его разметку в HTML-коде.

Способ 1 (выносим тело компонента в script):

<custom-counter></custom-counter>

<script>
    class CustomeCounter extends HTMLElement {
        constructor () {
            super()

            this.innerHTML = `<h1 data-id="count">0</h1>
    <button name="add">Add</button>`
            this.countEl = this.querySelector('[data-id=count]')
        }

        connectedCallback () {
            console.log('Подключился!')

            this.addEventListener('click', event => {
                const { target } = event
                const addBtnEl = target.closest('button[name=add]')
                if (!addBtnEl) return

                this.countEl.innerText = Number(this.countEl.innerText) + 1
            })
        }
    }

    customElements.define('custom-counter', CustomeCounter)
</script>

Как видите, мы добавили HTML-разметку компонента прямо в конструкторе. У этого подхода есть существенный недостаток: при первоначальной загрузке страницы тело компонента будет отсутствовать в DOM. Пользователю придётся дождаться не только загрузки скрипта (если он подключён через src), но и его выполнения, прежде чем компонент примет свой окончательный вид.

Способ 2 (создаем template в HTML):

<template id="custom-counter-template">
    <h1 data-id="count">0</h1>
    <button name="add">Add</button>
</template>
<custom-counter></custom-counter>

<script>
    class CustomeCounter extends HTMLElement {
        constructor () {
            super()

            const templateContent = document.getElementById('custom-counter-template').content.cloneNode(true)
            this.appendChild(templateContent)
            this.countEl = this.querySelector('[data-id=count]')
        }

        connectedCallback () {
            console.log('Подключился!')

            this.addEventListener('click', event => {
                const { target } = event
                const addBtnEl = target.closest('button[name=add]')
                if (!addBtnEl) return

                this.countEl.innerText = Number(this.countEl.innerText) + 1
            })
        }
    }

    customElements.define('custom-counter', CustomeCounter)
</script>

Этот подход отличается от предыдущего тем, что вместо описания HTML внутри скрипта, мы вынесли разметку на уровень HTML-документа, в тело страницы. Это обеспечивает большую гибкость: одна и та же логика компонента может работать с разными шаблонами на разных страницах.

Подведём итоги. В этой статье я показал, как создать пользовательский элемент — от регистрации в браузере до добавления интерактивности. Мы рассмотрели несколько практических подходов к формированию его структуры.

Однако это лишь начало знакомства с миром Веб-компонентов. Впереди — такие важные темы, как изоляция стилей через Shadow DOM, композиция с помощью слотов <slot>, тонкости жизненного цикла и многое другое. Всему этому будут посвящены следующие материалы.

Спасибо за внимание!

Комментарии (9)


  1. Xbolt
    04.11.2025 09:33

    Хорошо, но было б замечательно если бы было что-то более детальнее чем на https://learn.javascript.ru/ ,


    1. Dima-Andreev Автор
      04.11.2025 09:33

      learn.javascript отличный сайт, там много хорошего материала.
      Я бы хотел написать еще пару статей на эту тему, подскажите что можно было бы улучшить? и что вы имеете ввиду под более детальнее? Больше примеров?


      1. Xbolt
        04.11.2025 09:33

        Что-нибудь специфическое в использовании, редкое использование. Хитрости в применении.


  1. isumix
    04.11.2025 09:33

    Вэб компоненты слишком громоздки/многословны:

    Покажите мне код

    Например вот как может выглядеть компонент кастомного счетчика на Фьюзоре:

    const ClickCounter = ({count = 0}) => (
      <button click_e_update={() => count++}>Clicked {() => count} times</button>
    );
    


    1. Xbolt
      04.11.2025 09:33

      Это разные вещи, и там Вам ответили

      Лучше один раз изучить стандарт (W3C), чем для каждого фреймворка запоминать отличия.


  1. zababurin
    04.11.2025 09:33

    Про декларатиынй shadow dom ещё бы написали. Очень важная и определяющая возможность, для самой больной темы серверного рендеринга. С декларативным shadow dom это стало значительно проще.


  1. McLotos
    04.11.2025 09:33

    Очередной некропост о технологии пятнадцатилетней давности. Я это использовал еще до появления всех этих ваших новомодных vue, svelte и т.д.


    1. zababurin
      04.11.2025 09:33

      Я это использовал еще до появления всех этих ваших новомодных vue, svelte и т.д.

      Очередной некропост о технологии пятнадцатилетней давности

      Раньше 2017 года не могли. Тогда уже vue и react были.

      Я как только он появился начал на них писать и это один из агрументов был, что никто не сможет написать, что я это 15 лет уже использую. ))) Раньше меня вы могли начать писать, только если вы являетесь разработчиком этих стандартов.

      Сочетание этих технологий стало стандартом к 2018 году. 
      Да и тогда это фреймворк полимер был, поддержка очень плохая была.

      Стандарту 8 лет максимум


    1. Dima-Andreev Автор
      04.11.2025 09:33

      Я наверное немного отстал от жизни) Просто во фронтенде такое многообразие библиотек и фреймворков что все изучить просто нереально (Я до этого писал на React и Vue). Сейчас решил посмотреть на то что есть из коробки и мне это очень понравилось, вот и решил поделится)