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

Давайте поговорим о микрофронтендах и о встраиваемых виджетах, которые, по сути, были предшественниками концепции микрофронтендов. В докладе я рассказал о способах встраивать виджеты на страницу, об их плюсах и минусах с точки зрения изоляции и производительности кода, а также о способах применять виджеты в микрофронтендной архитектуре.— Всем привет! Меня зовут Леша. Я хочу с вами сегодня обсудить немного «перехайпленную» тему — микрофронтенды.
Если кто-то не знаком с этой концепцией, она берет свое начало от микросервисов. Микросервисы — это когда мы наш большой монолитный бэкенд делим на маленькие кусочки. Каждый кусочек независимо разрабатывается и деплоится собственной командой разработчиков.
u_bl66ipb5taudygihibvtak2uu.jpeg
Микрофронтенды — дальнейшее развитие этой идеи. Мы наше монолитное фронтенд-приложение также разделяем на независимые куски, независимые сервисы. Каждый сервис делается выделенной командой. Это может быть команда, у которой собственный фронтенд и бэкенд.
fxioup-6hou8jdu74hcfsz9mcvk.jpeg
Идея микросервисов и микрофронтендов не то чтобы очень новая. В Яндексе когда-то давно был проект Яндекс.Виджеты: можно было из библиотеки виджетов собрать себе персонализированную главную страничку Яндекса. В библиотечке были виджеты, которые делались как нами, так и сторонними компаниями.

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

ffjbboopo9wp0dwcyemmakaq2y8.jpeg
Ссылка со слайда



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

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

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


  

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

Либо есть более современное решение, которое использует часть спецификаций веб-компонентов, а именно Shadow DOM.
rxapqn0tzxxd33ku2u-hu6x1bek.jpeg
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 и смогли нашу верстку изолировать от другого кода.

fyhu55kn_xg4e3lxb2qkdmbcqbg.jpeg
Ссылка со слайда

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

9rcorhmhjwy2ebneu8rlvu396wy.jpeg
Что именно произошло? Изоляция, которую обеспечивает 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);
        …
    }


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

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

Вроде изоляцию мы полечили. Или не совсем? Давайте немножко поиграем в шарады.
riylkxfzkiil3dttgb9s4iobalc.jpeg
Опытные разработчики поймут, что здесь зашифрован 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 кладет в поле нашего класса.

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

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

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

gqjkr497_slrw4-oyx7r-qaan9g.jpeg
Как этот 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'))

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

43bxrezfgdi2ildisbmjdvdvjoi.jpeg

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

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

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

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

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

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

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

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

#shadow-root
#shadow-root

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

    © Habrahabr.ru