javascript

Reactive Web Components: реактивность без фреймворка

  • пятница, 21 ноября 2025 г. в 00:00:02
https://habr.com/ru/articles/968384/

Ссылка на 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.