javascript

Что не так с веб-компонентами?

  • четверг, 26 марта 2026 г. в 00:00:04
https://habr.com/ru/articles/1014708/

Здравствуйте, меня зовут Дмитрий Карловский, и я.. пилил веб-компоненты, когда их ещё не придумали, делал полноценные компоненты на 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="&quot;lol&quot;"></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. Первый срабатывает при подключении к документу, а второй при отключении. Отличные места для создания и освобождения ресурсов вокруг жизненного цикла компонента.

А теперь у нас викторина! Как вы думаете, что будет, если веб-компонент перенести в то же место, где он и так находился?

  1. Ничего не меняется, так что ничего и не произойдёт.

  2. Это всё равно перенос, поэтому будет лишь событие DOMSubtreeModified.

  3. Любой перенос — это удаление и вставка, поэтому disconnectedCallback и затем connectedCallback.

  4. Будут каскадные вызовы этих колбэков на всём поддереве компонент.

Ну конечно же последний вариант! Формально точный, но практически бесполезный. Даже если в disconnectedCallback запускать асинхронную задачу освобождения ресурсов с задержкой, а в connectedCallback отменять её, то всё равно запуск сотен-тысяч этих задач на ровном месте — штука не бесплатная.

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

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

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

Немного выводов

Я рассмотрел далеко не все проблемы веб-компонент, постаравшись отобрать лишь наиболее фундаментальные, но надеюсь мне удалось показать, что этот дважды поспешный стандарт настолько мертворождён, что за десяток лет, так и не снискал популярности, ни у разработчиков фреймворков, ни тем более у прикладников.

Немногие энтузиасты страдают, но продолжают есть кактус, надеясь на светлое будущее, наступления которого ждать не приходится. И причина — в кривом фундаменте, к которому до сих пор приходится лепить подпорки, чтобы он не разваливался на базовых задачах.

И ещё немного

The Flaws Of Web Components

Web Components Are Not the Future

A Criticism of Web Components

Подкидывайте свои ссылки, добавлю и их сюда.


Актуальный оригинал на $hyoo_page.