Что не так с веб-компонентами?
- четверг, 26 марта 2026 г. в 00:00:04
Здравствуйте, меня зовут Дмитрий Карловский, и я.. пилил веб-компоненты, когда их ещё не придумали, делал полноценные компоненты на AngularJS, когда там ещё были только директивы, и разработал компоненто-ориентированный фреймворк $mol с инверсией контроля и статической типизацией, когда это ещё не было мейнстримом. Короче, я немного в теме. И сейчас я расскажу вам, почему мы сразу отказались от Web Components и почему у них нет никаких перспектив.

В десятых годах веб усложнился настолько, что стало понятно, что делать приложения уникальными снежинками страничками контр-продуктивно, и нужна абстракция, которая позволит декомпозировать приложения на автономные части — компоненты.
В браузерах уже были нативные компоненты, такие как кнопки, селекты, инпуты и тд. Но стандартного набора, разумеется, не хватало и нужна была возможность создавать свои. Однако, инерция мышления не позволяла отказаться от HTML в пользу чего-то более подходящего, поэтому каждый пытался сделать из языка разметки гипертекста язык композиции компонент.
Именно в то время появился ReactJS с его вызовами функций через XML, AngularJS с его директивами в виде атрибутов, и герои этой статьи - веб-компоненты с его кучей проблем, о которых мы и пойдёт речь далее.
Особняком тут стоит $mol, где мы изначально спроектировали оптимально заточенный под композицию компонент DSL — view.tree. Но сегодня мы поговорим не про грамотные архитектурные решения.
Итак, первая версия веб-компонент увидела свет в 2014, когда их затащили в Хром. И проча им убийство фреймворков, все ринулись на них молиться. Четыре года молились, поняли, что лыжи не едут, переименовали в v0 и в 2018 перезапустили с новой спекой.
Авторы же учли все недостатки первой нулевой версии и не допустили тех же ошибок в следующей? Ага, конечно, и далее мы об этом поговорим.
Давайте создадим простейший веб компонент с одним свойством, используя API Custom Elements:
class MyFoo extends HTMLElement { _bar = 0 get bar() { return this._bar } set bar( next ) { this.setAttribute( 'bar', next ) } static observedAttributes = [ 'bar' ] attributeChangedCallback( name, prev, next ) { this[ '_' + name ] = next } } globalThis.customElements.define( 'my-foo', MyFoo ) const foo = document.createElement( id )
Как-то мне уже больно на это смотреть, а ведь мы ещё толком и не начали. Например, в такой наивной реализации свойства могут быть только строковыми.
Если нужны другие типы данных, то нужно прикручивать какую-нибудь сериализацию, чтобы поместить их в атрибут, десерелиализацию, чтобы доставать их обратно, и костыли, чтобы не десериализовывать то, что мы только что сериализовали.
Ну, давайте малой кровью прикрутим хотя бы JSON:
class MyFoo extends HTMLElement { _bar = 0 get bar() { return this._bar } set bar( next ) { this._bar = next this._muted_ = true; try { this.setAttribute( 'bar', JSON.stringify( next ) ) } finally { this._muted_ = false } } _muted_ = false static observedAttributes = [ 'bar' ] attributeChangedCallback( name, prev, next ) { if( this._muted_ ) return this[ '_' + name ] = JSON.parse( next ) } }
И с числами, например, теперь всё хорошо, но со строками теперь проблема — появляются двойные двойные кавычки в атрибутах:
<my-foo bar=""lol""></my-foo>
И тут уже начинают намазывать ещё слоёв абстракций с разными синтаксисами:
<my-foo [bar]="lol" tabIndex="1"></my-foo> <my-foo bar="lol" tabIndex="{1}"></my-foo> <my-foo bar="lol" @tabIndex="1"></my-foo>
Плюс нужен какой-то нормальный сериализатор, который хотя бы даты сможет передать в компонент, не говоря уж об экземплярах моделей и прочем.
Ну да ладно, с костылями можно приноровиться ходить, а бойлерплейт спрятать за фабриками фабрик, собрав свой собственный велосипедный фреймворк без названия. Но есть одно но...
Слои абстракции совсем не бесплатны. Кривые абстракции дороже на порядок. Но авторы веб-компонент умудрились сделать всё медленнее уж на 3 порядка. Вот вам, сравнение скорости создания разных объектов:

