Всем привет! Я Сергей, фронтенд-разработчик из команды привлечения Центрального университета. Занимаюсь проектами, связанными с регистрацией абитуриентов на мероприятия, и внутренними проектами по управлению мероприятиями. 

Осенью мы ждем поступление бакалавров. Чтобы начать набор, нужно встроить форму регистрации в лендинг на CMS. Форма довольно простая: пара полей для ввода данных, диалоговое окно с текстом соглашения об обработке персональных данных и кнопка отправки данных на сервер. Для скорости работы и проверки работоспособности идеи решили встроить приложение через iframe. Но форма стала обрастать различными бизнес-требованиями, которые приносили проблемы. В статье расскажу, с какими трудностями мы столкнулись и как их решали.

Адаптивная верстка

Сейчас любой сайт имеет адаптивную верстку, и CMS не исключение. Но если мы встраиваем приложение через iframe, то компонент должен реагировать на изменение ширины страницы. Если iframe занимает 100% ширины страницы, то можно просто подписаться на событие resize в нашем Angular-компоненте или написать media query в стилях. Таким образом, мы сможем работать с адаптивностью контента внутри iframe.

Но если компонент не может занимать всю ширину, а, допустим, есть колонка в 500—600 пикселей, то придется придумывать различные ухищрения. Особенно если потребуется кастомизация адаптивности для разных страниц, например разный размер ширины колонки и разные breakpoint-ы. Внутри приложения придется вместо css-breakpoint использовать свои размеры и уметь с ними работать. Такое поведение может свести с ума, особенно при переходе от десктопной версии к мобильной и обратно.

Динамический контент приложения

Если компонент занимает фиксированный размер, то вам повезло, можете переходить к следующей проблеме. Но если вам требуется открыть диалоговое окно, выпадающий список или аккордеон, то впереди увлекательное приключение. 

К сожалению, iframe не может покинуть пределы своих изначальных размеров. Одно из возможных решений — увеличить размер iframe заранее, чтобы обеспечить запас на динамические изменения размеров контента, но такой вариант далеко не всегда может решить проблему. 

Придется реализовать какой-то отдельный скрипт при встраивании для этой логики, например для открытия диалогового окна, а также раздел с его стилями и версткой. А чтобы взаимодействовать с нашим диалоговым окном, придется использовать postMessage и добавить события для открытия и закрытия диалогового окна. 

В итоге мы получаем дополнительную логику на стороне сайта, что снова усложняет нам встраивание, особенно если встраиваемся в несколько принципиально разных сайтов.

Конфигурирование приложения

Мы хотим, чтобы наш компонент имел возможность конфигурирования. Мы ведь не хотим делать под каждую встройку новое приложение. У нас есть два пути:

Первый — добавлять queryParams в URL при описании iframe. В таком случае потребуется реализовать получение параметра из URL внутри встраиваемого приложения. Так как мы пишем приложение на Angular, то можем реализовать подписку через Router. Помимо этого, есть ограничение на количество символов в URL, которое отличается в разных браузерах. Оно превышает 2000 символов, но рано или поздно в него можно упереться. Передать какие-то типы данных, кроме строк, вряд ли получится, кириллицу придется еще и декодировать, а числа и булевы параметры нужно будет выводить из строки. 

Логика с Router-ом добавляет немного дополнительной логики в наше приложение. Каждый раз писать конфигурацию в виде URL не очень удобно. Можно написать конфигурацию в виде объекта, а чтобы не думать над тем, как разместить параметры внутри URL, можем написать функцию, которая из объекта соберет красивый и правильный URL. Чудесно! Кажется, мы решили проблему с длинным и страшным URL. Но теперь от нас потребуется дополнительно добавлять еще скрипт с нашей функцией при каждом встраивании, что тоже не очень удобно и не очень красиво.

Второй путь — воспользоваться postMessage. Казалось бы, берем нужные данные и отправляем их в метод postMessage.send(), а внутри приложения надо подписаться на них. В идеальном мире этого было бы достаточно. Но, во-первых, postMessage может передавать только строки. Если хочется передать несколько параметров, для этого мы можем отправить несколько сообщений или записать конфигурацию как объект, сериализовать перед отправкой, а при получении десериализовать. 

Во-вторых, мы не можем в любое время отправить начальную конфигурацию, мы хотим это делать в момент инициализации нашего приложения. Для этого придется делать несколько сообщений, своего рода рукопожатия. Сперва в constructor или в OnInit отправить postMessage из приложения, чтобы известить родительскую страницу о том, что компонент готов к работе и может принимать данные. На родительском сайте нужно подписаться на postMessage. И в ответ отправить еще один postMessage с конфигурацией. 

