Мета-приложения и Symbiote.js

от автора

Что такое «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, будем благодарны за «звездочки» и любую активность в обсуждениях. Всем — добра и мира.


ссылка на оригинал статьи https://habr.com/ru/post/682580/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *