Ссылка на github
Пару лет назад я столкнулся с проблемой, которая наверняка знакома многим: нужно было сделать компонентную систему, но React, Vue и, тем более, Angular казались избыточными, а чистый JavaScript уже начинал превращаться в нечитаемую кашу из addEventListener и innerHTML.
В итоге я написал свою библиотеку — Reactive Web Components (RWC). Не потому, что хотел изобрести велосипед, а потому, что нужен был инструмент, который даёт реактивность без лишнего оверхеда и при этом работает с нативными Web Components. То есть компоненты можно использовать где угодно — хоть в React-приложении, хоть в старом jQuery-проекте.
Что это такое
RWC — это не фреймворк. Это просто набор утилит, которые добавляют реактивность к обычным веб-компонентам. В осно��е лежит система сигналов (похожа на Solid.js или Preact Signals), но заточена под Web Components.
Идея максимально простая: состояние — это сигналы, компоненты — классы с декораторами, рендеринг — через фабричные функции. Всё обёрнуто в TypeScript, так что автодополнение работает без танцев с бубном.
Почему сигналы — не просто тренд
Сегодня все говорят о реактивных сигналах. Solid.js, Qwik, даже Vue 3 перешли на них. Но почему?
Представьте: у вас есть счётчик. В React вы напишете:
const [count, setCount] = useState(0);
При изменении count перерисуется весь компонент и все его дети (если вы не оптимизировали через useMemo и React.memo). В RWC:
const count = signal(0);
Изменение count обновит только те DOM-узлы, которые непосредственно зависят от этого сигнала. Никаких виртуальных DOM, диффинга, мемоизации. Только чистые сигналы и их зависимости.
Вся реактивность завязана на сигналах. Сигнал — это функция, которая возвращает значение и при этом запоминает, кто её вызвал. Звучит просто, но это и есть вся магия.
import { signal } from '@shared/utils';
const count = signal(0);
count(); // читаем: 0
count.set(10); // меняем
count.update(v => v + 1); // или через функцию
Когда вызываешь сигнал внутри эффекта или компонента, библиотека автоматически подписывается на изменения. Обновил сигнал — все подписчики пересчитались. Никаких ручных подписок, никаких useEffect с зависимостями, которые можно забыть обновить.
В RWC все состояния — это сигналы. Свойства компонентов — сигналы. Контекст — сигналы. Это создаёт единую систему, где обновления происходят с хирургической точностью.
import { signal, effect } from '@shared/utils';
const name = signal('Иван');
const surname = signal('Петров');
effect(() => {
console.log(`Полное имя: ${name()} ${surname()}`);
});
name.set('Пётр'); // автоматически выведет "Полное имя: Пётр Петров"
Для вычисляемых значений есть createSignal — он сам отслеживает зависимости:
const price = signal(100);
const quantity = signal(2);
const total = createSignal(() => price() * quantity());
// total() всегда актуальный, обновляется сам
А для строк — rs (reactive string), работает как template literal, но реактивно:
const user = signal('Анна');
const greeting = rs`Привет, ${user}!`;
// greeting() обновляется автоматически
Компоненты: два подхода
RWC поддерживает оба подхода — классовый и функциональный. В разных ситуациях удобны разные стили.
Классовый подход — для сложной логики
Компоненты — это обычные классы, наследующиеся от BaseElement. Реактивные свойства и события помечаются декораторами:
import { component, property, event } from '@shared/utils/html-decorators';
import { BaseElement } from '@shared/utils/html-elements/element';
import { useCustomComponent } from '@shared/utils/html-fabric/custom-fabric';
import { div, button } from '@shared/utils/html-fabric/fabric';
import { signal, rs } from '@shared/utils';
import { newEventEmitter } from '@shared/utils';
@component('my-counter')
class Counter extends BaseElement {
@property()
count = signal(0);
@event()
onCountChange = newEventEmitter<number>();
render() {
return div(
button({
'@click': () => {
this.count.update(v => v + 1);
this.onCountChange(this.count());
}
}, rs`Счёт: ${this.count()}`)
);
}
}
export const CounterComp = useCustomComponent(Counter);
@property() делает поле реактивным и синхронизирует с HTML-атрибутом. @event() создаёт кастомное событие. TypeScript всё типизирует, так что опечатки ловятся на этапе компиляции.
Функциональный подход — для простоты
Для простых презентационных компонентов удобнее функциональный стиль:
import { createComponent } from '@shared/utils/html-fabric/fn-component';
import { div, img } from '@shared/utils/html-fabric/fabric';
interface UserCardProps {
user: { name: string; avatar: string; email: string };
}
const UserCard = createComponent<UserCardProps>((props) =>
div(
{ classList: ['card'] },
img({ '.src': props.user.avatar }),
div(props.user.name),
div(props.user.email)
)
);
Функциональные компоненты легче, проще тестируются и отлично композируются. Идеальны для UI-элементов без сложной логики.
Фабрика элементов: простота без компромиссов
Вместо document.createElement и ручной возни с атрибутами используются фабричные функции. Это похоже на Solid.js, но с важным отличием: нет JSX. Вместо него — фабричные функции (div, button), которые дают строгую типизацию и автодополнение без необходимости в Babel или TypeScript-трансформациях.
Это сознательный выбор. Когда работаешь с крупными проектами, типизация атрибутов, событий и CSS-классов экономит часы отладки. А отсутствие препроцессора упрощает настройку сборки.
import { div, button, input, signal } from '@shared/utils';
const count = signal(0);
// Создаём производный сигнал
const doubled = count.pipe(v => v * 2);
// Рендеринг
div(
button({
'@click': () => count.update(v => v + 1)
}, 'Увеличить'),
rs`Счётчик: ${count()} (удвоенное: ${doubled()})`
)
Есть краткая нотация: .атрибут для свойств, @событие для обработчиков. Код получается компактнее, но не менее читаемым.
Реактивные списки
Для списков есть getList — он обновляет только те элементы, что реально изменились. Не перерисовывает весь список, а точечно обновляет DOM:
import { getList } from '@shared/utils';
@component('item-list')
class ItemList extends BaseElement {
items = signal([
{ id: 1, name: 'Первый' },
{ id: 2, name: 'Второй' }
]);
render() {
return div(
getList(
this.items,
(item) => item.id, // ключ для отслеживания
(item, index) => div(`${index + 1}. ${item.name}`)
)
);
}
}
getList сравнивает элементы по ключу и трогает только изменённые. Для больших списков это критично — без этого UI начинает тормозить.
Представьте таблицу с тысячей строк, где каждая ячейка реагирует на изменения. В традиционных фреймворках это кошмар оптимизаций. В RWC — стандартный сценарий. При изменении цен в нескольких товарах RWC обновит только соответствующие DOM-узлы. Никакого перерендера всей таблицы.
Условный рендеринг
Есть два варианта: when и show. Первый полностью удаляет/добавляет элементы в DOM, второй просто скрывает через CSS:
import { when, show } from '@shared/utils/html-fabric/fabric';
const isVisible = signal(true);
// Полное удаление из DOM
div(
when(isVisible,
() => div('Видимо'),
() => div('Скрыто')
)
);
// Просто скрытие
div(
show(isVisible, () => div('Контент'))
);
when — для тяжёлых компонентов, которые редко показываются (экономишь память). show — для частых переключений, когда нужно сохранить состояние (например, форма с валидацией).
Контекст и провайдеры
Для передачи данных вниз по дереву — провайдеры и инъекции. Работает как Context API в React, но через сигналы, так что всё реактивно:
const ThemeContext = 'theme';
@component('theme-provider')
class ThemeProvider extends BaseElement {
providers = {
[ThemeContext]: signal('dark')
};
render() {
return div(slot());
}
}
@component('theme-consumer')
class ThemeConsumer extends BaseElement {
theme = this.inject<string>(ThemeContext);
render() {
return div(rs`Тема: ${this.theme()}`);
}
}
Изменил тему в провайдере — все потребители обновились автоматически. Без пропс-дриллинга, без лишнего кода.
Slot Templates
Для гибкой композиции есть slot templates — аналог render props или scoped slots из Vue:
@component('data-list')
class DataList extends BaseElement {
slotTemplate = defineSlotTemplate<{
item: (ctx: { id: number, name: string }) => ComponentConfig<any>
}>();
items = signal([...]);
render() {
return div(
getList(
this.items,
item => item.id,
item => this.slotTemplate.item?.(item) || div(item.name)
)
);
}
}
// Использование
DataListComp()
.setSlotTemplate({
item: (ctx) => div(`Элемент: ${ctx.name} (id: ${ctx.id})`)
})
Компонент управляет логикой, а рендеринг делегирует наружу. Удобно для библиотечных компонентов, где нужно дать пользователю контроль над внешним видом.
Почему это не очередной фреймворк-однодневка
Я понимаю ваши сомнения. За последние годы появилось десятки «революционных» UI-библиотек. Чем RWC отличается?
1. Минимальный API surface. Всё строится вокруг 3-4 базовых примитивов: signal, createSignal, effect, pipe. Нет десятков хуков и магических правил.
2. Нет виртуального DOM. RWC работает напрямую с DOM, обновляя только изменённые узлы. Это даёт предсказуемую производительность без просадок при росте приложения.
3. Совместимость со стандартами. Компоненты — настоящие Web Components. Их можно использовать в любом проекте, даже без сборки. Просто <my-component></my-component> и всё работает.
4. Отсутствие runtime-бандла. В продакшене библиотека весит меньше 15KB gzipped (для сравнения: React + ReactDOM — около 45KB), потому что многие утилиты дропаются TypeScript при компиляции.
React/Vue: нет виртуального DOM, нет runtime-оверхеда. Компоненты можно вставить хоть в jQuery-проект, хоть в React-приложение.
Lit: Lit — популярная библиотека для веб-компонентов, весит около 5KB. В Lit реактивные свойства (помеченные @state или @property) автоматически вызывают перерисовку при изменении. Главное отличие RWC от Lit — единая система сигналов для всего состояния.
В Lit для вычисляемых значений или сложной логики нужно либо создавать геттеры, либо вручную вызывать requestUpdate():
// Lit
@state() private count = 0;
private doubled = 0;
increment() {
this.count++;
this.doubled = this.count * 2; // нужно вручную обновить
this.requestUpdate(); // если doubled не реактивное свойство
}
В RWC вычисляемые значения — это просто сигналы, которые обновляются автоматически:
// RWC
count = signal(0);
doubled = createSignal(() => this.count() * 2); // обновляется сам
increment() {
this.count.update(v => v + 1); // doubled обновится автоматически
}
Также RWC использует фабричные функции вместо tagged template literals — это даёт строгую типизацию атрибутов и событий без необходимости в препроцессорах. В Lit шаблоны пишутся через html'...', в RWC — через div(...), что даёт автодополнение в IDE и проверку типов на этапе компиляции.
Stencil: компилятор, который генерирует веб-компоненты. Имеет свою систему реактивности, но требует компиляции. RWC работает без компиляции, используя runtime-реактивность через сигналы.
Отладка без магии
Один из самых частых вопросов: «Как отлаживать реактивные зависимости?» В RWC всё прозрачно.
Нет скрытых обновлений. В DevTools видно, какие сигналы влияют на какие DOM-узлы. Не нужно ломать голову, почему при изменении одного поля перерисовывается пол-страницы. Всё явно, всё можно отследить.
Практический пример: живой поиск
Давайте соберём всё вместе. Вот компонент поиска с дебаунсом и загрузкой:
import { component } from '@shared/utils/html-decorators';
import { BaseElement } from '@shared/utils/html-elements/element';
import { div, input, ul, li } from '@shared/utils/html-fabric/fabric';
import { signal, effect } from '@shared/utils';
import { when } from '@shared/utils/html-fabric/fabric';
@component('live-search')
class LiveSearch extends BaseElement {
query = signal('');
results = signal<any[]>([]);
isLoading = signal(false);
debounceTimer: number | null = null;
// Дебаунс для запросов
private debouncedSearch = () => {
if (this.debounceTimer) clearTimeout(this.debounceTimer);
this.debounceTimer = window.setTimeout(async () => {
const q = this.query();
if (q.length < 2) {
this.results.set([]);
return;
}
this.isLoading.set(true);
try {
const response = await fetch(`/api/search?q=${q}`);
const data = await response.json();
this.results.set(data);
} catch (error) {
this.results.set([]);
} finally {
this.isLoading.set(false);
}
}, 300);
};
connectedCallback() {
super.connectedCallback?.();
// Реактивно отслеживаем изменения query
effect(() => {
this.query();
this.debouncedSearch();
});
}
render() {
return div(
{ classList: ['search-container'] },
input({
'.value': this.query,
'@input': (e, self, host) => this.query.set(host.value),
'.placeholder': 'Поиск...'
}),
// Показываем лоадер
when(() => this.isLoading(), () => div('Загрузка...')),
// Результаты
ul(
() => this.results().map(item =>
li(
{
'@click': () => {
this.dispatchEvent(new CustomEvent('item-selected', {
detail: item
}));
}
},
item.name
)
)
)
);
}
}
Обратите внимание: нет useEffect, useState, useRef. Только сигналы и ��х преобразования. Обработка ошибок встроена в логику. Условная логика выразительна без лишних вложенностей. Всё работает реактивно — изменил query, автоматически запустился поиск.
Когда использовать
RWC подходит, если нужно:
Создавать переиспользуемые компоненты, которые работают везде (даже в legacy-проектах)
Иметь реактивность без тяжёлого фреймворка
Строгую типизацию и автодополнение
Контроль над производительностью (нет виртуального DOM, обновления точечные)
Не подходит, если:
Нужна огромная экосистема готовых компонентов (как у React с Material-UI)
Команда не знает TypeScript (хотя можно и без него, но теряется половина преимуществ)
Проект уже на другом фреймворке и переписывать не планируется (хотя компоненты можно использовать и там)
Почему это работает
После работы с большими фреймворками начинаешь замечать, что половину времени тратишь на борьбу с абстракциями. Virtual DOM, сложные системы жизненного цикла, магия компиляторов... RWC — это попытка вернуться к основам, но с современными возможностями.
Код получается декларативным, но без магии. Всё прозрачно, всё можно отладить в DevTools. Нет скрытых обновлений, нет неожиданных ре-рендеров. Изменил сигнал — обновилось только то, что от него зависит.
И главное — компоненты работают везде, где поддерживаются Web Components (то есть почти везде). Можно постепенно мигрировать старые проекты, подключая компоненты по одному. Или использовать в новых проектах с нуля.
Итоги
Я не призываю всех бросать React/Lit и переходить на RWC. У каждого инструмента своя область применения. Но если вы сталкиваетесь с проблемами производительности в сложных интерактивных интерфейсах, если вам надоело бороться с неоптимальными перерисовками — возможно, сигналы и fine-grained реактивность дадут вам то, чего не хватало.
RWC — это не попытка изобрести всё заново. Это аккуратное объединение лучших идей из Solid.js (реактивность), Web Components (стандарты) и функционального программирования (чистые преобразования).
Если интересно попробовать — репозиторий на GitHub, документация в README. Библиотека активно развивается, обратная связь приветствуется.
P.S. Если найдёте баг — не стесняйтесь заводить issue.
Комментарии (15)

