Как мы заменили заголовок и навигацию Bronto приложением React «Micro Frontend»

Начиная с сегодняшнего дня, Bronto повсеместно поставляет нашим клиентам обновленный макет приложения [1] [2] [3]. Мы приняли ряд ключевых решений по внедрению, которые помогли нам сделать это на перспективу, одновременно управляя рисками капитального ремонта нашего устаревшего PHP-приложения. Мы сделали это довольно новым и интересным способом, о котором я хотел бы поделиться некоторыми подробностями.

Задний план

Bronto начала планировать свою инициативу по редизайну где-то в конце февраля или начале марта. Тем не менее, проекты не были нарисованы, а объем четко не определен, по крайней мере, еще пару месяцев. Однако в начале июня команда разработчиков почувствовала, что у нас достаточно информации, чтобы попытаться создать прототип. Используя разрабатываемую нами инфраструктуру «Micro Frontend» (MFE) [4], мы продемонстрировали независимо развернутое и асинхронно загружаемое приложение React, управляющее макетом устаревшей страницы, которая была загружена скелетом [5]. Не беспокойтесь. Я объясню. Достаточно сказать, что мы внесли минимальные изменения в наш унаследованный монолит, максимально использовали передовые технологии и сделали большой шаг вперед в нашем плане по созданию нашего веб-приложения вне PHP. Мы обдумали уроки, извлеченные из прототипа, и загрузили приложение React с именем @bronto/emerald-layout, которое мы здесь описываем.

Асинхронный поток

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

GET https://app.bronto.com/mail/index/dashboard

Сторона сервера

  1. Наше PHP-приложение будет определять, следует ли отображать наш новый макет для данного запроса, используя шаблон спецификации [6]. Когда макет неприменим к запросу, ничего не меняется, и устаревший ответ остается неизменным. Когда это применимо, мы указываем нашей веб-инфраструктуре использовать специально предназначенный макет Zend.
  2. В новом минималистичном макете Zend отсутствует наша устаревшая разметка верхнего колонтитула, навигации и нижнего колонтитула. Скорее, он предоставляет минималистский скелет с содержимым устаревшей страницы рядом с ним.
  3. Макет включает в себя тег скрипта для получения нашего «динамического загрузчика модулей» и фрагмент для динамической загрузки, инициализации и рендеринга MFE «@bronto/emerald-layout», приложения JavaScript.
  4. Макет сериализует, а html кодирует данные JSON, которые в настоящее время недоступны из API (мы работаем над этим), которые будут использоваться при начальной загрузке приложения React.

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

  1. Браузер анализирует и начинает отображать ответ сервера, включая любую устаревшую разметку страницы и скрипты. Это позволяет пользователю потенциально взаимодействовать со страницами немедленно, пока наш хром рендерится асинхронно.
  2. Браузер загружает наш динамический загрузчик модулей и начинает загрузку модуля «@bronto/emerald-layout».
  3. Когда модуль «@bronto/emerald-layout» завершает загрузку и срабатывает событие DOMContentLoaded, мы инициализируем «@bronto/emerald-layout», анализируя наши данные начальной загрузки, полученные с помощью разметки, и загружая кеш GraphQL.
  4. React отображает «@bronto/emerald-layout» в элементе #emeraldLayout, разрушая скелет макета и передавая контроль над большей частью страницы нашему приложению React.
  5. Компонент React «PrimedContent» берет элемент #emeraldLayoutPrimedContent и переопределяет его как родитель внутри и внутри дерева «@bronto/emerald-layout».

Изобилие осторожности

Приступая к этому проекту, у нас было ощущение, что нам нужно подходить к делу по-другому. Пока мы разрабатывали этот новый макет, мы хотели избежать случайного внесения регрессий в существующий макет. Нам нужно было двигаться быстрее, чем обычно, с независимым циклом разработки и снизить риски, связанные с изменением нашей большой базы кода PHP. В этом духе мы свели к минимуму влияние наших дополнений на существующий поток выполнения PHP. Тот небольшой тщательный рефакторинг, который мы сочли необходимым, был хорошо спланирован и тщательно изучен. Мы ввели абстракцию LayoutProcessor в поток запросов, извлекли и сохранили существующий поток в файле LegacyLayoutProcessor. После того, как этот рефакторинг был завершен, мы представили спецификацию для запуска нового процессора, который на первых этапах запускался просто переключением функций.

/**
 * Find and create the first layout processor capable of processing
 * this request.
 * @param \Req $request
 * @return \Bronto\Platform\Layout\LayoutProcessor
 */
public function create(\Req $request)
{
   if ($this->emeraldLayoutSpecification->isSatisfiedBy($request)) {
       return new EmeraldLayoutProcessor($request);
   } else {
       return new LegacyLayoutProcessor($request);
   }
}

