Как стать автором
Обновить
353.47
Рейтинг
Яндекс
Как мы делаем Яндекс

Микрофронтенды и виджеты в 2021-м. Доклад Яндекса

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

— Всем привет! Меня зовут Леша. Я хочу с вами сегодня обсудить немного «перехайпленную» тему — микрофронтенды.

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

Микрофронтенды — дальнейшее развитие этой идеи. Мы наше монолитное фронтенд-приложение также разделяем на независимые куски, независимые сервисы. Каждый сервис делается выделенной командой. Это может быть команда, у которой собственный фронтенд и бэкенд.

Идея микросервисов и микрофронтендов не то чтобы очень новая. В Яндексе когда-то давно был проект Яндекс.Виджеты: можно было из библиотеки виджетов собрать себе персонализированную главную страничку Яндекса. В библиотечке были виджеты, которые делались как нами, так и сторонними компаниями.

Мне нравится думать, что микрофронтенды — это такие виджеты, которые перепридумали зумеры. Разница на самом деле не очень большая. Виджет — это выделенный кусочек интерфейса, который куда-то встраивается. А идея микрофронтендов в том, что мы всё наше приложение собираем из таких небольших виджетов.

В Яндексе эта концепция тоже нашла свое место. Один из примеров такой идеи — страница поисковой выдачи. Хотя вам может показаться, что это одна большая монолитная страница, на самом деле она состоит из нескольких достаточно независимых виджетов, которые разрабатываются виртуальными командами. Виджеты эти у нас называются колдунщиками.

Я за время работы в компании тоже успел поработать над несколькими проектами, которые в целом находятся в концепции микрофронтендов. (...) Cейчас я занимаюсь разработкой видеоплеера, который вы уже видели на слайде. Это виджет, который воспроизводит видео и трансляции.

Евангелистом идеи микрофронтендов я совершенно не являюсь. У этой идеи, как и у всех, есть свои плюсы и минусы. Давайте мы с вами о них поговорим.

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

Какие у этой концепции проблемы? Она сильно усложняет код, дает дополнительные накладные расходы на интеграцию и взаимодействие виджетов в микрофронтендах между собой, накладывает требования по обратной совместимости — чтобы API, через который виджеты общаются, при релизах не ломался и ваше приложение не развалилось. Свобода в технологическом стеке — наверное, тоже своего рода минус. Думаю, вам не очень хотелось бы пользоваться сайтом, который для отрисовки одного кусочка грузит Angular, а для другого — React, это будет работать не слишком быстро. Так что свобода — это одновременно и плюс, и минус.

Зачем вам эту концепцию использовать? Я для себя на этот вопрос ответил двумя пунктами. Первый: у вас большое приложение и несколько независимых команд разработки.

Второй: вы делаете встраиваемые виджеты, которые работают не только в микрофронтенде, а еще встраиваются на ваши сайты или, например, на сайты партнеров.

Но это все лирика. Поговорим о том, как технически реализовать микрофронтенды так, чтобы они обеспечивали все бонусы, о которых мы говорили, и при этом все работало производительно и быстро.

Как технически реализовать микрофронтенды/виджеты?


declare const DoggyWidget: {
    init: ({
        container: HTMLElement,
    }) => DoggyWidgetInstance;
}

declare interface DoggyWidgetInstance {
    destroy(): void;
    updateDoggy(): void;
}

В качестве примера возьмем вот такой простенький виджет DoggyWidget, он лежит по ссылке на GitHub. Виджет рисует картинку и кнопочку. Картинка принимает размеры контейнера, куда вы виджет вставили, и показывает рандомную фотографию собаки. Кнопка при нажатии меняет эту фотографию на другую рандомную. У нашего виджета будет API, с помощью которого с ним можно будет как-то взаимодействовать.

Из чего он будет состоять? В первую очередь он будет декларировать глобальный namespace DoggyWidget, в котором будет фабрика и с помощью которого можно создать инстанс этого виджета. У инстанса будет два метода. Первый метод — destroy, который при вызове удалит виджет со страницы и почистит всё, что он успел сделать с DOM-ом. Второй метод — updateDoggy, который делает то же самое, что нажатие на кнопку, а именно меняет картинку.

Давайте подумаем, как такой виджет реализовать.

<script>


