Привет, Хабр!

Меня зовут Алекс, и я мейнтейнер Symbiote.js - библиотеки для создания UI-компонентов и изоморфных приложений на самых современных веб-стандартах. Сегодня я расскажу про наше важное мажорное обновление - версию 3.x.

Идея в двух словах

Symbiote.js - это легкая (~6.4 kb brotli) обертка над Custom Elements, которая добавляет реактивность, шаблоны и механизмы работы со слоями данных. Без Virtual DOM, без специального компилятора, без обязательного этапа сборки - компоненты можно подключать прямо через CDN.

Но главное - не размер и не отсутствие зависимостей. Главное - это слабая связанность. Весь дизайн библиотеки строится вокруг важной идеи: компонент может заранее НЕ знать о том, кто его конфигурирует, что его окружает и в каком контексте он используется. Конфигурация и данные могут приходить из HTML-разметки, из CSS, из родительского компонента в DOM-дереве, или из выделенного контекста данных - а компонент просто проверяет, к чему он готов привязаться, оказавшись в конкретном месте в конкретное время, и вступает в симбиоз.

Еще один важный момент: я знаю, что многие опасаются связываться с Shadow DOM. Так вот, в Symbiote.js, Shadow DOM - это необязательная опция. Вы можете свободно применять самые консервативные подходы к работе со стилями, можете использовать слой изоляции только там, где это необходимо и с максимальной гибкостью и эффективностью реализовать любую гибридную схему.

Чем полезна слабая связанность

Слабая связанность - это не просто абстрактный архитектурный принцип. Это конкретные сценарии, в которых жёсткие зависимости между компонентами создавали бы реальные проблемы.

Встраиваемые виджеты. Ваш компонент должен работать на чужом сайте - а вы не контролируете его стек, CSS и сборку. Если виджет требует конкретного фреймворка, провайдера или билд-пайплайна - вы усложните жизнь себе и другим. Если он активируется через жизненный цикл Custom Element и настраивается через HTML-атрибуты или CSS - он легко встроится куда угодн��.

Микрофронтенды. Несколько команд собирают разные части одного приложения. Каждая команда хочет деплоить независимо, не ломая чужой код. Это работает гораздо лучше, когда компоненты общаются через декларативные контракты (HTML-атрибуты, CSS-переменные, именованные контексты данных), а не через прямые JS-импорты и общие объекты в памяти.

CMS и no-code платформы. Контент-менеджер или дизайнер настраивает компонент через HTML-разметку или CSS, не касаясь JavaScript. Это возможно, только если компонент умеет получать конфигурацию из этих источников.

Мультикомандная разработка. Одна команда делает дизайн-систему, другая - продуктовые фичи, третья - интеграции. Чем меньше явных зависимостей между модулями, тем меньше конфликтов при слиянии и тем проще заменить любую часть.

Поддержка и развитие проекта. Слабосвязанный код более дружелюбен к изменениям. Исправление бага в одном компоненте не вызывает каскад поломок в соседних - потому, что они связаны через декларативные контракты, а не через прямые ссылки. Добавление новой фичи - это новый компонент, который подключается к существующим контекстам, а не переписывание половины приложения.

Постепенная миграция. Вы не можете переписать всё сразу. Симбиот позволяет встроить новые компоненты в существующее приложение на React, Angular или jQuery - без оберток, адаптеров и двойных рендеров, организуя обмен данными без особого вмешательства в легаси.

Symbiote.js спроектирован так, чтобы все эти сценарии работали из коробки. Ниже - конкретные механизмы.

Всё описанное ниже можно посмотреть в действии в reference-приложении с живым демо - изоморфное приложение с SSR-стримингом, SPA-роутингом, декларативным серверным Shadow DOM, локализацией и основными паттернами подключения к реактивным данным. Без сборщиков, без билд-пайплайна - чистый ESM + щепотка import maps. Хочу сделать акцент на том, что это именно демо базовых подходов, а не полноценное приложение.

Тут, также, можно посмотреть примеры и поиграть с живым кодом, без установки: https://rnd-pro.com/symbiote/3x/examples/

Конфигурация вне JavaScript

Большинство UI-библиотек навязывают похожий и уже привычный способ настройки компонентов - через пропсы или атрибуты, которые передает родительский JS-компонент. Symbiote.js расширяет эту модель: компоненты можно конфигурировать из нескольких источников данных. Все они работают одинаково прозрачно и не требуют отдельных зависимостей.

Описание биндингов в HTML-атрибутах

Любой шаблон Symbiote.js можно записать как обычный HTML, который вообще ничего не знает про JavaScript-контекст:

<div bind="textContent: myProp"></div>
<button bind="onclick: handler; @hidden: !flag">Нажми</button>

Атрибут bind - это и есть декларативное связывание элемента с реактивным состоянием компонента. Его можно написать руками в HTML-файле, сгенерировать на сервере или создать в любом шаблонизаторе. JavaScript-код компонента это не волнует - он увидит bind в DOM, подставит данные и подключит обработчики.