DimNS
20.11.2025 10:07Странно что нет сравнения со Svelte, ведь это первое что приходит на ум когда нужна реактивность без оверхеда

tamazyanarsen Автор
20.11.2025 10:07там еще суть в веб-компонентах
для svelte веб-компоненты далеко не основное направление развития
и всё-таки svelte это не совсем чистый js/ts, там свой компилятор
а я в статье написал, что хотелось что-то простое, но без лишних оберток
свой компилятор писать точно не хотел

MaximKiselev
20.11.2025 10:07Как по мне стоит выбирать только между html first и js first. За последнее время посмотрел достаточно много библиотек. Практически все выглядят как ужас. Удобней реакта в плане построения компонентов нет- это то когда jsx реально тащит. Чисто для html vue хорош. Сигналы есть preact/reef и тд. Даже типа построения шаблонов в виде функций есть но выглядит это все как вырви глаз. Мне допустим зашёл alphinejs. Я сразу вспомнил старый добрый angularjs, это как раз когда нужна лёгкая реактивность - без лишних оберток. Правда с компонентами там непонятки. Скучаю по старому angularjs. А так, что то хорошего прям нет. Svelte да хорош, но хотелось бы без сборщиков. надоеда эта магия и бандлеры.

Dominux
20.11.2025 10:07Автор выдает себя за уверенного пользователя фронта, но слышал лишь про нестареющую троицу... печально
Выше упомянули про свелт, солид и тд, но автор что-то пытается бубнить про веб-компоненты, хотя данный эвангелизм канул в лету где-то в 2015м, когда нашлись очевидные проблемы. Упомяну ещё alpine.js, мб это автор желал. Но детская привычка сделать "все сам" продолжает непокидать умельца