Первая идея «в лоб»: наш виджет будет отдельным скриптом.

class Widget {
    constructor({ container }) {
        this.container = container;
        container.classList.add('doggy-widget');

        this._renderImg();
        this._renderBtn();

        this.updateDoggy();
    }

 …

}

Давайте инстанс виджета определим с помощью класса. У класса будет конструктор, который принимает конфиг. В конфиге есть контейнер, где виджет должен рисоваться. Мы на этот контейнер навесим className, вызовем два метода — для отрисовки картинки и для отрисовки кнопки — и вызовем updateDoggy, который поставит начальную картинку при инициализации виджета.

    _renderImg() {
        this.img = document.createElement('img');
        this.img.classList.add('doggy-widget__img');
        this.img.alt = 'doggy';
        this.container.appendChild(this.img);
    }

Что будет делать renderImg? Он будет создавать тег img, навешивать на него className и аппендить его в контейнер.

    _renderBtn() {
        this.btn = document.createElement('button');
        this.btn.classList.add('doggy-widget__btn');
        this.btn.addEventListener('click', () => this.updateDoggy());
        this.container.appendChild(this.btn);
        this.btn.innerText = 'New doggy!';
    } 

renderBtn будет делать примерно то же самое, только он будет создавать не img, а кнопочку.

    updateDoggy() {
        const { width, height } = this.container.getBoundingClientRect();
        const src = `https://placedog.net/${width - 10}/${height - 10}?random=${Math.random()}`;
        this.img.src = src;
    }

И у нас еще есть публичный API. updateDoggy определяет параметры контейнера, куда мы вставили виджет, конструирует ссылку на изображение. Я здесь буду использовать сервис placedog.net, который подставляет рандомные плейсхолдеры с фотками собак. Метод src ставит тег img.

    destroy() {
        this.container.innerHTML = '';
        this.container.classList.remove('doggy-widget');
    }

destroy будет очень простой — он будет подчищать innerHTML у контейнера и снимать с него className, который мы поставили в конструкторе.

(() => {

    class Widget {
        ...
    }

    window.DoggyWidget = {
        init(config) {
            return new Widget(config);
        }
    }
})();

Напишем код, с помощью которого виджет будет вставляться. Мы его содержимое обернем в IIFE, чтобы спрятать класс виджета в замыкание, и определим в нем глобальный namespace DoggyWidget, в namespace будет функция init — фабрика, которая вернет нам инстанс виджета.

<script src="doggy-widget.js"></script>
<link rel="stylesheet" href="doggy-widget.css">

<div id="widget-1"></div>
<div id="widget-2"></div>

<script>
    const widget1 = DoggyWidget.init({ 
        container: document.getElementById('widget-1'),
    });
    const widget2 = DoggyWidget.init({ 
        container: document.getElementById('widget-2'),
    });
</script>

Как это все будет ставиться на страничку? Вот два файла: doggy-widget.js с JS-кодом, который мы разобрали, и doggy-wodget.css со стилями для виджета.

Мы заведем два div, и в каждый из них вставим виджет через DoggyWidget.init(), который мы тоже в doggy-widget.js описали.

Ссылка со слайда

Это все будет выглядеть так. У первого виджета будет updateDoggy.

Ссылка со слайда

Мы его вызовем. Он изменит нам фотографию.

Вспомним, какие бонусы нам обещают микрофронтенды. Первый бонус — изоляция виджетов.

Ссылка со слайда

        * {
            font-family: 
        Arial, Helvetica, sans-serif !important;
            font-size: 10px !important;
        }

Представим, что мы наш виджет встроили на страничку, где находится вот такой CSS-код.

Ссылка со слайда

Что произойдет, когда мы отрисуем виджет? Очевидно, у него поедет верстка, потому что у нас есть глобальный CSS selector, который для всех элементов переопределяет font-family и font-size. Так что виджет не очень хорошо изолирован от окружающего его CSS-кода.

Вы скажете, что это вредительство и такого CSS никто не пишет.


Ссылка со слайда

<link rel="stylesheet" 
      href="bootstrap.min.css">

*, ::after, ::before {
    box-sizing: border-box;
}

Окей, рассмотрим чуть более реальный пример. Мы встраиваемся на страничку, на которой используется Bootstrap, например. В Bootstrap есть такой код, который всем элементам задает box-sizing.