Хелпер html в JS-файлах просто генерирует эти атрибуты из более удобного синтаксиса:

html`<button ${{onclick: 'handler'}}>Нажми</button>`
// → <button bind="onclick: handler">Нажми</button>

Результат один и тот же - чистый HTML с атрибутами-аннотациями. Шаблон можно хранить где и как угодно: в JS-файле, в HTML-документе, на сервере. Он не привязан к контексту исполнения.

Конфигурация из CSS

Вот это - пожалуй, самая необычная фича. Компоненты могут читать CSS-переменные для инициализации состояния:

my-widget {
  --label: 'Загрузить файлы';
}

@media (max-width: 768px) {
  my-widget {
    --label: 'Загрузить';
  }
}
class MyWidget extends Symbiote {...}

MyWidget.template = html`
  <button>{{--label}}</button>
`;

Компонент использует --label из CSS. Меняете тему - меняются параметры. Сработал media-query - применяется адаптивный шаблон. Переключили класс на контейнере - новая конфигурация.

Зачем это? Несколько сценариев:

  • Продвинутые темы: параметры компонента привязаны к дизайн-токенам, а не к пропсам

  • Адаптивность: layout-параметры определяются media/container-запросами

  • Встраивание в чужой контекст: виджет конфигурируется через CSS хост-приложения без доступа к его JS

  • Каскадная модель: доступ к данным с возможностью отдельных деклараций и переопределений в разных ветках DOM дерева

  • Локализация: строки передаются через CSS-переменные - удобно для статических страниц.

  • CSP-безопасность: CSS, в отличие от inline-скриптов, обычно разрешён политиками безопасности

Внешние шаблоны

Компонент может использовать шаблон, определенный в произвольном месте HTML-документа:

class MyComponent extends Symbiote {
  allowCustomTemplate = true;
}
<template id="custom-view">
  <h1>{{title}}</h1>
  <p>{{description}}</p>
</template>

<my-component use-template="#custom-view"></my-component>

Это полезно, когда компонент предоставляет только данные и обработчики, а разные варианты разметки формируют разные представления. Короче, данные, логика и представление - максимально по разным углам.

Общение компонентов без проброса пропсов

В Symbiote.js есть несколько механизмов общения компонентов, которые не требуют явной передачи данных от родителя к потомку или между экземплярами.

Общий контекст (ctx + *)

Компоненты можно объединить в группу через HTML-атрибут - как нативный HTML-атрибут name объединяет радиокнопки:

<upload-btn ctx="gallery"></upload-btn>
<file-list  ctx="gallery"></file-list>
<status-bar ctx="gallery"></status-bar>
class UploadBtn extends Symbiote {
  init$ = { '*files': [] }
  onUpload(newFile) {
    this.$['*files'] = [...this.$['*files'], newFile];
  }
}

class FileList extends Symbiote {
  init$ = { '*files': [] }
}

Три компонента, один общий контекст данных gallery и поле *files. Без общего родительского компонента, без prop drilling, без шины событий. Поставил ctx="gallery" в разметке - компоненты связались, готово.

Группу можно назначить и через CSS:

.gallery-section {
  --ctx: gallery;
}
<div class="gallery-section">
  <upload-btn></upload-btn>
  <file-list></file-list>
</div>

Это layout-driven группировка: визуальный контейнер определяет логическую связь компонентов.

Очевидный кейс применения: сложный виджет, где, к примеру, интерфейс загрузки файлов и прогресс-бар этой загрузки - находятся в разных частях DOM-дерева хост-приложения.

Pop-up binding (^)

Компонент может обратиться к свойствам ближайшего предка в DOM-дереве - без импортов, без знания о конкретном родителе:

class ToolbarBtn extends Symbiote {}

ToolbarBtn.template = html`
  <button ${{onclick: '^onAction'}}>{{^label}}</button>
`;

^onAction - Symbiote пойдёт вверх по DOM и найдёт первый компонент, у которого onAction зарегистрирован в его стейте. Как CSS-каскад, только снизу-вверх, для данных и обработчиков.

Это позволяет создавать переиспользуемые "глупые" компоненты, которые адаптируются к контексту применения:

<text-editor>
  <toolbar-btn></toolbar-btn>    <!-- получит обработчики от text-editor -->
</text-editor>

<image-editor>
  <toolbar-btn></toolbar-btn>    <!-- получит обработчики от image-editor -->
</image-editor>

Один и тот-же toolbar-btn, два разных контекста. Без условной логики, без ручного проброса пропсов, без специальной конфигурации.

Именованные контексты данных

Для ситуаций, когда нужен глобальный или выделенный под фичу стейт:

import { PubSub } from '@symbiotejs/symbiote';

// app/app.js - регистрируем один раз
PubSub.registerCtx({
  darkTheme: true,
  toDoList: [],
}, 'app');

В любом компоненте:

// Доступ:
this.$['app/darkTheme'] = false; // запись
console.log(this.$['app/darkTheme']); // чтение