Скорее всего, postMessage будет не один, а значит, придется написать конструкцию switchCase, Map или его аналог для того, чтобы определить тип нашего сообщения. И все это надо будет разместить на странице сайта.

Сбор аналитики

Раз мы работаем с пользователями из интернета, то хотим получать и записывать события аналитики. Например, записывать в window.dataLayer события для Google Tag Manager. Поэтому идем дописывать еще событие для postMessage. 

К аналитике можно отнести сбор utm-меток, их источником может быть queryParams в URL родительской страницы, а может быть cookie на случай, если пользователь сохранил у себя страницу и решил вернуться к ней позже, но уже без queryParams. Значит, нам нужно написать свой скрипт для работы с utm-метками, который реализует нашу логику получения меток, запись в cookie и передачу их в наш iframe. И этот скрипт мы должны поместить на родительской странице, опять усложняя процесс встраивания приложения.

Тестирование

Тестирование механизма встраивания вызывает дополнительные проблемы. Если вы хотите протестировать сайт и ваш компонент с помощью end-to-end тестов или UI-тестов, то придется повозиться с селектором, чтобы получить доступ к элементам нашего компонента. А еще нужно будет писать дополнительные тесты на механизм встраивания, а если мы учли все описанные выше проблемы, то проверять такой скрипт будет довольно неприятно.

Если мы хотим протестировать наш компонент и работу с cookie, то придется поднимать приложение или сайт, в который будем встраиваться для тестов. К сожалению, cookie не работают при открытии в браузере HTML-страницы.

iframe и его альтернативы

Несмотря на описанные выше проблемы при использовании iframe, у него есть и свои плюсы: 

  1. Инкапсуляция стилей. Стили от сайта не могут никак переопределить стили нашего приложения. И это утверждение верно и в обратную сторону. 

  2. Различные механизмы обеспечения безопасности. iframe позволяет изолировать скрипты внутри сайта и внутри компонента так, что они не смогут получить доступ напрямую друг к другу. Если нет необходимости настраивать общение между страницей-хостом и iframe, а также нет необходимости в адаптивной верстке, то это идеальный вариант для быстрого и простого встраивания.

Посмотрим на альтернативы iframe.

Embed. На ум и на первые пару ссылок в поисковой выдаче приходит технология Embed, но после открытия MDN становится понятно, что придется искать дальше. С первых же строк: «Следует иметь в виду, что большинство современных браузеров прекратили поддержку плагинов. Поэтому использование `<embed>` не рекомендуется, если необходима одинаковая работа сайта для большинства пользователей».

Object. Аналог embed, позволяет встраивать видео, изображения и HTML-страницы. Последнее — то, что нам нужно. Но ситуация такая же, что и с embed. В описании указано, что лучше использовать iframe.

Portal. В 2019 году Google представила технологию Portal. Она позволяет встраивать контент аналогично iframe, но дает больше возможностей, которые ограничивали iframe. Снова нет: технология осталась в стадии экспериментальной, а значит, использовать ее на продакшене не получится.

Web components. Подкинем монетку на удачу и посмотрим внимательнее. 

Web components — совокупность различных стандартов, которая позволяет создавать пользовательские HTML-элементы со своими свойствами, методами, инкапсулированными DOM и стилями. Одна из главных фишек web components — приложение работает как обычный HTML-компонент и позволяет работать с родительской страницей напрямую. Если верить Can I use и MDN, то поддерживается широкий спектр различных методов и работает в большинстве браузеров. 

Отлично — есть интересная и живая альтернатива iframe. Полдела сделано, осталось ее изучить и попробовать на практике.

Преимущества и недостатки перехода на web components

В web components работа с localStorage, sessionStorage, cookie идет напрямую с родительской страницей. Это позволит самим сохранять данные и читать их при следующих запусках приложения. При этом не потребуется добавлять логику для обработки и проброса конфигурации внутрь приложения через postMessage. Потенциально родительская страница может удалить или изменить наши данные. В качестве решения можно подобрать специфичные для приложения префиксы, чтобы минимизировать эту потенциальную проблему.

Есть доступ напрямую к window и document, что позволяет привязываться к событиям resize или работать с media query, так как они привязаны к ширине родительской страницы и будет удобно работать с ними. Это облегчит нам работу с адаптивностью верстки нашего приложения. А еще можно сохранять различные данные в window или document. Например, если этого требуют инструменты аналитики, такие как Google Tag Manager, которые добавляют события в объект window.dataLayer или в другие поля.

