Что такое meta application?
Определимся сразу, что мета-приложения и мета-компоненты - это ещё не устоявшиеся в индустрии термины, а скорее предложение, которое может быть принято или отвергнуто сообществом. Самое время объяснить, что конкретно мы имеем в виду.
Meta applications - относительно независимые решения в основной структуре веб-приложения, такие как:
Виджеты
Микро-фронтенды
Элементы UI-библиотек
Приложения-надстройки
и так далее...
Это всё приложения или компоненты, решающие свою выделенную задачу, или часть более общей задачи, которые могут быть простыми или довольно сложными сами по себе, и существуют относительно независимо от архитектур и принципов разработки хост-приложений. Упс, еще один новый термин. Ну тут должно быть проще: host application - это “среда” для мета-приложения, окружение интеграции.
Встраиваемый виджет - это частный случай мета-приложения. Если вы, когда-либо, решали задачу создания встраиваемого виджета (видео или аудио плеера, галереи, чата, загрузчика файлов и тому подобного), вам должна быть знакома и основная ее проблематика: необходимо предоставить максимально универсальное решение, которое будет комфортно себя чувствовать в различных, порой весьма непредсказуемых, условиях. Нам, разработчикам веб-приложений, хватает проблем с поддержкой разных браузеров, а тут ещё и совершенно разные экосистемы, стандарты, подходы... А ещё, пользователи вашего виджета захотят его настроить под свои особые требования, изменить дизайн, включить или выключить какие-то функции...
Формулируем задачу
Существует простой и распространенный подход к интеграции виджетов: мы даем указать, через созданный нами API, элемент-контейнер, в который и добавляем всё необходимое. И все у нас хорошо, пока не выяснится, что пользователь-интегратор - использует стили, которые глобально влияют на все элементы определенного типа на странице... И вот, вы должны позаботиться о защите CSS, но таким образом, чтобы дизайн вашего решения можно было настроить снаружи каким-то вменяемым способом... А потом, клиенту потребуется использовать несколько виджетов, на одной странице и с разными настройками одновременно... А потом, вы выясните, что цикл рендеринга хост-приложения влияет на то, как ваш виджет инициализируется, и вам нужно будет объяснить пользователю, как и когда вызывать методы вашего API для того, чтобы не возникало никаких "гонок" и прочих "cannot read property X of undefined". А ещё, вам может понадобиться отобразить одну часть интерфейса в одном месте хост-приложения, а другую часть - в другом, и в разное время... В общем, никакие классические и консервативные подходы, достойных ответов, на эти вопросы, не дают.
Подытожим. Для создания виджета, нам необходимо:
Определиться с "точкой подключения", решить каким образом ваш мета-компонент, в итоге, попадает в DOM. В идеале, интеграция должна быть максимально бесшовной и универсальной, поддерживаться как на уровне прямой манипуляции объектами DOM через JavaScript, так и на уровне шаблонов разметки и генерации документа на сервере.
Разобраться с инкапсуляцией: решить что, а главное, как, мы прячем "под капот", а что оставляем доступным снаружи, в качестве API. Это касается как стилей, так и логики работы.
Решить то, каким образом виджет может быть настроен, какие настройки могут быть общими, и как предоставить каждому экземпляру виджета на странице его персональные и уникальные настройки. И желательно, чтобы это было возможно сделать в разных экосистемах: от динамических JS-приложений до статических HTML-документов.
Сделать всё вышеперечисленное, максимально доступным для пользователей способом, не усложняя и не создавая избыточных абстракций над общедоступными возможностями платформы и базовыми паттернами веб-разработки.
Постараться оставить решение максимально "лёгким", с вниманием отнестись к потреблению ресурсов.
Путь к решению
Луч света в темном царстве виджетостроения - это стандарты Custom Elements и Shadow DOM.
Одно из важнейших достоинств кастомных элементов - это встроенные методы жизненного цикла. Мы можем точно знать, когда виджет появился в DOM и когда он был удален, независимо от того, кто именно, как и когда добавил его в документ. Одной серьезной проблемой меньше - нам не нужно ничего инициализировать или удалять из памяти вручную и описывать необходимые действия в документации. Помимо этого, кастомные элементы позволяют реализовать доступ к своим API через ссылку на элемент в DOM, с учетом его положения в структуре документа и остального контекста, при этом, не используя никаких более сложных внешних абстракций.
Shadow DOM дает нам возможность надежно защитить наши стили и используемые в JS-коде селекторы от "протечек", как извне, так и наружу, что тоже очень полезно.
Но, все эти замечательные штуки, довольно низкоуровневые, и никак не помогают нам наладить обмен данными, описать реактивные шаблоны и делать всё то, что мы привыкли, будучи избалованными современными библиотеками и фреймворками. Так что, двигаемся дальше.
В принципе, мы можем использовать любую привычную нам библиотеку для разработки "тела" виджета, и потом обернуть результат в Custom Element c Shadow DOM. На этом этапе, стоит вспомнить о производительности и трафике: будет не очень хорошо, если мы используем что-то громоздкое - наши пользователи могут воспринять это негативно если для них важна эффективность и общий объем загружаемого кода. Вы, наверное, догадываетесь к чему я клоню: React или Vue, как варианты, отпадают первыми - слишком много оверхеда (~ 40K и ~ 20K gzip/br соответственно). Про Angular я вообще молчу. Давайте обозначим рамки: у нас есть кандидаты, чей добавляемый к общим цифрам размер, вписывается в ~3-5К gzip, вот на них и обратим свой пристальный взор.
Из довольно популярных, и при этом, "лёгких" вариантов, можно отметить Svelte и Preact. В экосистемах этих библиотек есть готовые решения для использования с Custom Elements, что добавляет плюсов в их копилку. Но и тут есть свои нюансы: например, не для всех приемлема зависимость от компилятора в Svelte. Вообще всё, что использует свои специфические компиляторы отпадает, если вы хотите иметь возможность гибко работать с кодом компонентов в хост-приложении напрямую (они должны уметь собираться как обычный JS). И реализация, с использованием сразу двух, принципиально разных компонентных моделей (уровень библиотеки и Custom Elements) - меня лично, очень смущает, как с архитектурной, так и с концептуальной точки зрения. Поясню: в случае, если мы создаем решение, сочетающее в себе некий набор элементов - каждый такой элемент придется "оборачивать" отдельно, и затем разбираться уже с двумя типами компонентов: внутренними и теми, которые мы отдаем для пользовательской интеграции. Это неудобно и, в определенный момент, может выйти нам боком. Идем дальше.
Далее, мы подходим к набору библиотек, специально предназначенных для работы со стандартом Custom Elements, у которых поддержка реализована на уровне генов, где компонент приложения = Custom Element.
Одна из самых популярных и подходящих под наши цели среди них - это LitElement, от разработчиков из Google. В свое время, я делал выбор именно в её пользу. Тогда, основные проблемы пришли со стороны поддержки Content Security Policy. Дело в том, что LitElement использует для подключения стилей экспериментальный интерфейс adoptedStyleSheets, который, на текущий момент, не поддерживается в Safari. И, в случае, если поддержки нет, LitElement создает в shadowRoot
компонента, тег <style>
куда добавляет CSS в виде текста, что, как вы уже, наверное, догадались, конфликтует с настройками CSP в общем случае... Увидеть проблему можно сравнив результат работы LitElement на https://lit.dev/playground/ с помощью инструментов разработчика в Safari и Firefox/Chrome. Использовать стратегию nonce
или hash
мы не можем, поскольку наше решение - встраиваемое. Говорить своим пользователям, о том, что им необходимо добавлять в настройки флаг unsafe-inline мы посчитаем дурным тоном, и начнем думать о том, как решить вопрос иначе.
Вообще, стилизация внутри Shadow DOM - это интересная тема, которой я хочу посвятить отдельную статью. Если кратко: мы можем использовать свою альтернативную реализацию добавления стилей в LitElement и починить конфликт с CSP, или не использовать Shadow DOM вовсе. Но, мы же пришли за готовым решением, верно? Начинаем сомневаться в том, что LitElement - это правильный выбор.
Кстати, для тех, кто задастся вопросом, чего я так прицепился к этим CSP, я покажу вот этот график: https://trends.builtwith.com/docinfo/Content-Security-Policy.
С опытом, мы начинаем видеть и другие недостатки LitElement:
Обработка шаблонов через механизм Tagged templates - это привязывает определения шаблона к контексту класса компонента и затрудняет манипуляции с шаблонами, даже на уровне примитивного разделения кода.
Резолвинг модулей через node.js, вместо, поддерживаемых браузерами, относительных путей, что, в режиме разработки, привязывает нас к специальному серверу и не дает использовать компоненты "на лету" в по настоящему "сыром" виде. Да, мы знаем про import-map, как и про то, что это нигде, кроме движков, основанных на Chromium, нативно не поддерживаются.
Нет встроенного решения для организации взаимодействий между разными частями мета-приложения: есть управление данными "внутри" мета-компонентов но нет "снаружи".
Общее движение от близости к нативным API в сторону усложнения: с каждым обновлением документации ты превращаешься в "LitElement-разработчика", хотя желал простоты и дзена.
В любом случае, все эти проблемы, так или иначе, решаемы. И для каждой, скорее всего, найдется даже сразу несколько возможных направлений поиска решений. Но, тут всплывает резонный вопрос: если мы уже начали лепить надстройки и кастомизации для нашей базовой библиотеки, почему бы не перестать бороться с ветряными мельницами, и не создать "уницикл", который будет ИЗНАЧАЛЬНО ПОЛНОСТЬЮ СООТВЕТСТВОВАТЬ нашим нуждам?
Решение
Итак, встречайте: Symbiote.js - библиотека, специально созданная для мета-приложений.
Как следует из названия, Symbiote - это про симбиоз. Для того, чтобы симбиоз стал возможен, при разработке мы следовали принципу максимального приближения к веб-платформе и её нативным API, при сохранении достаточного уровня удобства. Symbiote.js - изначально предназначен для создания приложений со слабосвязанной архитектурой, которая упрощает интеграцию в широкий набор сред и окружений. Symbiote.js дает высокий уровень свободы, что, конечно же, подразумевает и высокий уровень ответственности. Несмотря на общую внешнюю схожесть (динамические привязки данных в шаблонах, состояния, жизненный цикл), Symbiote.js довольно сильно отличается от своих более известных коллег концептуально. И всё дело именно в этих отличиях:
Шаблоны - это HTML. Буквально, шаблоном в Symbiote.js считается то, что браузер может сам преобразовать в объектную модель без ошибок и дополнительных действий с нашей стороны. Синтаксис шаблонов основан на HTML-тегах и их атрибутах, доступных для пост-обработки через самый обычный DOM API. Таким образом, в отличие от JSX, или даже lit-html, шаблоны могут представлять из себя обычные шаблонные литералы (строки) или отдельные HTML-файлы (если вы используете HTML-лоадер для вашего сборщика).
Данные определяются контекстом. В Symbiote.js, из коробки, есть поддержка как работы с локальными данными компонента, так и с данными из общедоступных контекстов: абстрактных (named) или сформированных с учетом положения компонента в DOM-дереве хост-приложения. Инициализация компонентов происходит после того, как они попадают в DOM, и, образно говоря, первым делом Symbiote-компонент задает вопросы: "так, куда я попал?" и "кто вокруг меня?"
Pub/sub - работа с данными реализована через простейший, как для понимания, так и в использовании, паттерн.
Shadow DOM - это опция, выключенная по умолчанию. Теневой документ - это очень мощный инструмент, дающий виджетостроителю многое. Но иногда, он-же является и источником проблем. В концепции Symbiote.js - Shadow DOM - это важная, но далеко не обязательная часть. Лично я, предпочитаю создавать теневой DOM только на "внешних рубежах", не усложняя стилизацию внутри компонентов и не добавляя лишней работы браузеру.
Синхронные динамические обновления DOM. Пакеты обновлений не нужно накапливать, чтобы потом синхронизировать их с DOM более эффективно. Симбиоту не требуется отдельный, и довольно затратный этап сравнения для внесения изменений, поскольку в нем нет Virtual DOM или каких-либо аналогов этого механизма. Для обработки участка DOM, во время инициализации шаблона, используется обычный DocumentFragment.
Этап сборки - не является необходимым. Вы можете писать свой код и сразу видеть результат в браузере, без установки каких-либо специфических зависимостей: компиляторов, специальных dev-серверов и прочего. Также, вы можете использовать и любой, привычный вам, стек или подход. Выбор за вами. Можно тестировать как все приложение, так и отдельные его компоненты, без дополнительных настроек в проекте и сайд-эффектов окружения.
Поддержка объектной модели документа. Symbiote.js не создает искусственных барьеров между DOM и вашим кодом в компонентах, а напротив, предоставляет прямой и удобный доступ.
Прогрессирующая сложность. Согласно концепции "Progressive Complexity", простые задачи должны иметь такое-же простое решение. А для решения задач сложных, не должно быть никаких концептуальных либо архитектурных ограничений. И в Symbiote.js всё именно так.
"HTML as low-code". Симбиот стимулирует перенос взаимодействий компонентов с окружением на уровень HTML и CSS, туда, где за всё отвечает браузер, а не какая-либо, специфичная для конкретного стека, js-абстракция. Симбиот позволяет строить мощный и гибкий API на уровне самых базовых сущностей платформы.
"CSS Context Properties" - вы можете инициализировать компоненты с теми данными, которые сформированы CSS-контекстом в каждом конкретном месте общего документа. Этот контекст может как наследоваться так и переопределяться на различных уровнях согласно каскадной модели.
Технологический агностицизм в генах. Это касается и экосистем и рантайма. Это касается и внешних зависимостей, которых у Symbiote.js - нет.
Примеры кода
Я приведу ряд самых общих примеров, для того, чтобы познакомить вас с основами синтаксиса и дать общее представление о DX. В следующих статьях, я планирую разобрать несколько действительно интересных кейсов, где Symbiote.js смотрится особенно выгодно.
Для подсветки HTML и CSS синтаксиса внутри шаблонных литералов, вы можете использовать специальное расширение для вашей IDE, например это: https://marketplace.visualstudio.com/items?itemName=Tobermory.es6-string-html
Простой пример, не требующий установки:
<script type="module">
import { BaseComponent } from 'https://symbiotejs.github.io/symbiote.js/core/BaseComponent.js';
class MyComponent extends BaseComponent {
init$ = {
count: 0,
increment: () => {
this.$.count++;
},
}
}
MyComponent.template = /*html*/ `
<h2>{{count}}</h2>
<button set="onclick: increment">Click me!</button>
`;
MyComponent.reg('my-component');
</script>
<my-component></my-component>
Этот код можно просто скопировать в HTML-файл и открыть в браузере.
Более сложный пример, где есть динамический рендеринг таблицы и общий Shadow-контейнер:
import { BaseComponent } from 'https://symbiotejs.github.io/symbiote.js/core/BaseComponent.js';
//// Dynamic list item component:
class TableRow extends BaseComponent {}
TableRow.template = /*html*/ `
<td>{{rowNum}}</td>
<td>Random number: {{randomNum}}</td>
<td>{{date}}</td>
`;
TableRow.reg('table-row');
//// Dynamic list wrapper component:
class TableApp extends BaseComponent {
init$ = {
tableData: [],
buttonActionName: 'Generate',
generateTableData: () => {
this.$.buttonActionName = 'Update';
let data = [];
for (let i = 0; i < 1000; i++) {
data.push({
rowNum: i + 1,
randomNum: Math.random() * 100,
date: Date.now(),
});
}
this.$.tableData = data;
},
}
TableApp.shadowStyles = /*css*/ `
table-row {
display: table-row;
}
td {
border: 1px solid #f00;
}
`;
TableApp.template = /*html*/ `
<button set="onclick: generateTableData">{{buttonActionName}} table data</button>
<table
repeat="tableData"
repeat-item-tag="table-row">
</table>
`;
TableApp.reg('table-app');
Пример с определением шаблона в разметке за пределами компонента:
<script type="module">
import { BaseComponent } from 'https://symbiotejs.github.io/symbiote.js/core/BaseComponent.js';
class MyComponent extends BaseComponent {
// Enable external template usage:
allowCustomTemplate = true;
init$ = {
title: 'Title',
clicks: 0,
onClick: () => {
this.$.clicks++;
},
};
}
MyComponent.reg('my-component');
</script>
<template id="external-template">
<h1>{{title}}</h1>
<div>{{clicks}}</div>
<button set -onclick="onClick">Click me!</button>
</template>
<my-component use-template="#external-template"></my-component>
Обратите внимание, что в первом и последнем примерах, обработчики нажатий на кнопку привязываются к шаблону по разному: Symbiote.js поддерживает два типа синтаксиса привязок, каждый из которых более удобен в определенных случаях. О том, почему это так, я более подробно расскажу в одном из следующих материалов.
Symbiote.js поддерживает TypeScript и может одинаково свободно использоваться как в TypeScript, так и в JavaScript проектах.
Заключение
На данный момент, Symbiote.js протестирован и хорошо себя зарекомендовал в достаточно сложных и разнообразных ситуациях. Но, конечно, это только начало пути, и мы находимся у истоков зарождения сообщества и экосистемы. Документация будет совершенствоваться, примеры будут пополняться, полезные инструменты разработки будут появляться.
Надеюсь, что всем, кому близки темы, касающиеся встраиваемых решений - будет, как минимум, любопытно, а как максимум...
В общем, ждем вас в/на GitHub, будем благодарны за "звездочки" и любую активность в обсуждениях. Всем - добра и мира.