Казалось бы, одна микросекунда — и глазом моргнуть не успеешь. Но всего-лишь тысяча таких компонент, и на одно только их создание уходит больше миллисекунды. А миллисекунд у нас во фрейме анимации есть всего 16, чтобы не было лагов. И большая часть из них тратится даже не на прикладной код, а на рендеринг того, что мы наворотили.
Ну ладно, мы же не дураки на каждый фрейм создавать новые компоненты, правда? А это не важно, любое взаимодействие с веб-компоненами занимает микросекунды. Вот, например, изменение одного свойства с минимально возможной логикой:

Почему всё так печально? А ответ прост. Любой дом-элемент - это не просто JS-объект, который может быть эффективно оптимизирован JIT-компилятором, это объект в памяти хоста (браузера), для которого дополнительно создаётся JS-прокси, с которым мы и работаем.
И если нативные элементы реализованы на C++ и скомпилированы с AOT-компиляцией, то веб-компонентам приходится постоянно передавать управление между JS-рантаймом и рантаймом хоста, что не позволяет JIT-компилятору что-либо эффективно заинлайнить.
Надо ли говорить, что памяти это всё занимает на порядок больше? 124 байта у тривиального веб-компонента против 16 у обычного JS-объекта.
А ведь мы ещё даже не аттачили компоненты к документу, не добавляли Shadow DOM, не создавали кастомных реестров, и не гоняли события по дереву компонент туда-сюда.
Адепты веб-компонент предпочитают не замечать этого слона, сидящего у них на коленях. И всё ждут, что вот-вот все браузеры оптимизируют и наступит щастье. 12 8 лет уже ждут. Давайте пожелаем им ещё столько же терпения.
Всё, у чего есть начало, есть и конец. У всего, кроме веб-компонент. Единожды зарегистрировав такой, он останется жить навечно, пока вся вкладка не будет закрыта. Хотелось бы посмотреть в глаза тому гениальному учёному, который решил, что отменять регистрацию никогда не требуется, и заставить его реализовывать какой-нибудь Hot Module Reload на таком API, да боюсь меня тогда посадят за насилие над детьми.
И все эти регистрации глобальны. Кто первый встал - того и тапки. А кто вторым — получай эксепшеном по интерфейсу:
customElements.define( 'ya-button', YandexButton ) // ... customElements.define( 'ya-button', YahooButton ) // 💥 the name "ya-button" has already been used with this registry
Модуль-неудачник мало того, что будет сыпать стрёмными ошибками, так ещё и вместо своих компонент будет создавать и использовать чужие одноимённые. Как думаете, что тут может пойти так?
Ну ладно, ладно, когда-нибудь можно будет обмазываться CustomElementRegistry в Shadow DOM и не регистрировать всё глобально:
const customElementRegistry = new CustomElementRegistry() customElementRegistry.define( 'ya-button', YandexButton ) class YandexForm extends HTMLFormElement { constructor() { super() const shadow = this.attachShadow({ mode: "open", customElementRegistry, }) shadow.innerHTML = `<ya-button>Ну-ну</ya-button>` } }
Кто бы мог подумать, что конфликты имён не стоит допускать? Никогда такого не было и вот опять!
И вот подключу я две такие обмазанные всевозможными реестрами библиотеки, которые регистрируют веб-компоненты... а, нет, не регистрируют, ибо всё равно может быть конфликт имён их рутовых компонент. Поэтому они предоставят мне свои реестры, которые я должен смёржить... а, нет, как ты их смёржишь-то не потеряв компоненты одной из них? Значит мне нужна мёржилка, которая будет переименовывать компоненты, приклеивая к их именам префиксы неймспейсов. В результате один и тот же компонент в разных контекстах будет иметь разное имя. Смотри не перепутай!
А, да, и заполифилить эти реестры для современных браузеров не получится, так что особо рассчитывать на них в ближайшие несколько десятков лет не приходится.
Как эту проблему можно было бы решить уже сейчас? Для этого есть 2 древние идеи: глобальный фрактальный реестр имён, по типу DNS, и VerLess — обновление кода без слома обратной совместимости.
А что насчёт обратной задачи? Допустим нам нужно заменить реализацию какой-нибудь кнопки внутри веб-компонента на свою распрекрасную реализацию с непередаваемыми тактильными ощущениями. Короче, запилить инверсию контроля, говоря умными словами. А, ну да, кому это вообще нужно? Жуй, что дают, и не хоти лишнего.
Так как с дисциплиной у разработчиков всё плохо, ибо они то и дело лезут ломать снаружи наши прекрасные компоненты, то авторы веб-компонент решили, что надо ограничить подвижность с помощью изоляции в Shadow DOM. То есть если вставляешь сторонний компонент в свою страницу, то он должен выглядеть не в стиле твоего приложения, а в каком-то своём уникальном, превращая всё приложение в лоскутное одеяло. Какое прекрасное поведение по-умолчанию!
Для кастомизации стилей веб-компонента нам предлагают вырывать гланды через анус: создаём стайлшит (скачанный не подойдёт), и передаём его в компонент... а, нет, не передаём, у нас же подражание HTML, а значит ничего, кроме строк и DOM элементов передать нельзя. Сам компонент должен взять откуда-то снаружи стайлшит и пропихнуть его в свой Shadow DOM через adoptedStyleSheets. Откуда и как — ну придумайте как-нибудь, чо как маленькие.
Если вам всё ещё не хватило острых ощущений, то вот вам ещё одна задачка — сделать полифил для нового DOM-элемента, который поддерживается, ещё не во всех браузерах. Допустим, какой-нибудь <geolocation>. Казалось бы, вот оно, идеальное применение веб-компонент, но нет, решили садисты-стандартизаторы, разработчики должны страдать! Как из-за того, что имена стандартных элементов должны быть в lowercase (без шампуров), так и из-за того, что имена веб-компонент могут быть только в kebab-case (с хотя бы одним шампуром).
А, и ещё из-за того, что кто-то особо одарённый решил засунуть 100500 совершенно разных компонент в один элемент <input>, который ни расширить, ни переопределить. И до сих пор не понял, что это была плохая идея, продолжая накидывать туда ещё.
У веб-компонентов есть чудесные хуки жизненного цикла connectedCallback и disconnectedCallback. Первый срабатывает при подключении к документу, а второй при отключении. Отличные места для создания и освобождения ресурсов вокруг жизненного цикла компонента.
А теперь у нас викторина! Как вы думаете, что будет, если веб-компонент перенести в то же место, где он и так находился?
Ничего не меняется, так что ничего и не произойдёт.
Это всё равно перенос, поэтому будет лишь событие DOMSubtreeModified.
Любой перенос — это удаление и вставка, поэтому disconnectedCallback и затем connectedCallback.
Будут каскадные вызовы этих колбэков на всём поддереве компонент.
Ну конечно же последний вариант! Формально точный, но практически бесполезный. Даже если в disconnectedCallback запускать асинхронную задачу освобождения ресурсов с задержкой, а в connectedCallback отменять её, то всё равно запуск сотен-тысяч этих задач на ровном месте — штука не бесплатная.
Можно, конечно, создание ресурсов перенести в конструктор, а освобождение в FinalizationRegistry, но первый не может быть асинхронным, а когда сработает второй, и сработает ли вообще — не возьмётся предсказать даже Ванга.
Про ленивую инициализацию свойств и автоматический контроль времени жизни ресурсов я даже не заикаюсь. Нормальную реактивность мы не дождёмся в веб-компонентах, думаю, никогда.
Стоит ли вообще упоминать, что о статической типизации вообще и TypeScript в частности тоже можно не мечтать? В то время как даже самые слоупок-фреймворки уже худо-бедно статически типизируются, веб-компоненты предлагают нам коммуникацию между компонентами через нетипизированные строки. Самое то в век галюцинирующих Искусственных Идиотов!
Я рассмотрел далеко не все проблемы веб-компонент, постаравшись отобрать лишь наиболее фундаментальные, но надеюсь мне удалось показать, что этот дважды поспешный стандарт настолько мертворождён, что за десяток лет, так и не снискал популярности, ни у разработчиков фреймворков, ни тем более у прикладников.
Немногие энтузиасты страдают, но продолжают есть кактус, надеясь на светлое будущее, наступления которого ждать не приходится. И причина — в кривом фундаменте, к которому до сих пор приходится лепить подпорки, чтобы он не разваливался на базовых задачах.
Web Components Are Not the Future
Подкидывайте свои ссылки, добавлю и их сюда.