Если мы можем положить что-то в window, то можем оттуда и взять. И на этот раз речь идет больше чем о строках, теперь мы можем взять уже целый объект. Например, забрать те же данные аналитики или передать конфигурацию в наше приложение. В примере используем globalThis, так как он не зависит от платформы, а в нашем контексте будет равен window:

// код на родительской странице
globalThis.myConfig = {
  field: "someValue",
};

// код внутри web component
console.log(globalThis.myConfig); // { field: "someValue" }

Кажется, что это выглядит очень просто. Доступ к родительской странице позволяет нам управлять ее DOM-ом. В большинстве случаев так делать, наверное, не стоит, но если очень надо, то почему бы и нет. Но только тс-с-с!

Еще одно преимущество — возможность использовать CSS Custom Properties для кастомизации приложения. Можно создать набор CSS-переменных, которые достаточно переопределить при настройке, не отправляя больше никаких данных в сам компонент. Получается довольно простой и удобный способ изменить тему нашего приложения и разделить ответственность, отдав настройки стилей в CSS.

Компоненты можно добавлять динамически с помощью javascript на стороне родительского сайта, если есть такая необходимость.

Пример встраивания компонента:

let element = document.createElement("my-custom-element");

document.getElementById('containter-id').appendChild(element);

Благодаря библиотеке Angular Elements появляются возможности использовать некоторые особенности самого Angular для работы с web components.

Поддерживается работа с @Input() и @Output() атрибутами компонента. Input просто описывается как атрибут HTML, можно без проблем передать в него строковое значение и обновить при необходимости с родительской страницы. Для работы с Output достаточно подписаться на событие через addEventListener. Имя события — это имя Output-поля.

// событие в компоненте Angular
@Output() someEvent = new EventEmitter<someType>();

// скрипт на родительском сайте
const customContainer = document.getElementById('custom-container');
const eventHandler = (data) => {
  // любое действие, которое потребуется
  // data.details для доступа к данным
}

customContainer.addEventListener('someEvent', eventHandler);
customContainer.removeEventListener('someEvent', eventHandler);

Поддерживается работа с ng-content, можно передать данные из родительской страницы внутрь компонента, а при использовании именованных секций получится реализовать своеобразный layout. Но компонент, который будет вставлен внутрь другого, будет распознаваться как обычный HTML, а не как компонент.

Web components позволяет оптимизировать приложение и загрузку кода. Если нам требуется встроить два одинаковых компонента, то загрузка Angular и общих библиотек произойдет один раз, тогда как iframe дважды полностью загрузит все приложение. Здесь же можно отметить, что iframe использует отдельный процесс, аналогичный новой вкладке браузера, тем самым увеличивая нагрузку на ресурсы компьютера. Чем больше iframe — тем больше нужно ресурсов. 

Мы можем создать несколько разных компонентов, но они будут объединены в один чанк. При таком разделении у нас точно также будет один раз загружен общий код для компонентов, но компоненты предоставят разную функциональность для пользователя в разных частях сайта. 

Дополнительно можно реализовать ленивую загрузку компонентов, снизив нагрузку на критический путь, а при необходимости и вовсе не загружать какие-то компоненты, если в них не будет необходимости у пользователя.

Ограничения и недостатки. Angular elements накладывает некоторые ограничения на работу самого Angular. Помимо ограничения типизации для @Input() и особенностей работы ng-content, есть еще несколько:

  1. Нельзя использовать Router и ActivatedRouter, но можно напрямую работать с window.location.

  2. Все зависимости DI работают только внутри компонента: если мы встраиваем несколько компонентов и вкладываем их один в другой на стороне родительской страницы, то у каждого из них будет свой контейнер DI.

  3. Имя селектора компонента и имя компонента для регистрации в CustomElementRegistry должны быть разными. Можно не указывать селектор компонента в декораторе Component, если компонент не будет использоваться внутри Angular-приложения. 

CustomElementRegistry — это браузерный API, который позволяет зарегистрировать новые элементы в браузере для дальнейшего их использования. В общих чертах, каждый web element наследуется от класса HTMLElement. После регистрации браузер может получить конструктор элемента и создать его как обычный элемент разметки. При необходимости можно из CustomElementRegistry узнать информацию о компоненте.

Есть ограничение на названия компонентов, есть набор правил, которым должно соответствовать имя. Для большего удобства можно выделить префикс, специфичный для приложения, как у библиотек компонентов. Это упростит придумывание имени компоненту, находить компоненты при отладке станет удобнее, и можно избежать случайного совпадения имен.