Предположим, мы наш виджет отрисуем на такой страничке:

Тогда у него чуть-чуть поедут размеры, потому что border переехал из-за box-sizing внутрь элемента — у нас меняются размеры виджета. Не очень классно. Получается, что наш виджет подвержен влиянию окружающего его кода, чего мы в микрофронтендах хотим избежать.

Как этого можно избежать? Первый вариант: есть достаточно старый проект cleanslate.css.

<body>
  <div class="blah">
      <!-- general content is not affected -->
      <div class="myContainer cleanslate">
          <!-- this content will be reset -->
      </div>
  </div>
</body>

Это специальный CSS reset, который перезагружает стили не на всей страничке, а только на том div, где стоит класс cleanslate. Всё, что находится внутри cleanslate, будет переопределено, у него будут дефолтные зарезеченные стили.

Либо есть более современное решение, которое использует часть спецификаций веб-компонентов, а именно Shadow DOM.

Shadow DOM — это такой способ отрисовать часть DOM-дерева изолированно и скрыто от других элементов на страничке. С помощью Shadow DOM рисуются встроенные в браузер контролы, например, input range. Если вы посмотрите на него в dev tools, там внутри в shadow root находится верстка, стилизованная с помощью CSS, который зашит в движок браузера.

    constructor({ container }) {
        this.shadowRoot = container.attachShadow(
            { mode: 'open' }
        );
        this.innerContainer = document.createElement('div');
        this.innerContainer.classList.add('doggy-widget');
        this.shadowRoot.appendChild(this.innerContainer);

        …
    }

Окей, попробуем заюзать Shadow DOM для нашего виджета. Что нам для этого нужно? В конструкторе мы приаттачим в контейнер shadowRoot, создадим еще один div, назовем его innerContainer и зааппендим его внутрь нашего shadowRoot.

    _renderImg() {
        …
        this.innerContainer.appendChild(this.img);
    }

    _renderBtn() {
        …
        this.innerContainer.appendChild(this.btn);
    }

И нам потребуется немного переделать методы renderImg(), renderBtn(). Теперь мы будем картинку и кнопку складывать не в контейнер, который нам пришел, а в innerContainer, который мы уже положили внутрь shadowRoot.

    destroy() {
        …
        this.shadowRoot.innerHTML = '';
    } 

Осталось еще немного поправить destroy. В destroy будем shadowRoot просто подчищать за собой.

Класс! Кажется, мы использовали Shadow DOM и смогли нашу верстку изолировать от другого кода.


Ссылка со слайда

В этом случае мы получим что-то такое — у нас пропали все стили.


Что именно произошло? Изоляция, которую обеспечивает Shadow DOM, работает в обе стороны: она блокирует как вредоносные стили, которые нам не нужны, так и наши собственные стили, которые мы хотим добавить. Смотрите, link с doggy widget CSS остался снаружи shadowRoot, а верстка виджета находится внутри. Соответственно, правила, которые описаны снаружи, не влияют на то, что находится внутри shadowRoot.

     constructor() {
        …
        const link = document.createElement('link');
        link.rel = 'stylesheet';
        link.href = 'doggy-widget.css';
        this.shadowRoot.appendChild(link);
        …
    }

<script src="doggy-widget.js"></script>

<link rel="stylesheet" href="doggy-widget.css">

Чтобы это полечить, нам нужно тег link класть внутрь shadowRoot. Сделать это очень просто. Создаем элемент link, ставим ему href и аппендим его внутрь shadowRoot. В коде вставки виджета на страницу отдельный CSS-файл нам уже будет не нужен, он будет подключаться в конструкторе виджета.

Ссылка со слайда

Это будет работать примерно так. По ссылочке — пример с подключенным Bootstrap, где можно посмотреть, что наш виджет по верстке получилось изолировать.

Единственная проблема, которую вы можете заметить, если откроете dev tools: на каждую инициализацию виджета появился отдельный запрос за doggy-widget.css. Здесь вам нужно будет убедиться, что у вас корректно настроено кеширование, чтобы повторно не грузить этот файл вашим клиентам.

Вроде изоляцию мы полечили. Или не совсем? Давайте немножко поиграем в шарады.

