Как стать автором
Обновить

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

Время на прочтение11 мин
Количество просмотров3.4K

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

Теги:
Хабы:
Всего голосов 5: ↑4 и ↓1+3
Комментарии12

Публикации