Ряд недостатков, которые относятся в целом к web components:

  1. Для каждого встраивания придется настраивать стили, так как стили из web components могут повлиять на стили родительской страницы и наоборот. Особенно актуально для работы с диалоговыми окнами и выпадающими списками: нужно будет проверять их z-index. Чтобы упростить настройку стилей, можно воспользоваться CSS-переменными.

  1. Если приложение может отправлять данные на сервер, то придется настраивать политику для работы с CORS для всех доменов, в которые будем встраивать наше приложение, так как запрос будет отправлен с родительской страницы. У iframe будет всегда свой постоянный адрес, и CORS-политики нужно будет настроить только для него.

Создание web component на Angular 

Кажется, что web components нам подходит. Реализуем его на Angular. Нам сразу же поставляют пакет для работы с web components, который называется @angular/elements. 

Если посмотреть статистику по скачиванию пакетов в npm, то получаем 3,2—3,5 млн в неделю для `@angular/core`, тогда как среднее количество скачиваний `@angular/elements` — 0,25—0,28 млн в неделю. Получается разница примерно в 12 раз, то есть чуть меньше 10% пользователей Angular пользуются веб-компонентами, это довольно хороший результат.

Сделать минимально рабочий вариант web components легко. Команда Angular сделала все возможное, чтобы все свелось к нескольким простым действиям:

  1. Добавить зависимость @angular/elements в package.json своего проекта.

  2. Создать компонент.

  3. Добавить свой компонент в CustomElementRegistry в main.js

import { createCustomElement } from '@angular/elements';
import { createApplication } from '@angular/platform-browser';
import { SomeComponent } from './custom.component';

(async () => {
  const app = await createApplication({
    providers: [], // глобальные провайдеры — то, что провайдится в root.
  });

  // создаем компонент
  const element = createCustomElement(SomeComponent, {
      injector: app.injector,
  });

  // сохраняем компонент в CustomElementRegistry
  customElements.define('custom-container', element);
})();
  1. Запускаем свое приложение командой serve.

  2. Встраиваем свой компонент в нужную нам страницу

 <!-- встраиваем стили -->
  <link rel="stylesheet" href="http://localhost:4200/styles.css"></head>

  <!-- встраиваем компонент -->
  <custom-component></custom-component> 

  <!-- встраиваем скрипты -->
  <script src="http://localhost:4200/runtime.js" type="module"></script>
  <script src="http://localhost:4200/polyfills.js" type="module"></script>
  <script src="http://localhost:4200/vendor.js" type="module"></script>
  <script src="http://localhost:4200/main.js" type="module"></script>

Готово!

Дополнительно для оптимизации можно выделить наши компоненты в отдельные чанки. Можем сделать загрузку компонентов параллельной, а еще загружать некоторые компоненты только тогда, когда это необходимо. 

Для реализации ленивой загрузки используем компонент-обертку, который сможет динамически загружать другие компоненты и встраивать их с помощью viewChild и viewContainerRef.

// добавим в шаблон ng-template.
@Component({
  template: `<ng-template #outletRef></ng-template>`,
})

class LazyWrapperComponent implements OnInit {
  private readonly injector = inject(Injector);

// с помощью ViewChild получим доступ к нашему ng-template.
  @ViewChild('outletRef', { read: ViewContainerRef })
  public outletRef!: ViewContainerRef;

  ngOnInit(): void {
    // через динамический импорт загружаем компонент
    import('./lazy.component').then((module) => {
    // после загрузки, создаем компонент
      this.outletRef.createComponent(module.LazyComponent, {
        injector: this.injector,
      });
    });
  }
}

Для удобства можно сделать коллекцию компонентов и через @Input() параметр передавать имя компонента, тем самым использовать один и тот же компонент для встраивания разных ленивых компонентов внутрь, например так:

<lazy-wrapper inner-component=”first-component”></lazy-wrapper>
<lazy-wrapper inner-component=”second-component”></lazy-wrapper>

Примеры из статьи, а также дополнительные примеры можно посмотреть на stackblitz

Заключение

Web Components — довольно интересная альтернатива старому доброму iframe. У технологии есть свои преимущества и недостатки. Имеется довольно простое API для подключения приложения к любому сайту или CMS.  

А Angular предоставляет удобный и легкий в освоении инструментарий для создания своих web components. Если учесть все особенности при работе с web components, то можно забыть про iframe.

Если у вас есть свой опыт использования Angular elements или Web Components, то смело пишите в комментариях, обсудим.