При таком подходе мы смогли разветвить и скопировать любые .phtml файлов и классов, необходимых для завершения обработки запроса, в «изумрудное» пространство имен. Мы эффективно изолировали новый макет и его сложности, и мы могли безопасно вносить радикальные изменения без какого-либо риска для нормального потока запросов.

Загрузка скелета

Чтобы отделиться от нашего устаревшего приложения, мы решили использовать гибрид разметки, созданной сервером, и разметки, управляемой клиентом. Этот подход означает, что наш макет React загружается и отображается асинхронно. Чтобы избежать мерцания пользовательского интерфейса или снижения воспринимаемой производительности, мы отображаем скелетный заголовок и панель навигации при загрузке страницы, которые соответствуют размерам нашего приложения React. В усеченном примере шага № 2 на стороне сервера вы можете видеть, что элемент #emeraldLayout, который будет уничтожен React, включает разметку, стиль которой соответствует размерам и цветам конечного результата.

<html>
  <head>
    <title>Bronto - Home Dashboard</title>
  </head>
  <body>
    <div id="emeraldLayout">
      <!-- The Skeleton Chrome! -->
      <div id="emeraldNavBar" style="position: absolute; top: 0px; height: 100%; width: 248px; background-color: #FFFFFF;" />
      <div id="emeraldAppBar" style="position: absolute; width: 100%; height: 64px; background-color: #343A40;" />
    </div>
    <div id="emeraldPrimedContent" />
      <!-- Legacy Page Content Gets Inserted Here! -->
    </div>
  </body>
</html>

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

Раздельные развертывания, динамическая загрузка

Первым и, осмелюсь сказать, наиболее важным решением, которое мы приняли в нашем плане реализации, было использование нашей инфраструктуры MFE [4] для независимого развертывания и загрузки модулей JavaScript. Это освободило нас от цикла выпуска нашего устаревшего монолита и позволило нам развиваться быстрее и не опасаясь случайных и каскадных регрессий. На самом деле, большая часть прироста эффективности нашей команды стала результатом такого разделения [8].

Мы разработали плагин и оболочку для SystemJS [9] (наш «динамический загрузчик модулей»), которые позволяют нам загружать модули JavaScript, развернутые активы которых будут разрешены во время выполнения. Это позволяет нам развертывать модули «микроинтерфейса» независимо от загружаемого PHP-приложения, загружая и извлекая текущую активированную версию актива во время выполнения. Если мы разделим фрагмент кода до его ядра, то, что мы включим в <body>, будет выглядеть так.

<script type="text/javascript">
  (function() {
    loader.init()
      .import("@bronto/emerald-layout/application.js")
      .then(function(EmeraldLayout) {
        (new EmeraldLayout())
          .init()
          .then(function(app){
            app.render(
              document.getElementById('emeraldLayout')
            );
          });
      });
  })();
</script>

Наш динамический загрузчик сначала запрашивает манифест приложения «@bronto/emerald-layout» (app-manifest.json), который обеспечивает сопоставление именованных ресурсов с URL-адресами ресурсов с отпечатками пальцев, доступными и кэшируемыми. Манифест приложения MFE может быть изменен и в конечном итоге будет разрешен из поддерживающего его микросервиса. Развертывая новые версии микросервиса «@bronto/emerald-layout», мы переназначаем наши ресурсы на стороне клиента и указываем браузеру получить и отобразить новую версию нашего приложения React.

GET /@bronto/emerald-layout/app-manifest.json
{
  "version": "1.1.1",
  "assets": {
    "application.js": 
      "@bronto/emerald-layout/static/js/application.3a9cee90.js",
    "application.js.map": 
      "@bronto/emerald-layout/static/js/application.3a9cee90.js.map"
  }
}

Мы ожидаем, что подключаемый модуль SystemJS запросит app-manifest.json приложения MFE, а затем динамически разрешит актив с отпечатком пальца, который можно навсегда кэшировать. После того, как мы разрешили URL-адрес актива, мы позволяем SystemJS загрузить модуль. Эта динамическая загрузка (и изоляция, которую она обеспечивает) позволяет нам доставлять обновления макета гораздо быстрее и с большей уверенностью, чем если бы мы вносили изменения в файлы .php и .phtml, общие для всех Bronto.

Асинхронный рендеринг на стороне клиента с рендерингом на сервере, устаревшее содержимое

В результате нашей динамической загрузки и использования React мы вынуждены учитывать асинхронное поведение в основных частях взаимодействия с нашими пользователями. Мы полностью намерены перенести MFE-приложения на JavaScript, отображаемый на сервере, но в настоящее время мы поддерживаем страницы, отображаемые PHP, которые основаны на каталоге старых технологий и фреймворков. Итак, мы хотели гибридное серверное и клиентское приложение, которое могло бы быстро получать и отображать наше приложение React, позволяя пользователю взаимодействовать с устаревшим контентом, как только он станет доступным. Мы приняли два решения, критически важных для поддержки этого, казалось бы, парадоксального требования.

Отображение содержимого страницы сразу, управление позже

