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);