javascript

Мультиверсионный UI-кит с RWC: один JS-API для разных веб-компонентов

  • четверг, 18 июня 2026 г. в 00:00:09
https://habr.com/ru/articles/1048416/

Ссылка на github

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 между независимыми модулями.

Важно разделять два сценария:

  1. Несколько React-приложений на одной странице без shared runtime. Это рабочий сценарий: если каждый микрофронтенд приезжает со своим react и react-dom, монтируется в собственный контейнер и не пытается делить singleton-зависимости, разные версии React могут сосуществовать на одной странице.

  2. Несколько микрофронтендов через 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-кит, который будут использовать несколько модулей

configCustomComponent

Строим приложение без версионирования

@component + useCustomComponent

Строим stateless-утилитарный компонент

createComponent (без Custom Element)

// Стандартный путь — для компонентов внутри приложения
@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);