// Подписка:
this.sub('app/toDoList', (items) => {
  console.log('Задачи:', items);
});

В шаблоне:

<div itemize="app/toDoList" item-tag="list-item"></div>

Три строчки регистрации - и любой компонент в приложении может читать, писать и подписываться. Без стора, без провайдера, без useContext. В reference-приложении именованный контекст app используется для глобального состояния: это список задач, тема оформления и другие данные уровня приложения.

SSR и изоморфные компоненты

Киллер фича - серверный рендеринг веб-компонентов. Один флаг, один код, один компонент, работает везде, на сервере и на клиенте:

class MyComponent extends Symbiote {
  isoMode = true;
  count = 0;
  increment() {
    this.$.count++;
  }
}

MyComponent.template = html`
  <h2 ${{textContent: 'count'}}></h2>
  <button ${{onclick: 'increment'}}>Нажми!</button>
`;
MyComponent.reg('my-component');

isoMode = true - если есть серверный контент, компонент его оживляет. Если нет - рендерит шаблон с нуля. Без условий, без 'use client'.

На сервере:

import { SSR } from '@symbiotejs/symbiote/node/SSR.js';

await SSR.init();
await import('./my-app.js');
let html = await SSR.processHtml('<my-app></my-app>');
SSR.destroy();

Hydration mismatches невозможны в принципе, бай дизайн - нет диффинга. Сервер пишет bind=-атрибуты в разметку, клиент их читает и навешивает реактивность. Никаких километровых json-ов для гидрации.

Компоненты с Shadow DOM, также, поддерживаются в SSR, через механизм Declarative Shadow DOM (DSD).

Забавный факт: недавно мне, в очередной раз, попался комментарий на Reddit, с кучей плюсов, о том, что Custom Elements - это чисто браузерный API и рендерить веб-компоненты на сервере - невозможно. Так что, друзья, тут мы с легкостью делаем невозможное. Вообще, веб-компоненты, как группа стандартов, окружены множеством мифов и заблуждений и Symbiote хорошо помогает с этими заблуждениями бороться.

Что ещё есть в новой версии?

Computed properties - вычисляемые свойства с трекингом зависимостей. Автоматический трекинг для локального контекста, явное перечисление зависимостей - для глобального.
Живой пример: https://rnd-pro.com/symbiote/3x/examples/icons-2/

Exit-анимации - CSS-анимации входа и выхода. @starting-style для появления, [leaving] для исчезновения:

task-item {
  opacity: 1;
  transition: opacity 0.3s;
  @starting-style { opacity: 0; }
  &[leaving] { opacity: 0; }
}

Живой пример: https://rnd-pro.com/symbiote/3x/examples/list/

SPA Роутер - опциональный модуль с path-based URL, параметрами, гардами и ленивой загрузкой:

AppRouter.initRoutingCtx('R', {
  home: { pattern: '/', default: true },
  user: { pattern: '/users/:id' },
  about: { pattern: '/about', load: () => import('./about.js') },
});

Keyed itemize - key-based reconciliation для списков (опционально). Может работать кратно быстрее для неизменных данных. Подробнее тут.

CSP & Trusted Types - совместимость с строгими CSP-заголовками из коробки. Подробнее тут.

Dev mode - предупреждения о проблемах в биндингах и с гидрацией. Дебаг не будет большой проблемой. Подробности...

Размер

Библиотека

Minified

Gzip

Brotli

Symbiote.js (core)

19.8 kb

7.1 kb

6.4 kb

Symbiote.js (full, с AppRouter)

24.0 kb

8.3 kb

7.5 kb

Lit 3.3

15.5 kb

6.0 kb

~5.1 kb

React 19 + ReactDOM

~186 kb

~59 kb

~50 kb

В 6.4 kb ядра уже включены: реактивность, контексты данных, динамические списки, анимации, computed properties, гидрация - все самое важное. Для сопоставимого функционала у Lit или React нужны дополнительные пакеты. Про SSR я вообще молчу.

Reference-приложение - хороший пример того, что можно получить без лишних зависимостей и абстракций: изоморфное/гибридное приложение с SSR-стримингом, SPA-роутером, динамической локализацией, SSR для Shadow DOM, темами и т.д.

Итого

Symbiote.js - это библиотека, которая значительно расширяет возможности веб-компонентов, сохраняя близость к платформе и стандартам. Конфигурация из CSS, связывание через HTML-атрибуты, контексты данных без прямых связей между компонентами в js, минимум бойлерплейта, оптимальный DX. Компоненты не знают друг о друге, но работают вместе, на клиенте и на сервере.

Если вам нужны виджеты, которые встраиваются в любое окружение, микрофронтенды без лишних заморочек, сложные гибридные приложения-агностики или переиспользуемая библиотека компонентов для разных проектов - обратите внимание на Symbiote.js.

Даже если вы не планируете использовать саму библиотеку, но увидели интересные для себя подходы - поставьте проекту звездочку, это очень помогает нам, разработчикам Open Source проектов, не унывать.