javascript

Symbiote.js — изоморфные веб-компоненты (клиент + сервер)

  • пятница, 13 марта 2026 г. в 00:00:14
https://habr.com/ru/articles/1008822/

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

Меня зовут Алекс, и я мейнтейнер 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 проектов, не унывать.