В нашем первоначальном прототипе мы решили скрыть содержимое страницы с помощью display: none до тех пор, пока приложение React не инициализируется и не отобразится. Я хотел бы отдать должное Олексию Кващенко за то, что он подтолкнул нас к скелетной модели загрузки, которая позволяет пользователям взаимодействовать с устаревшими страницами, как только они становятся доступными (независимо от нашего асинхронного макета). Мы используем простой компонент, который извлекает элемент #emeraldLayoutPrimedContent и перемещает его в приложении React для поддержания иерархии элементов DOM.

export class PrimedContent extends Component {
  rootEl = React.createRef();
  
  componentDidMount() {
    const { primedContentElement } = this.props;
    this.rootEl.current.appendChild(primedContentElement);
    // Clear any provisional styling from the skeleton 
    primedContentElement.setAttribute("style", "");
  }
  render() {
    return <div ref={this.rootEl} />;
  }
}

В настоящее время наше приложение «@bronto/emerald-layout» будет отображать себя поверх устаревших страниц, предоставляя <PrimedContent> в качестве дочернего элемента.

ReactDOM.render(
  <EmeraldLayoutComponent>
    <PrimedContent
      primedContentElement={
        document.getElementById("emeraldLayoutPrimed")
      }
    />
  </EmeraldLayoutComponent>,
  rootNode
);

По мере того, как мы движемся к созданию приложений и страниц за пределами нашего устаревшего приложения php, мы сможем отображать их, не ожидая устаревшего, «загрунтованного» контента. Нам нужно только указать другого ребенка. Таким образом, будущие приложения MFE смогут отображать себя в нашем макете без участия PHP.

export const EmeraldLayoutComponent = props => {
  return (
    <div>
      <Header />
      <Sidenav />
      <main>
        {props.children}
      </main>
    </div>
  );
};

Заполните кэш клиентских данных закодированным JSON

В нашем исторически управляемом сервером веб-приложении отсутствует API общего назначения для использования нашим клиентским приложением. Нам нужно было быстро передавать данные в наше асинхронное приложение, и мы хотели избежать создания одноразовых API-интерфейсов на PHP. Следуя рекомендациям OWASP по предоставлению сериализованного JSON в разметке[10], мы отправляем большое количество встроенных данных в HTML-ответ сервера. JSON кодируется в формате html и записывается в скрытые элементы.

<div id="mfeDataUser" style="display: none;">    {&quot;username&quot;:&quot;patrick.winters&quot;}
</div>

Состояние ссылки Apollo [11] и закодированный JSON позволили нам загрузить приложение с подготовленным кешем данных GraphQL. Наше приложение React, как правило, может отображать заголовок и навигационную информацию сразу после динамической загрузки и рендеринга.

Сохраняйте активы, открывайте новые

По общему признанию, заголовок поста ироничен. В Bronto у нас есть очень мощное и многофункциональное веб-приложение. Мы хотим сохранить как можно больше, заложив фундамент на будущее. Я полагаю, что мы тщательно шли по линии с нашим редизайном макета, удивительно хорошо соблюдая баланс между затратами, рисками и возможностями переписывания программного обеспечения. Проделав иглу, нам удалось доставить это замечательное обновление клиентам, одновременно заложив основу, необходимую для ускорения будущих планов. Мы предоставляем современный асинхронный пользовательский интерфейс наряду с нашим существующим устаревшим пользовательским интерфейсом; и мы смогли сделать это быстро и уверенно!

использованная литература

[1] Новая навигация делает опыт пользователей Bronto еще лучше. http://blog.bronto.com/product/new-navigation-makes-bronto-users-experiences-even-better/

[2] Попробуйте новый способ навигации по Бронто. https://www.youtube.com/watch?v=nHNAF0QpNQo

[3] [Вебинар] Испытайте новый способ навигации по Бронто https://www.youtube.com/watch?v=6ackcMmXGV0

[4] Манифест Micro Frontend. https://medium.com/@patrick.winters/the-micro-front-end-manifesto-fd65f5984d20

[5] Улучшите свой UX с помощью Skeleton Pattern. https://medium.com/@rohit971/boost-your-ux-with-skeleton-pattern-b8721929239f

[6] Шаблон спецификации. Википедия. https://en.wikipedia.org/wiki/Specification_pattern

[7] React — интеграция с другими библиотеками. https://reactjs.org/docs/integrating-with-other-libraries.html

[8] Дисциплина — это мост между целями и достижениями. https://medium.com/@patrick.winters/discipline-is-the-bridge-between-goals-and-accomplishment-7015f95793a8

[9] https://github.com/systemjs/systemjs

[10] https://www.owasp.org/index.php/XSS_(Cross_Site_Scripting)_Prevention_Cheat_Sheet#RULE_.233.1_-_HTML_escape_JSON_values_in_an_HTML_context_and_read_the_data_with_JSON.parse

[11] https://github.com/apollographql/apollo-link-state