Reactive Web Components: реактивность без фреймворка
- пятница, 21 ноября 2025 г. в 00:00:02
Ссылка на 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 — аналог 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.