savostin
20.11.2025 10:07Если бы каждый, у кого возникла мысль написать своё, останавливался потому, что "это уже есть", не было бы ни "свелт, солид и тд".

tamazyanarsen Автор
20.11.2025 10:07пытается бубнить про веб-компоненты, хотя данный эвангелизм канул в лету где-то в 2015м, когда нашлись очевидные проблемы.
странно, а вот ребята из reddit и microsoft с Вами не согласны
в 2023-ем году reddit целиком переехал с react на Lit (веб-компоненты), и они получили значительный прирост производительности https://www.reddit.com/r/reddit/comments/11zso11/an_improved_web_experience/
есть пример с браузером edge, где тоже был переезд на веб-компоненты (2024 год) https://thenewstack.io/from-react-to-html-first-microsoft-edge-debuts-webui-2-0/
если не ошибаюсь, у них есть своя библиотека для создания веб-компонентов (fast)на российском рынке из примеров есть компания Едадил, они полностью переехали на Lit https://frontendconf.ru/moscow/2025/abstracts/16096
данный эвангелизм канул в лету где-то в 2015м
1-ая спецификация по веб-компонентам появилась в 2016, а поддержка браузерами появлялась с 2017 по 2020 (в 20-ом году начал поддерживать edge, а это очень важно, учитывая количество обычных пользователей на windows)