Комментарии (10)


  1. 19Zb84
    18.07.2024 08:41

    Хорошая статья. Спасибо.
    Очень рад видеть что web components по немногу набирают популярность.
    Я их начал использовать с появление первой версии.
    Мне iframe понадобился только для того, что бы запустить ещё один проект в нем.

    Если бы я начал встраивать в компонент код стороннего проекта, я бы в service worker не смог отличить файлы стороннего проекта, от основного проекта.


    1. Goodzonchik Автор
      18.07.2024 08:41

      Рад, что статья пришлась по душе.

      Не часто выпадают такие задачи, поэтому, в целом, не было понимания на каком уровне находятся web components. Очень классный и мощный инструмент, но все же имеет свои плюсы и минусы. Так что все равно приходится подбирать инструмент под задачи. Тот же Youtube встраивает свои видео через iframe.


      1. 19Zb84
        18.07.2024 08:41

        Я ни одного минуса не знаю.

        Есть полу минус, если надо сделать бандл для что бы быстрее страница собиралась, это немного сложнее и css не получится склеить в один файл.
        И при неправильной архитектуре сложнее управлять проектом.

        В остальном только плюсы. Не знаю ни одного момента, который там нельзя было бы проще реализовать, чем в других аналогах.


  1. Bigata
    18.07.2024 08:41

    "нужно встроить форму регистрации" и для этого городить?

    Неужели на нативном так трудно писать, что надо кучу д...ма к web-приложению тащить.


    1. Goodzonchik Автор
      18.07.2024 08:41

      Соглашусь, можно написать нативно. Тогда будет банд маленького размера, ну или как минимум меньше, чем нам достанется от Angular.

      Но стоит смотреть на ситуацию под разными углами. Например, форма может уже существовать в рамках другого Angular-приложения. Тогда создать вторую форму на нативных технологиях будет "дороже", как по написанию, так и по поддержке, придётся повторять одну и ту же логику дважды на разных технологиях.

      Имея под рукой UI-кит, инструменты для работы с формами и т.д., можно ускориться в процессе разработки, что тоже важно. А также готовые и проверенные инструменты уберегут нас от мелких проблем, таких как, например, баги кроссбраузерной верстки.

      Также стоит смотреть в сторону дальнейшего развития функционала. Сегодня у нас два поля и кнопка, а завтра это может быть форма со степпером или другой логикой. Придётся создавать "конструктор форм", что уже будет на порядок сложнее.


      1. Iworb
        18.07.2024 08:41

        И вот вы почти пришли к Module federation. Так встроить форму будет ещё дешевле за счёт того, что в родительском приложении уже будет готов ангуляр.


        1. 19Zb84
          18.07.2024 08:41

          Веб компоненты это  "нативный" код. Module federation в разы сложнее и требуют больше затрат чем компоненты.


          1. Iworb
            18.07.2024 08:41

            Конечно это так. Но раз выше шла речь про UI-кит, инструменты и знакомую среду, то, возможно, дойдет дело и до оптимизации. Это с одной стороны кажется, что там сложно, а по факту всего-то конфиг подкинуть в основном приложнии, которое будет говорить а что же оно отдает, и в дочернем веб компоненте, которое также будет говорить, что отдает оно. И вот при совпадении зависимостей у основного приложения и компонента дважды не будут тянуться повторящиеся библиотеки. Сейчас, может, и не важно, а потом - как знать.


  1. Marcelinka
    18.07.2024 08:41
    +1

    А как же классический вариант встройки виджета? Как нативные js-библиотеки работают, когда размещаешь контейнер, подключаешь скрипт, а он встраивает мини приложение в этот контейнер.

    Я пишу на Vue, и у нас была задача встройки калькулятора на страницы, сделали виджет. На странице нужно создать просто div с id, виджет туда встраивает маленькое Vue приложение.

    В формате виджета можно сделать даже 2 версии, одна с фреймворком, вторая без, и встраивай хоть в другое приложение, хоть на страницу отдельную, где не подключен ещё фреймворк.

    Web Components тоже прикольная тема, просто удивилась, что самый классический способ решения не упомянут.


    1. 19Zb84
      18.07.2024 08:41

      Мне требовалось быстро в реакт приложении сделать раздел в прилоении, который был написан в другом приложении на компонентах.

      Что я сделал.
      1. Поместил все реакт приложение в shadow dom
      2. Скопировал страницу написанную на веб компонентах в public директорию
      3. Подключил через слот эту станицу в реакт приложении.

      Так как приложение находится в shadow Dom в любую часть реакт приложения можно встроить любой компонент.

      Минус такого подхода появился только один.
      Надо написать адаптер для обработки асинхронной загрузки страницы.
      Я очень костыльно это сделал пока. На следующей неделе сделаю аккуратный вариант.

      Тот же самый виджет только без фрейморка и с возможностью динамического переключения любого колличества виджетов.