Reactive Web Components: реактивность без фреймворка
Зачем держать несколько версий UI-кита на одной странице
Представьте платформу из нескольких десятков микрофронтендов: они катятся независимо разными командами и используют общий UI-кит. В какой-то момент кит нужно развивать — новый дизайн-токен, переработанная кнопка, ломающее изменение в API компонента. И тут возникает проблема, которая по своей природе организационная, а не техническая: обновить все модули одновременно невозможно.
Обычно остаётся выбор из двух плохих вариантов. Либо big-bang-миграция: все команды бросают фичи и синхронно переезжают на новую версию — это дорого, рискованно и почти никогда не укладывается в один релиз. Либо заморозка: UI-кит перестаёт развиваться, потому что любое ломающее изменение блокирует слишком многих. В первом случае страдает скорость команд, во втором — продукт.
На самом деле хочется третьего: чтобы новый модуль/виджет уже сегодня использовал UI-кит v2, соседний оставался на v1, и оба спокойно жили на одной странице. Тогда миграция превращается в фоновый процесс «модуль за модулем» — без общего дедлайна и без риска уронить весь продукт разом.
Насколько это вообще сложно — зависит от стека. В React и Angular такая мультиверсия достижима, но живёт она в конфигурации сборки и изоляции рантайма: какой версией компонента пользоваться, по сути решается в webpack.config.js и в том, как нарезаны микрофронтенды (ниже описано более подробно). На веб-платформе есть более прямой рычаг — сам реестр Custom Elements. Именно с него и начинается разница.
Проблема: глобальный реестр Custom Elements
Глобальный реестр Custom Elements (window.customElements) допускает имя тега только один раз: повторный define с тем же именем (uwc-button) выбрасывает ошибку, и страница ломается. Для приложений с микрофронтенд-архитектурой или для поэтапной миграции UI-кита это жёсткое ограничение — разные модули не могут зарегистрировать разные версии одного компонента под одним тегом в глобальном реестре.
На уровне платформы это ограничение уже начали снимать через Scoped Custom Element Registries: можно создать отдельный реестр и привязать его к shadow root, и тогда один и тот же тег существует в разных скоупах независимо. Сейчас это отгружено в Safari и Chromium-браузерах (Chrome, Edge), но пока не поддерживается в Firefox и скоупит строго по shadow root — удобно, когда приложение целиком на веб-компонентах, и неудобно, когда хост не веб-компонентный (например, React), потому что под каждый скоуп нужна отдельная shadow-граница.
RWC решает ту же задачу иначе и без этих ограничений: библиотека разделяет JS-фабрику компонента (объект TypeScript с API) и регистрацию Custom Element (тег в браузерном реестре). Благодаря этому UwcButton() вызывается одинаково во всём коде, а под капотом каждый модуль рендерит свой Custom Element с уникальным постфиксным тегом — на глобальном реестре, что работает во всех браузерах сегодня (включая Firefox), без полифила и без требования к хосту оборачивать потребителей в shadow root.
┌─────────────────────────────────────────────────┐ │ RWC (Фундамент) │ │ Сигналы, BaseElement, Декораторы, HTML-фабрика │ └──────────┬──────────────────────┬───────────────┘ │ │ ┌─────▼───────┐ ┌───────▼─────┐ │ UI Kit v1 │ │ UI Kit v2 │ │ uwc-button │ │ uwc-button │ └──────┬──────┘ └──────┬──────┘ │ │ ┌──────▼──────────────┐ ┌──────▼──────────────┐ │ Модуль A │ │ Модуль B │ │ postfix: module-a │ │ postfix: module-b │ │ uwc-button-module-a│ │ uwc-button-module-b│ └─────────────────────┘ └─────────────────────┘
Ключевая функция: configCustomComponent
Функция configCustomComponent — точка входа, которую использует UI-кит при описании компонентов. Вместо немедленной регистрации в браузерном реестре она возвращает кортеж [factory, register]:
factory(UwcButton) — JS-функция, которую разработчик вызывает в шаблонахregister(registerUwcButton) — функция, которую вызывают позже, передавая конкретный тег
// ui-kit/src/components/button.ts import { configCustomComponent, BaseElement, signal, property, div, slot } from '@reactive-web-components/rwc'; class UwcButtonComponent extends BaseElement { @property() disabled = signal(false); @property() variant = signal<'primary' | 'secondary'>('primary'); render() { return div( { classList: ['uwc-button'], reactiveClassList: { 'uwc-button--disabled': this.disabled, 'uwc-button--secondary': () => this.variant() === 'secondary', }, }, slot() ); } } export const [UwcButton, registerUwcButton] = configCustomComponent(UwcButtonComponent);
Компонент описан один раз. UwcButton — это типизированная фабричная функция. Никакого тега в реестре пока нет.
Сборка регистрационного модуля UI-кита
В точке входа UI-кита (src/index.ts) собираются все регистраторы и экспортируется единая функция registerAllComponents. Она принимает конфиг с postfix:
// ui-kit/src/index.ts import { registerUwcButton } from './components/button'; import { registerUwcAlert } from './components/alert'; import { registerUwcModal } from './components/modal'; import { registerUwcInput } from './components/input'; import { registerUwcSelect } from './components/select'; interface RegisterConfig { postfix?: string; } type TagName = `${string}-${string}`; const withPostfix = (name: TagName, postfix: string): TagName => postfix ? `${name}-${postfix}` as TagName : name; export const registerAllComponents = ({ postfix = '' }: RegisterConfig = {}) => { registerUwcButton(withPostfix('uwc-button', postfix)); registerUwcAlert(withPostfix('uwc-alert', postfix)); registerUwcModal(withPostfix('uwc-modal', postfix)); registerUwcInput(withPostfix('uwc-input', postfix)); registerUwcSelect(withPostfix('uwc-select', postfix)); }; // Реэкспорт фабрик — разработчики используют именно их export { UwcButton } from './components/button'; export { UwcAlert } from './components/alert'; export { UwcModal } from './components/modal'; export { UwcInput } from './components/input'; export { UwcSelect } from './components/select';
Обратите внимание: фабрики (UwcButton, UwcAlert, …) экспортируются отдельно от регистраторов. Бизнес-модуль импортирует фабрики для работы с компонентами и registerAllComponents — для инициализации.
Практически полезно сразу договориться о формате постфикса, например module-a, module-b, чтобы имена тегов оставались предсказуемыми и единообразными в DOM.
Инициализация в бизнес-модуле
Каждый бизнес-модуль при старте регистрирует свою версию UI-кита со своим уникальным постфиксом:
// module-a/src/main.ts (использует UI Kit v1.7) import { registerAllComponents } from '@hrcrm/web-ui-kit@1.7'; registerAllComponents({ postfix: 'module-a' }); // В браузерном реестре: uwc-button-module-a, uwc-alert-module-a, ... // module-b/src/main.ts (использует UI Kit v1.8) import { registerAllComponents } from '@hrcrm/web-ui-kit@1.8'; registerAllComponents({ postfix: 'module-b' }); // В браузерном реестре: uwc-button-module-b, uwc-alert-module-b, ...
Оба модуля сосуществуют на одной странице без конфликтов, потому что теги уникальны.
Если есть риск повторного вызова инициализации, стоит дополнительно защитить регистрацию проверкой customElements.get(tagName) внутри регистратора.
Использование компонентов: JS-код не меняется
После регистрации разработчик использует фабрики точно так же — независимо от постфикса или версии UI-кита:
// Внутри module-a (UI Kit v1.7) import { UwcButton } from '@hrcrm/web-ui-kit@1.7'; UwcButton({ '.disabled': false, '.variant': 'primary' }, 'Сохранить') // Внутри module-b (UI Kit v1.8) — идентичный вызов import { UwcButton } from '@hrcrm/web-ui-kit@1.8'; UwcButton({ '.disabled': false, '.variant': 'primary' }, 'Сохранить')
Вызов UwcButton(...) в обоих случаях одинаков. configCustomComponent внутри подставляет нужный тег, который был зарегистрирован через registerUwcButton. Рендеринг в DOM:
<!-- module-a --> <uwc-button-module-a variant="primary">Сохранить</uwc-button-module-a> <!-- module-b --> <uwc-button-module-b variant="primary">Сохранить</uwc-button-module-b>
Реальный сценарий: поэтапная миграция
Допустим, в монорепозитории есть три бизнес-модуля, и нужно обновить UI-кит с v1.6 до v2.0 с новым дизайн-токеном. Не нужно обновлять все модули одновременно:
// hr-module/src/main.ts import { registerAllComponents } from '@hrcrm/web-ui-kit@2.0'; registerAllComponents({ postfix: 'hr' }); // payroll-module/src/main.ts import { registerAllComponents } from '@hrcrm/web-ui-kit@1.6'; registerAllComponents({ postfix: 'payroll' }); // crm-module/src/main.ts import { registerAllComponents } from '@hrcrm/web-ui-kit@1.6'; registerAllComponents({ postfix: 'crm' });
Все три модуля работают на одной странице. hr-module использует новый дизайн, остальные — старый. Миграция идёт по одному модулю за спринт, без риска сломать весь продукт.
Как эту же задачу решают в React и Angular
Та же проблема — несколько версий UI-кита на одной странице — в экосистемах React и Angular решается иначе и обычно сильнее зависит от устройства runtime и сборки.
React: изоляция зависимостей, а не переименование компонентов
В React UI-кит — это набор обычных компонентов, которые живут внутри React-дерева конкретного приложения или микрофронтенда. Поэтому задача мультиверсии здесь обычно сводится не к переименованию DOM-тегов, а к изоляции зависимостей и runtime между независимыми модулями.
Важно разделять два сценария:
Несколько React-приложений на одной странице без shared runtime. Это рабочий сценарий: если каждый микрофронтенд приезжает со своим
reactиreact-dom, монтируется в собственный контейнер и не пытается делить singleton-зависимости, разные версии React могут сосуществовать на одной странице.Несколько микрофронтендов через Module Federation с shared React. Здесь появляется главный источник проблем: если
reactиreact-domобъявлены какsingleton, на странице должна использоваться одна согласованная версия runtime, и несовместимые требования разных модулей приводят к warning’ам, fallback-механике или поломкам в рантайме в зависимости от конфигурации.
Типичная конфигурация выглядит так:
new ModuleFederationPlugin({ name: 'moduleA', shared: { react: { singleton: true, requiredVersion: '^18.0.0' }, 'react-dom': { singleton: true, requiredVersion: '^18.0.0' }, '@company/ui-kit': { singleton: false, requiredVersion: '^2.0.0' }, }, })
Внутри React прикладной код обычно не знает про версию UI-кита на уровне имени компонента. Button остаётся Button, а вопрос совместимости решается через организацию сборки, стратегию shared-зависимостей и границы между микрофронтендами.
Гранулярность изоляции: где React принципиально проигрывает
Здесь важно разобрать один конкретный сценарий, в котором разница между подходами становится наиболее ощутимой. Предположим, нужно не заменить целый микрофронтенд, а просто использовать один компонент из новой версии UI-кита рядом со старой — например, обновлённую кнопку с новым дизайном прямо внутри уже существующего React 17-приложения.
В Web Components это тривиально: <uwc-button-v2> — это автономный кастомный элемент, у которого нет зависимости от родительского runtime. Он регистрируется одной строкой и работает рядом с <uwc-button-v1> без каких-либо накладных расходов. Весь diff между версиями — это только код самого компонента.
В React компонент — не самодостаточная единица. Он не работает без React runtime. Поэтому, чтобы использовать один компонент из новой версии UI-кита с другой версией React, нужно либо обновить весь модуль целиком, либо упаковать этот компонент вместе с React 19 + ReactDOM 19 как отдельный изолированный бандл. В итоге за одну кнопку приходится платить полным весом React-рантайма — порядка 40–50 КБ в сжатом виде.
Ценовая единица изоляции в React — это application runtime. В Web Components — это сам компонент. Именно поэтому мелкозернистая мультиверсия (несколько компонентов разных версий одновременно на одной странице, внутри одного хоста) в Web Components нативна, а в React требует архитектурного решения.
Angular: версия фреймворка важнее версии UI-кита
В Angular та же задача ещё жёстче связана с runtime и компилятором. Angular-компоненты компилируются под конкретную версию Angular, и безопасное совместное выполнение разных приложений чаще всего требует, чтобы модули использовали совместимые версии Angular runtime.
На практике в Angular-мире Module Federation обычно настраивают так, чтобы @angular/core, @angular/common и связанные пакеты были singleton и проходили строгую проверку версий:
shared: share({ '@angular/core': { singleton: true, strictVersion: true, requiredVersion: 'auto' }, '@angular/common': { singleton: true, strictVersion: true, requiredVersion: 'auto' }, '@company/ui-kit': { singleton: false, requiredVersion: 'auto' }, })
Технически загрузить две версии @angular/core в браузер возможно, но это выходит за рамки стандартного и поддерживаемого сценария, усложняет bootstrap и заметно повышает риск несовместимостей. Для production-систем это не штатная архитектура.
Отдельно стоит уточнить про zone.js. Проблема не в том, что на странице вообще не могут жить несколько Angular-приложений — они могут работать вместе, используя один общий zone.js. Сложности начинаются, когда разные приложения ожидают разные версии или разные режимы работы zone-патчей. Режим zoneless (доступен с Angular 20+) уменьшает этот класс проблем, но не является обязательным условием для совместного размещения Angular-приложений.
Итог для Angular: несколько версий UI-кита на одной странице обычно достижимы, если все участвующие модули договорились о совместимом Angular runtime. Несколько несовместимых версий самого Angular — это пограничный сценарий, который редко рассматривается как нормальная целевая архитектура.
Сравнение подходов
Характеристика |
RWC (postfix) |
React |
Angular |
|---|---|---|---|
Несколько версий UI-кита на странице |
✅ Нативно через уникальные теги |
✅ Да, через изоляцию бандлов или MF |
✅ Да, если runtime совместим |
JS-код при смене версии |
Не меняется |
Обычно не меняется |
Обычно не меняется |
Нужна настройка сборщика |
❌ Не обязательна |
⚠️ Часто нужна в MFE-сценариях |
⚠️ Обычно нужна |
Ценовая единица изоляции |
Компонент (без runtime) |
Application runtime (~50 КБ) |
Application runtime |
Основная точка сложности |
Регистрация тегов |
Shared runtime и зависимости |
Совместимость Angular runtime |
Несколько версий фреймворка на странице |
— |
Возможны при изоляции бандлов |
Теоретически возможны, но нежелательны |
Поэтапная миграция |
Модуль за модулем |
Модуль за модулем |
Модуль за модулем |
Обмен данными между версиями |
DOM / события / API библиотеки |
Props, events, shared state |
Inputs/outputs, events, shared services |
Ключевые различия
В React и Angular версионирование UI-кита обычно упирается в архитектуру runtime и сборки. Один и тот же компонентный API может оставаться неизменным, но вопрос совместимости переносится в границы приложений, shared-зависимости и способ композиции микрофронтендов.
В RWC версионирование UI-кита — это в первую очередь проблема регистрации. Сборщик может вообще не участвовать в выборе конкретного тега. Каждый модуль вызывает registerAllComponents({ postfix: 'my-module' }), а прикладной код продолжает писать UwcButton(...) как обычно.
Ту же задачу решают и другие UI-киты на Web Components, и сама платформа — но разными способами. Postfix-подобное переименование тега есть у Stencil.js (transformTagName в defineCustomElements) и Microsoft Graph Toolkit (withDisambiguation). Lit получает мультиверсионность через @open-wc/scoped-elements, опираясь на scoped-реестры. И сама платформа движется туда же: Scoped Custom Element Registries (отгружены в Safari и Chromium, пока без Firefox) позволяют одному тегу нативно жить в разных скоупах — но скоупят строго по shadow root. Во всех этих случаях это либо дополнительный механизм поверх API компонентов, либо браузерная возможность с неполным покрытием и привязкой к shadow root. В RWC разделение [factory, register] через configCustomComponent заложено в архитектуру изначально и работает на глобальном реестре во всех браузерах, не завязываясь на shadow-границы хоста.
Интеграция со стандартным useCustomComponent
configCustomComponent — надстройка над useCustomComponent. Если модуль не нуждается в постфиксной регистрации (например, это приложение, а не библиотека), можно использовать стандартный путь напрямую:
Сценарий |
Что использовать |
|---|---|
Строим UI-кит, который будут использовать несколько модулей |
|
Строим приложение без версионирования |
|
Строим stateless-утилитарный компонент |
|
// Стандартный путь — для компонентов внутри приложения @component('app-header') class AppHeader extends BaseElement { render() { return header(slot()); } } export const AppHeaderComp = useCustomComponent(AppHeader); // Путь UI-кита — для переиспользуемых компонентов-библиотек class UwcButtonComponent extends BaseElement { /* ... */ } export const [UwcButton, registerUwcButton] = configCustomComponent(UwcButtonComponent);