Опытные разработчики поймут, что здесь зашифрован monkey-patching. Это техника, которая нам позволяет делать прототипное наследование JavaScript, а именно изменять стандартную библиотеку. Например, через это работают полифилы. Мы можем в старый браузер притащить метод, который появился в новой спецификации, чтобы писать код, используя новые спеки. Но monkey-patching позволяет как делать хорошие штуки, так и очень сильно всё ломать.

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

var str = JSON.stringify(['haha'])
> '["haha"]'
JSON.parse(str)
> ["haha"]

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

Очевидно, если мы такую строку распарсим, то получим массив. Все хорошо.

var str = JSON.stringify(['haha'])
> '"[\"haha\"]"'
JSON.parse(str)
> '["haha"]'

А вот на сайте одного из партнеров, куда мы этот виджет встраивали, мы видели такую картину.

JSON.stringify у нас возвращал такую штуку. Тут добавились еще одни экранированные кавычки. И при парсинге этой строчки мы получали вместо массива строку. Примерно такими были выражения лиц у всех разработчиков, кто это увидел в первый раз.

Array.prototype.toJSON: () => Object

Стали разбираться, что происходит. JSON.stringify по спецификации, если у объекта определен метод toJSON, вызывает его. Метод должен вернуть сериализуемый объект, который дальше будет через JSON.stringify преобразован, чтобы получить строку.

Array.prototype.toJSON = function () {
    var c = [];
    this.each(function (a) {
        var b = Object.toJSON(a);
        if (!Object.isUndefined(b))
            c.push(b)
    });
    return '[' + c.join(', ') + ']'
}

И как раз на этом сайте мы обнаружили такой код, внимание на предпоследнюю строку. В прототип массива добавлялся метод toJSON, который возвращал не сериализуемый объект, как должно быть по спеке, а строчку.

Код этот прилетел из старой библиотеки prototype.js, это такая либа эпохи раннего jQuery, которая занимается тем, что расширяет стандартную библиотеку JavaScript для появления удобных в использовании методов.

Мы, кстати, как потом выяснилось, не единственные, кто с такой проблемой столкнулся. На Stack Overflow есть обсуждение, где предлагается эту проблему пролечить таким страшненьким кодом:

var _json_stringify = JSON.stringify;
JSON.stringify = function(value) {
    var _array_tojson = Array.prototype.toJSON;
    delete Array.prototype.toJSON;
    var r=_json_stringify(value);
    Array.prototype.toJSON = _array_tojson;
    return r;
};

Строго говоря, предлагается полечить monkey-patching еще одним monkey-patching, что не кажется очень хорошим решением.

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

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