nin-jin
20.11.2025 10:07Веб компоненты гвоздями прибиты к DOM, что делает их мертворождёнными.
Хостовой объект, приаттаченый к документу - это крайне медленно. И JIT компилятор тут ничем помочь не может.
Значениями атрибутов могут быть лишь строки - нужны адовые не совместимые между либами костыли для передачи других типов значений.
Жизненный цикл компонента начинается лишь в момент аттача хостового элемента.
Перенос хостового элемента приводит к реаттачу всего поддерева компонент.
Легко словить конфликт имён компонент между разными либами. И механизмов разрешения этого конфликта нет.
Единожды зарегистрированный компонент уже нельзя удалить.
В теневом доме отваливаются все стили - их надо копипастить в каждый теневой дом отдельно. Надо ли говорить, что это тормоза на ровном месте?
Веб компоненты ничего не знают про пулл реактивность. Хочешь что-то им передать пуш по чём зря через атрибуты и слоты.
И вот на этой кривой архитектуре вы и сделали свой фреймворк, которого "как бы нет".

Egor_Grin
20.11.2025 10:07Когда я открыл для себя Lit, я был в полнейшем восторге. Почти ничего не весит, по духу напоминает Ангуляр + гугловская поддержка. Идеально подошел, как инструмент для отрисовки интерфейса, в картографическом приложении поверх mvc на TS. Сделал его отдельным модулем, и связал всё через RxJS. Получилось отличное реактивное приложение. Очень рад что в моменте не дали время на переписывание на фреймворк, и пришлось импровизировать. Очень рекомендую Lit, за такими библиотеками будущее фронтенда, имхо.

