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