Давайте попробуем сломать наш виджет, посмотреть, как такой мониторинг будет работать и что он нам даст.

    _renderImg() {
        const img = document.createElement(‘img');
        this.img = img;

        img.classList.add('doggy-widget__img');
        img.alt = 'doggy';
        this.container.appendChild(this.img);
 
        this.updateDoggy(img);
    }

Если помните, у нас был метод renderImg, который отрисовывал картинку. Давайте мы его сломаем, а именно удалим третью строчку, которая img кладет в поле нашего класса.

Что произойдет? Начальная отрисовка у нас отработает.

Ссылка со слайда

А вот если мы нажмем на кнопочку, то увидим exception.

window.addEventListener('error', (e) => {
    console.log('got error:', e.error);
    e.preventDefault();
});


Как этот exception можно поймать, обработать и залогировать? Что делают те сервисы, которые я показывал несколько слайдов назад? Есть глобальный ивент 'error', который срабатывает на объекте window. На него можно подписаться и получить из этого ивента объект ошибки, которая произошла и которую вы не отловили через try-catch. У ивента можно вызвать preventDefault, чтобы также скрыть красную ошибку в консольке и не пугать ваших пользователей, которые внезапно решили открыть devtools.

В нашем виджете это будет выглядеть так. При нажатии на кнопку будет срабатывать обработчик.

window.addEventListener('unhandledrejection', (e) => {
    console.log('got promise reject:', e.reason);
    e.preventDefault();
});

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

Promise.reject(new Error('bla'))

Подумаем. Если мы используем микрофронтенды и хотим мониторить ошибки, которые в каждом из виджетов происходят, все наши виджеты будут сыпать эксепшены в один и тот же обработчик:


window.addEventListener('error', (e) => {
    console.log('got error:', e.error);
    e.preventDefault();
});

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

Так что независимые мониторинги при таком подходе мы не получаем.

Давайте подведем промежуточные итоги. Что нам дает использование независимых скриптов?

У нас есть минимум накладных расходов. Каждый отдельный элемент микрофронтенда, каждый виджет, — это просто лишний скрипт. Кажется, менее накладно это сделать нельзя. Но при этом у нас плохая изоляция, мы научились изолировать верстку с помощью Shadow DOM, а JS мы изолировать не можем. И у нас нет хороших независимых мониторингов ошибок.

Для чего такая идея может пригодиться? Она в целом нормально работает в микрофронтендах. Но если мы виджет делаем как независимую библиотечку, которая встраивается не только в приложение с микрофронтендами, но и на какие-то сторонние странички, то эта идея подходит не очень хорошо, потому что мы не защищены от monkey-patching на внешних сайтах и не можем хорошо отслеживать ошибки, которые происходят с нашим виджетом.

Тем не менее, эта идея активно используется. Например, один из популярных фреймворков для построения микрофронтендов single-spa как раз на ней, в общем-то, и построен.

Что делать, если нам это все не подходит и хочется больше изоляции? Здесь поможет старая технология iframe.

<iframe>


Это тег, который позволяет на внутри вашей странички отрисовать еще один независимый документ.

(() => {
    window.DoggyWidget = {
        init({ container }) {
            const iframe = document.createElement('iframe');








        }
    }
})();

Попробуем переписать наш виджет так, чтобы он использовал iframe и изоляцию, которую он предоставляет.

(() => {
    window.DoggyWidget = {
        init({ container }) {
            const iframe = document.createElement('iframe');

            iframe.style.width = '100%';
            iframe.style.height = '100%';

            iframe.style.borderWidth = 0;
            iframe.style.display = 'block';
            iframe.src = 'https://some-url/doggy-widget.html';

            


        }
    }
})();

В фабрике init нашего виджета нам нужно будет создать iframe и повесить на него стили. Мы поставим width и height 100%, чтобы он полностью растягивался до размеров контейнера, куда его вставили. Мы переопределим ему display и поставим границу 0, потому что по дефолту браузеры рисуют border.

Внутри iframe загрузим документ, в котором будет рендериться наш виджет.

(() => {
    window.DoggyWidget = {
        init({ container }) {
            const iframe = document.createElement(‘iframe');

            iframe.style.width = '100%';
            iframe.style.height = '100%';

            iframe.style.borderWidth = 0;
            iframe.style.display = 'block';
            iframe.src = 'https://some-url/doggy-widget.html';

            container.appendChild(iframe);
            
            ...
        }
    }
})();

Осталось зааппендить этот iframe внутрь контейнера.


Ссылка со слайда

Все будет работать, виджет будет отрисовываться.

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

declare const DoggyWidget: {
    init: ({
        container: HTMLElement,
    }) => DoggyWidgetInstance;
}

declare interface DoggyWidgetInstance {
    destroy(): void;
    updateDoggy(): void;
}

Но мы кое о чем забыли. У нашего виджета есть API. У инстанса есть destroy и updateDoggy. Давайте попробуем их реализовать.

destroy() {
    this.container.innerHTML = '';
}

destroy будет суперпростой. Нам нужно будет просто почистить контейнер, если вы не используете этого парня. В IE 11 и legacy Edge есть неприятный баг, связанный с тем, что контекст JS, который работает внутри фрейма, продолжает частично жить после удаления iframe из DOM. Что значит частично? В нем ломается стандартная библиотека, перестают, например, быть доступны объекты Date, Object, Array и прочее. Но асинхронный код, сет таймауты, сет интервалы, реакция на ивенты, которая там была, продолжают работать, и вы можете в ваших мониторингах в таком случае увидеть очень странные эксепшены из IE и legacy Edge о том, что у вас вдруг пропал Date, он стал undefined.

Чтобы это обойти, нам наш iframe предварительно перед удалением его из DOM нужно будет вот таким образом почистить. Тогда IE 11 и старый Edge корректно его задестроят и остановят весь JS-код, который внутри него выполнялся.

destroy() {
    // чистим iframe для ie11 и legacy edge 
    this.iframe.src = '';
    this.container.innerHTML = '';
}


Ссылка со слайдов

Proof of concept — destroy работает.

Что еще? У нас остался updateDoggy, для него нам нужно обновить картинку, которая рисуется внутри фрейма. Соответственно, сделать какое-то действие между нашим основным документом, отправить команду внутрь iframe. Здесь есть проблема. Если iframe загружается с другого хоста, браузер заблокирует любое взаимодействие с window внутри фрейма и вы получите примерно такую ошибку.

Как же все-таки можно взаимодействовать? Для взаимодействия нужно использовать postMessage. Это API, который позволяет отправить сериализуемую команду внутрь другого window, и внутри этого window подписаться на объект message, прочитать то, что было в команде. И отреагировать на нее.

updateDoggy() {
    this.iframe.contentWindow
        .postMessage({ command: 'updateDoggy' });
}

Давайте реализуем updateDoggy через postMessage. В родительском документе у нас будет отправляться сообщение с командой updateDoggy внутрь iframe.

window.addEventListener('message', (e) => {
    if (e.data.command === 'updateDoggy') {
        widget.updateDoggy();
    }
})

И внутри iframe нам нужно будет написать вот такой код, который подписывается на события message, а если там updateDoggy, то дергает updateDoggy у виджета, который перерисует нам картинку.


Ссылка со слайдов

Посмотрим, что нам дает использование iframe. В первую очередь все взаимодействие с виджетом, который рисуется внутри iframe, становится асинхронным. postMessage — асинхронный API. До этого мы могли синхронно вызывать методы, а сейчас мы этого делать не можем.

События, которые происходят внутри iframe, наружу не всплывают. Если вы хотите реагировать, например, снаружи на то, что пользователь кликнул внутри виджета, то вам нужно отправлять postMessage наверх. Использовать addEventListener напрямую у вас не получится — событие через iframe не всплывет.

У вас появляются трудности с коллбэками. С ходу непонятно, какой конкретно виджет отправил сообщение. Предположим, на страничке несколько таких виджетов, у вас один глобальный обработчик message, несколько виджетов пишут свои постмесседжи и вам нужно как-то разделять, видеть, какой виджет отправил сообщение. Здесь придется придумать идентификаторы или нечто похожее.

И еще: iframe нельзя передвигать по DOM. Когда вы iframe детачите и аттачите обратно, он перезагружается, виджет будет перерисовываться, все запросы, которые он выполняет для инициализации, будут исполнены заново. В общем, не очень оптимально.

Что мы в итоге получаем? У нас сильно усложняется код. И еще появляются накладные расходы.

Если мы вспомним, как рисовался наш виджет, вставляемый через скрипт, это это выглядело бы так. У нас бы загружалась страничка, загружался CSS, JS. Дальше, когда виджет рисовался бы, каждый виджет запрашивал бы для себя картинку.

Если мы рассмотрим наш новый вариант с iframes, мы увидим такое. Внутри каждого виджета загрузится документ, у нас загрузится CSS, который там нужен, и JS, который внутри этого документа исполняется.

Для первого виджета, для второго. Сколько у вас их будет на странице, столько будет загрузок этих файлов?

Ссылка со слайда

Здесь могло бы помочь кеширование, но недавно браузеры сделали так, чтобы изолировать кеши друг от друга между различными сайтами. Это нужно, чтобы предотвратить трекинг посещения пользователем одного сайта с другого. То есть если на сайте номер 1 используется какая-то библиотечка, сайт номер 2 тоже может ее подключить и посмотреть через Performance API, была они ла загружена из кеша. Если да, то пользователь, скорее всего, до этого посещал сайт № 1 и это можно как-то использовать. Браузеры сейчас от такого поведения стараются пользователей защищать.

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

К чему это приводит, если мы пытаемся строить микрофронтенды на виджетах, которые загружаются каждый в независимом iframe?

https://website.ru/
    https://yastatic.net/react/16.8.4/react-with-dom.min.js

    Widget #1
        <iframe> https://widget-1.ru/
            https://yastatic.net/react/16.8.4/react-with-dom.min.js

    Widget #2
        <iframe> https://widget-2.ru/
            https://yastatic.net/react/16.8.4/react-with-dom.min.js

Допустим, у нас есть наш основной сайт, на котором подключен React. Есть виджет номер 1, на котором подключен React — допустим, даже тот же самый bundle. И виджет номер 2 с еще одного хоста, на нем тоже подключен React.

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

Итак, что мы получаем с iframe? У нас есть полная изоляция «виджетов» в CSS. Есть полная изоляция JS, потому что документы не зависят друг от друга. Есть независимые мониторинги, потому что внутри каждого iframe свой собственный window, на котором мы можем ловить ошибки.

Но при этом сильно усложнился код, поскольку появилась асинхронность.

Появились накладные расходы за дополнительными запросами, а именно за документом, который грузится внутри iframe, за HTML. И появились сложности с кешированием. iframes также требуют дополнительные браузерные ресурсы на работу, дополнительную память и дополнительное процессорное время.

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

Выглядит так, будто у нас есть два варианта: один с использованием скрипта, один с использованием iframe.

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

Здесь поможет так называемый “friendly” <iframe>. Вы еще можете встретить название same-origin <iframe>, или anonymous <iframe>.

const globalOne = window;


let iframe = document.createElement('iframe');
document.body.appendChild(iframe);
const globalTwo = iframe.contentWindow;

В чем идея? Есть глобальная область — наш текущий window. Можно создать через createElement новый iframe и зааппендить его на страничку. При этом заметьте, что я внутри этого фрейма никакой документ не загружаю, дополнительного запроса за HTML здесь не будет и внутри документа окажется пустая страничка, которую туда автоматически подложит браузер.

Теперь contentWindow этого iframe можно рассматривать как еще один независимый контекст, который мы можем использовать.

foobar.js:
window.someMethod = () => {...}

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

Вот наш скрипт foobar.js, который в глобальную область добавляет метод. Как подключить его внутрь нашего нового контекста? Создаем скрипт, ставим ему src и аппендим внутрь head нашего iframe.

const script = document.createElement(script);
script.src = 'foobar.js';
globalTwo.head.appendChild(script);

Теперь, чтобы взаимодействовать с кодом внутри этого скрипта, нам больше не нужно использовать postMessage, потому что контекст у нас same-origin:

globalTwo.postMessage();

globalTwo.someMethod();

Можно просто напрямую вызвать сам метод, и это будет работать, браузер не будет это блокировать.

Давайте попробуем, используя эту идею, переписать наш виджет еще раз.

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

А верстку мы будем рисовать в контексте родительской страницы и будем использовать Shadow DOM. Попробуем взять лучшее из двух подходов, которые мы до это рассматривали.

Как теперь будет выглядеть фабрика нашего виджета?

const iframe = document.createElement('iframe');
document.head.appendChild(iframe);

const script = document.createElement('script');
script.src = 'doggy-widget-inner.js';
const loaded = new Promise((resolve) => {
    script.onload = resolve;
});

loaded.then(() => {
    iframe.contentWindow.init(config);
})
iframe.contentDocument.head.appendChild(script);

Создаем в нем iframe, загружаем внутрь этого iframe скрипт и сохраняем promise, который зарезолвится, когда этот скрипт загрузится.

После того, как он прогрузился, мы вызовем внутри нашего виджета init и передадим его config, который отрисует виджет внутри. Нам осталось зааппендить скрипт в head нашего iframe.

Как теперь преобразуется doggy-widget-inner.js, код, который работает внутри фрейма?

window.init = (config) => {
    const widget = new Widget(config);
    window.widget = widget;
};

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

Как в итоге все будет работать? Если мы отрисуем таким способом два виджета на страничке, то получим примерно такое DOM-дерево.



Ссылка со слайдов

Для каждого виджета у нас будет в хэде скрытый friendly iframe, который пользователь не видит, но при этом код внутри него исполняется и с ним можно работать. Для каждого виджета в контейнере, который мы передали, будет использоваться shadow root, внутри которого будет находиться верстка этого конкретного виджета. Вот для первого виджета, а вот для второго.

Код целиком:

<head>
    <iframe>
        #document
            <html>
                <head>
                    <script src="doggy-widget-inner.js"></script>
                </head>
                <body></body>
            </html>
    </iframe>
    <iframe>
        #document
            <html>
                <head>
                    <script src="doggy-widget-inner.js"></script>
                </head>
                <body></body>
            </html>
    </iframe>
</head>

<body>
    <div id="widget-1">
        #shadow-root
            <link rel="stylesheet" href="doggy-widget.css">
            <div class="doggy-widget">
                <img class="doggy-widget__img"/>
                <button class="doggy-widget__btn"/>
            </div>
    </div>
    <div id="widget-2">
        #shadow-root
            <link rel="stylesheet" href="doggy-widget.css">
            <div class="doggy-widget">
                <img class="doggy-widget__img"/>
                <button class="doggy-widget__btn"/>
            </div>
    </div>

    <script src="doggy-widget.js"></script>
</body>

Что этот подход нам дает? Мы получаем:

  • Полную изоляцию наших виджетов в CSS, потому что используем Shadow DOM.
  • Полную изоляцию в JS, потому что код работает внутри выделенного iframe, и какой-либо monkey-patching в родительском документе на него никак не влияет.
  • Независимые мониторинги, потому что код виджета работает, опять-таки, в независимом window, где мы можем слушать эксепшены.
  • Работающее кеширование, так как контекст same-origin в браузере больше не изолирует кеши между виджетами.

При этом все еще есть:

  • Некоторое усложнение кода. Загрузка становится асинхронной, но гораздо лучше использовать асинхронное взаимодействие, в отличие от секьюрного фрейма, где мы использовали постмесседжи.
  • Небольшие накладные расходы — запрос за дополнительным js-файлом, который загружается внутрь iframe.
  • Необходимость в дополнительных ресурсах. Требуются дополнительные ресурсы браузера и устройства пользователя на работу отдельного контекста iframe, но они сильно меньше, чем у секьюрного фрейма.

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

Немного поговорим о том, что ждет нас в светлом будущем. Там нас ждет спецификация Realms API. Она сейчас находится в TC39 на Stage 2, это draft. Активно идет написание стандарта. Спецификация развивается. Надеемся, что скоро она перейдет на stage 3.

Что она позволяет делать? Вспомним, как мы создавали friendly frame. У нас был глобальный контекст globalOne. Мы создавали элемент iframe, аппендили его в документ и получали globalTwo — еще один независимый контекст внутри этого фрейма.

const globalOne = window;
let iframe = document.createElement('iframe');
document.body.appendChild(iframe);
const globalTwo = iframe.contentWindow;

const globalOne = window;
const globalTwo = new Realm().globalThis;

Realms позволяет это заменить на такую конструкцию. Появляется новый глобальный объект Realm. Создав инстанс Realm, вы получаете внутри него globalThis, который является как раз тем самым независимым контекстом, который при этом работает оптимальнее, чем отдельный iframe.

Как внутри Realm можно будет исполнить код? Через вызов импорта.

const realm = new Realm();

const { doSomething } = await realm.import(
    ‘./file.js'
);

doSomething();

Заимпортируем какой-нибудь JS-файл, который экспортирует метод doSomething. Его сразу можно будет вызвать, он будет работать в контексте Realm независимо от основной странички.

У этого API даже есть полифильчик, который построен как раз с использованием friendly frame. Правда, полифил, похоже, стал deprecated. Там года два уже не было коммитов, и он сильно отстал от текущего драфта спеки. Но надеюсь, с развитием спецификации его оживят.

Итоги


Используйте микрофронтендный подход с умом, только если вам это действительно нужно. Выбирайте подходящую вам техническую реализацию. И следите за спецификациями. Эта тематика активно развивается, в будущем у нас появятся более удобные способы строить микрофронтенды. Спасибо!
Теги:микрофронтендымикросервисыiframeвиджеты
Хабы: Яндекс JavaScript Интерфейсы Промышленное программирование Микросервисы
Всего голосов 26: ↑24 и ↓2 +22
Просмотры4.7K

Похожие публикации

Fullstack-разработчик
ЯндексМосква
Frontend developer
от 100 000 до 300 000 ₽ЯндексМоскваМожно удаленно
Frontend Developer Yandex.Garage
ЯндексСанкт-Петербург

Лучшие публикации за сутки

Информация

Дата основания
Местоположение
Россия
Сайт
www.yandex.ru
Численность
свыше 10 000 человек
Дата регистрации

Блог на Хабре