DanriWeb
20.11.2025 10:07Классно объяснено! Хотя интересно, что при том, что вы называете RWC утилитками, (имхо) по стилю статья читается как описание небольшого фреймворка вокруг Web Components — с компонентами, реактивностью и архитектурой. Получилось даже чище, чем в Lit.
Dantte
А в чем проблема была использовать готовые решения, например Lit, Stenciljs или solidjs?
tamazyanarsen Автор
я был техлидом команды, которая делала ui-kit на Lit, мы использовали вторую версию
на тот момент там были проблемы с перерисовкой (целиком вызывается функция render при каждом изменении атрибута веб-компонента)
насколько я знаю, в 3-й версии добавили точечное обновление DOM, но другие проблемы остались
например, ленивая загрузка контента табов (слотов), это не так просто и удобно сделать на lit, solid, stencil, используя именно веб-компоненты
также не хотелось использовать jsx или шаблонные строки для создания компонента, хотелось получить реактивность, но без лишних оберток (это как раз описано в статье)
yusfremov
Идея здравая, но насчёт поддержки и дальнейшего развития есть высокая доля сомнения, наверное по этой причине, возможно, стоит сделать выбор в пользу более зрелого решения
tamazyanarsen Автор
поддерживаю я + коллеги по работе
активно используем в компании на проде в одном из продуктов как основной инструмент для разработки фронтенда
themen2
А что за продукт? Можно его посмотреть на предмет тормозов
tamazyanarsen Автор
это b2b продукт, его нет в открытом доступе
мы сравнивали (в основном для больших списков и общее потребление памяти) с react/vue/angular, производительность выросла
но, чтобы не просто верить моим слова, а увидеть конкретные результаты, планировал выложить сюда https://krausest.github.io/js-framework-benchmark/ для сравнения производительности