Я переписал свою реактивную библиотеку

Осенью 2026-го я рассказывал здесь про RWC — библиотеку реактивных веб-компонентов на сигналах. Без JSX и шаблонного компилятора: разметка собирается типизированными фабриками div, button, input, а обновляется через сигналы, как в Solid.

Статью прочитали восемь тысяч человек, тридцать оставили комментарии. Я собирался поправить пару шероховатостей, а в итоге переписал ядро.

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

Что из этого видно снаружи

Декораторы @component, @property, @event убраны — 363 строки магии, требовавшей experimentalDecorators и не дававшей нормального вывода типов. Вместо них одно объявление:

class Counter extends defineComponent({
  props: { value: 0, step: 1 },
  events: { change: payload<number>() },
}) {
  render() {
    const { value, step } = this.props;
    return div(
      button({ "@click": () => value.update((v) => v - step()) }, "−"),
      span(value),
      button({ "@click": () => value.update((v) => v + step()) }, "+"),
    );
  }
}

export const counter = useCustomComponent(Counter, "x-counter");

В counter({ "@change": (e) => e.detail }) тип detailnumber, потому что так написано в объявлении. Опечатка в имени пропса — ошибка компиляции, а не проигнорированный атрибут.

Функция теперь реактивный источник везде, где значение не может быть функцией: span(() => count() * 2) работает без computed. null, undefined и false среди детей пропускаются, так что isAdmin() && button("удалить") делает ожидаемое. Появился resourcedata, loading, error, refetch, отмена предыдущего запроса через AbortSignal. А одинаковый CSS даёт один CSSStyleSheet на страницу через adoptedStyleSheets: тысяча кнопок — одна таблица стилей, а не тысяча тегов <style>.

Теперь про комментарии к прошлой статье

Самый частый вопрос был прямой: зачем, если есть Lit, Stencil, Solid?

Честный ответ: если вам подходит Lit — берите Lit. Он зрелее, у него больше пользователей и за ним стоит Google. Отличий два. Первое — разметка без шаблонных строк: у Lit это html с интерполяцией, то есть текст, проверяемый в рантайме; здесь разметка — обычный TypeScript, и переход к определению, переименование и поиск ссылок работают штатными средствами редактора, без плагина. Второе — @state в Lit перерисовывает весь шаблон, а сигнал обновляет конкретный текстовый узел.

Второй упрёк — про поддержку: стоит выбрать более зрелое решение. Возразить нечего, это правда. Могу только показать, что изменилось: тесты, документация на двух языках, проверка собранного пакета глазами потребителя, а версии 2.x остались под тегом и веткой legacy/v2, а не исчезли. С помощью ИИ агентов планирую быстро собрать на этом ядре всю необходимую экосистему.

Третий — самый интересный, потому что по большей части справедливый. Тезис: сами Web Components плохи — хостовые объекты медленные, атрибуты только строковые, имена тегов глобальны, теневой DOM мешает стилям.

Про строки в атрибутах — правда, и это не лечится: значение разбирается JSON.parse, а что не разобралось, остаётся строкой. Поэтому основной путь передачи данных здесь — DOM-свойства (.value), а атрибуты нужны для разметки, написанной руками.

Про глобальные имена — тоже правда, и вот тут удалось сделать хоть что-то. Обычно тег прибит к компоненту намертво: customElements.define("uwc-button", Button) где-то в недрах кита. Второй такой вызов на странице — ошибка.

configCustomComponent разделяет объявление и регистрацию. Кит только объявляет и отдаёт наружу пару «фабрика + регистратор»:

// @company/ui-kit — здесь customElements.define не вызывается
class Button extends defineComponent({ props: { type: "secondary" } }) {
  render() {
    return button({ class: `btn btn-${this.props.type()}` }, slot());
  }
}

export const [UwcButton, registerUwcButton] = configCustomComponent(Button, "uwc-button");

Имя тега выбирает уже приложение — на старте, один раз:

// оболочка, сидит на ките 1.x
registerUwcButton({ postfix: "shell" }); // <uwc-button-shell>

// виджет на той же странице, приехал со своей сборкой кита 2.x
registerUwcButton({ postfix: "widget" }); // <uwc-button-widget>

А теперь главное. Прикладной код в обоих местах — одинаковый, имя тега в нём не упоминается вообще:

UwcButton({ ".type": "primary" }, "Купить");

В DOM при этом оказываются два разных элемента, каждый со своей версией кита внутри:

<uwc-button-shell>Купить</uwc-button-shell>
<uwc-button-widget>Купить</uwc-button-widget>

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

Ради этого сценария библиотека и писалась: как фундамент для кита, который живёт в нескольких приложениях с разным темпом обновления.

Про стили и теневой DOM спорить не буду: это компромисс, и кому нужна сквозная тема без CSS-переменных — будет больно.

А вот с высказываниями на тему «Web Components никому не нужны» не соглашусь. Reddit в 2023-м переехал с React на Lit, Edge собран на веб-компонентах, дизайн-системы Adobe и SAP — тоже. Это не «будущее фронтенда», а рабочий инструмент для одной задачи: компонентов, которые должны пережить смену фреймворка в приложении.

Чего это стоило

Прямого пути обновления с 2.x нет — публичный API другой. Убраны декораторы, роутер (не место ему в такой библиотеке), signal.pipe, forceSet, setName, getSubscribers; forkJoin и combineLatest заменены одним combine. Сигнал несёт ровно три метода: set, peek, update.

При этом ядро уменьшилось: ~3100 строк против 2250 при большем числе возможностей.

Итого

Библиотека: github.com/tamazyanarsen/reactive-web-components, ставится как npm i @rwcjs/core. Внутри — гайд, справочник API и рецепты на русском и английском, плюс папка examples/ с работающими компонентами.